diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index d963df0b..5127d8be 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -13,13 +13,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: true ref: ${{ github.event.pull_request.head.ref }} - name: Install Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: stable @@ -33,7 +33,7 @@ jobs: run: git config --global --add safe.directory /__w/sdk-for-flutter/sdk-for-flutter # required to fix dubious ownership - name: Add & Commit - uses: EndBug/add-and-commit@v9.1.4 + uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 with: add: '["lib", "test"]' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7053b5e1..045dcbaf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,13 +11,13 @@ jobs: id-token: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: stable - name: Install dependencies run: flutter pub get - - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 # v1.7.2 - name: Publish run: dart pub publish --force diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eebc892f..56f26a0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,11 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: stable - run: flutter pub get - run: flutter analyze --no-fatal-infos --no-fatal-warnings - - run: flutter test \ No newline at end of file + - run: flutter test diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b89769..fb6b74b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,18 @@ # Change Log +## 24.1.0 + +* Added: Realtime `presences` channel and `RealtimePresence` types for presence subscriptions +* Added: `Advisor` and `Presences` services +* Added: `Insight`, `Presence`, and `Report` models with list variants +* Added: `fusionauth`, `keycloak`, and `kick` providers to `OAuthProvider` enum +* Updated: `X-Appwrite-Response-Format` header to `1.9.5` + ## 24.0.0 -* Breaking: Added `unsubscribe()`, `update()`, and `close()` for Realtime subscription lifecycle. -* Added: Added `userPhone` to the `Membership` model. -* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2`. +* Breaking: Added `unsubscribe()`, `update()`, and `close()` to Realtime subscriptions +* Added: Added `userPhone` field to `Membership` model +* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2` ## 23.1.0 diff --git a/README.md b/README.md index ef450aa3..ca43bef0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add this to your package's `pubspec.yaml` file: ```yml dependencies: - appwrite: ^24.0.0 + appwrite: ^24.1.0 ``` You can install packages from the command line: diff --git a/docs/examples/advisor/get-insight.md b/docs/examples/advisor/get-insight.md new file mode 100644 index 00000000..f224e4f6 --- /dev/null +++ b/docs/examples/advisor/get-insight.md @@ -0,0 +1,14 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Advisor advisor = Advisor(client); + +Insight result = await advisor.getInsight( + reportId: '', + insightId: '', +); +``` diff --git a/docs/examples/advisor/get-report.md b/docs/examples/advisor/get-report.md new file mode 100644 index 00000000..cdbb7541 --- /dev/null +++ b/docs/examples/advisor/get-report.md @@ -0,0 +1,13 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Advisor advisor = Advisor(client); + +Report result = await advisor.getReport( + reportId: '', +); +``` diff --git a/docs/examples/advisor/list-insights.md b/docs/examples/advisor/list-insights.md new file mode 100644 index 00000000..d9b594ef --- /dev/null +++ b/docs/examples/advisor/list-insights.md @@ -0,0 +1,15 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Advisor advisor = Advisor(client); + +InsightList result = await advisor.listInsights( + reportId: '', + queries: [], // optional + total: false, // optional +); +``` diff --git a/docs/examples/advisor/list-reports.md b/docs/examples/advisor/list-reports.md new file mode 100644 index 00000000..162d463e --- /dev/null +++ b/docs/examples/advisor/list-reports.md @@ -0,0 +1,14 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Advisor advisor = Advisor(client); + +ReportList result = await advisor.listReports( + queries: [], // optional + total: false, // optional +); +``` diff --git a/docs/examples/presences/delete.md b/docs/examples/presences/delete.md new file mode 100644 index 00000000..b79ace2c --- /dev/null +++ b/docs/examples/presences/delete.md @@ -0,0 +1,13 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Presences presences = Presences(client); + +await presences.delete( + presenceId: '', +); +``` diff --git a/docs/examples/presences/get.md b/docs/examples/presences/get.md new file mode 100644 index 00000000..9fad57c6 --- /dev/null +++ b/docs/examples/presences/get.md @@ -0,0 +1,13 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Presences presences = Presences(client); + +Presence result = await presences.get( + presenceId: '', +); +``` diff --git a/docs/examples/presences/list.md b/docs/examples/presences/list.md new file mode 100644 index 00000000..5810a298 --- /dev/null +++ b/docs/examples/presences/list.md @@ -0,0 +1,15 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Presences presences = Presences(client); + +PresenceList result = await presences.list( + queries: [], // optional + total: false, // optional + ttl: 0, // optional +); +``` diff --git a/docs/examples/presences/update.md b/docs/examples/presences/update.md new file mode 100644 index 00000000..84a64be0 --- /dev/null +++ b/docs/examples/presences/update.md @@ -0,0 +1,20 @@ +```dart +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/permission.dart'; +import 'package:appwrite/role.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Presences presences = Presences(client); + +Presence result = await presences.update( + presenceId: '', + status: '', // optional + expiresAt: '2020-10-15T06:38:00.000+00:00', // optional + metadata: {}, // optional + permissions: [Permission.read(Role.any())], // optional + purge: false, // optional +); +``` diff --git a/docs/examples/presences/upsert.md b/docs/examples/presences/upsert.md new file mode 100644 index 00000000..32acc92f --- /dev/null +++ b/docs/examples/presences/upsert.md @@ -0,0 +1,19 @@ +```dart +import 'package:appwrite/appwrite.dart'; +import 'package:appwrite/permission.dart'; +import 'package:appwrite/role.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Presences presences = Presences(client); + +Presence result = await presences.upsert( + presenceId: '', + status: '', + permissions: [Permission.read(Role.any())], // optional + expiresAt: '2020-10-15T06:38:00.000+00:00', // optional + metadata: {}, // optional +); +``` diff --git a/lib/appwrite.dart b/lib/appwrite.dart index e3c1523d..85a84fe6 100644 --- a/lib/appwrite.dart +++ b/lib/appwrite.dart @@ -39,6 +39,8 @@ part 'services/functions.dart'; part 'services/graphql.dart'; part 'services/locale.dart'; part 'services/messaging.dart'; +part 'services/presences.dart'; +part 'services/advisor.dart'; part 'services/storage.dart'; part 'services/tables_db.dart'; part 'services/teams.dart'; diff --git a/lib/channel.dart b/lib/channel.dart index 064c44d7..afc68efa 100644 --- a/lib/channel.dart +++ b/lib/channel.dart @@ -27,6 +27,8 @@ class _Team {} class _Membership {} +class _Presence {} + class _Resolved {} // Helper function for normalizing ID @@ -85,6 +87,9 @@ class Channel { static Channel<_Membership> membership(String id) => Channel<_Membership>._(['memberships', _normalize(id)]); + static Channel<_Presence> presence(String id) => + Channel<_Presence>._(['presences', _normalize(id)]); + static String account() => 'account'; // Global events @@ -94,6 +99,7 @@ class Channel { static String executions() => 'executions'; static String teams() => 'teams'; static String memberships() => 'memberships'; + static String presences() => 'presences'; } // --- DATABASE ROUTE --- @@ -169,3 +175,11 @@ extension MembershipChannel on Channel<_Membership> { Channel<_Resolved> update() => _resolve('update'); Channel<_Resolved> delete() => _resolve('delete'); } + +/// Only available on Channel<_Presence> +extension PresenceChannel on Channel<_Presence> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> upsert() => _resolve('upsert'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} diff --git a/lib/models.dart b/lib/models.dart index ca9bccc6..5a4c5dac 100644 --- a/lib/models.dart +++ b/lib/models.dart @@ -6,6 +6,7 @@ import 'enums.dart' as enums; part 'src/models/model.dart'; part 'src/models/row_list.dart'; part 'src/models/document_list.dart'; +part 'src/models/presence_list.dart'; part 'src/models/session_list.dart'; part 'src/models/identity_list.dart'; part 'src/models/log_list.dart'; @@ -20,8 +21,11 @@ part 'src/models/currency_list.dart'; part 'src/models/phone_list.dart'; part 'src/models/locale_code_list.dart'; part 'src/models/transaction_list.dart'; +part 'src/models/insight_list.dart'; +part 'src/models/report_list.dart'; part 'src/models/row.dart'; part 'src/models/document.dart'; +part 'src/models/presence.dart'; part 'src/models/log.dart'; part 'src/models/user.dart'; part 'src/models/algo_md5.dart'; @@ -55,3 +59,6 @@ part 'src/models/mfa_factors.dart'; part 'src/models/transaction.dart'; part 'src/models/subscriber.dart'; part 'src/models/target.dart'; +part 'src/models/insight.dart'; +part 'src/models/insight_cta.dart'; +part 'src/models/report.dart'; diff --git a/lib/services/advisor.dart b/lib/services/advisor.dart new file mode 100644 index 00000000..153401be --- /dev/null +++ b/lib/services/advisor.dart @@ -0,0 +1,82 @@ +part of '../appwrite.dart'; + +class Advisor extends Service { + /// Initializes a [Advisor] service + Advisor(super.client); + + /// Get a list of all the project's analyzer reports. You can use the query + /// params to filter your results. + /// + Future listReports( + {List? queries, bool? total}) async { + const String apiPath = '/reports'; + + final Map apiParams = { + if (queries != null) 'queries': queries, + if (total != null) 'total': total, + }; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.ReportList.fromMap(res.data); + } + + /// Get an analyzer report by its unique ID. The response includes the report's + /// metadata and the nested insights it produced. + /// + Future getReport({required String reportId}) async { + final String apiPath = + '/reports/{reportId}'.replaceAll('{reportId}', reportId); + + final Map apiParams = {}; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.Report.fromMap(res.data); + } + + /// List the insights produced under a single analyzer report. You can use the + /// query params to filter your results further. + /// + Future listInsights( + {required String reportId, List? queries, bool? total}) async { + final String apiPath = + '/reports/{reportId}/insights'.replaceAll('{reportId}', reportId); + + final Map apiParams = { + if (queries != null) 'queries': queries, + if (total != null) 'total': total, + }; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.InsightList.fromMap(res.data); + } + + /// Get an insight by its unique ID, scoped to its parent report. + /// + Future getInsight( + {required String reportId, required String insightId}) async { + final String apiPath = '/reports/{reportId}/insights/{insightId}' + .replaceAll('{reportId}', reportId) + .replaceAll('{insightId}', insightId); + + final Map apiParams = {}; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.Insight.fromMap(res.data); + } +} diff --git a/lib/services/presences.dart b/lib/services/presences.dart new file mode 100644 index 00000000..3018f52e --- /dev/null +++ b/lib/services/presences.dart @@ -0,0 +1,120 @@ +part of '../appwrite.dart'; + +class Presences extends Service { + /// Initializes a [Presences] service + Presences(super.client); + + /// List presence logs. Expired entries are filtered out automatically. + /// + Future list( + {List? queries, bool? total, int? ttl}) async { + const String apiPath = '/presences'; + + final Map apiParams = { + if (queries != null) 'queries': queries, + if (total != null) 'total': total, + if (ttl != null) 'ttl': ttl, + }; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.PresenceList.fromMap(res.data); + } + + /// Get a presence log by its unique ID. Entries whose `expiresAt` is in the + /// past are treated as not found. + /// + Future get({required String presenceId}) async { + final String apiPath = + '/presences/{presenceId}'.replaceAll('{presenceId}', presenceId); + + final Map apiParams = {}; + + final Map apiHeaders = {}; + + final res = await client.call(HttpMethod.get, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.Presence.fromMap(res.data); + } + + /// Create or update a presence log by its user ID. + /// + Future upsert( + {required String presenceId, + required String status, + List? permissions, + String? expiresAt, + Map? metadata}) async { + final String apiPath = + '/presences/{presenceId}'.replaceAll('{presenceId}', presenceId); + + final Map apiParams = { + 'status': status, + if (permissions != null) 'permissions': permissions, + if (expiresAt != null) 'expiresAt': expiresAt, + if (metadata != null) 'metadata': metadata, + }; + + final Map apiHeaders = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.put, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.Presence.fromMap(res.data); + } + + /// Update a presence log by its unique ID. Using the patch method you can pass + /// only specific fields that will get updated. + /// + Future update( + {required String presenceId, + String? status, + String? expiresAt, + Map? metadata, + List? permissions, + bool? purge}) async { + final String apiPath = + '/presences/{presenceId}'.replaceAll('{presenceId}', presenceId); + + final Map apiParams = { + if (status != null) 'status': status, + if (expiresAt != null) 'expiresAt': expiresAt, + if (metadata != null) 'metadata': metadata, + if (permissions != null) 'permissions': permissions, + if (purge != null) 'purge': purge, + }; + + final Map apiHeaders = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.patch, + path: apiPath, params: apiParams, headers: apiHeaders); + + return models.Presence.fromMap(res.data); + } + + /// Delete a presence log by its unique ID. + /// + Future delete({required String presenceId}) async { + final String apiPath = + '/presences/{presenceId}'.replaceAll('{presenceId}', presenceId); + + final Map apiParams = {}; + + final Map apiHeaders = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.delete, + path: apiPath, params: apiParams, headers: apiHeaders); + + return res.data; + } +} diff --git a/lib/src/client.dart b/lib/src/client.dart index 9535a309..940ddb5e 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -79,6 +79,11 @@ abstract class Client { /// Your secret dev API key. Client setDevKey(String value); + /// Set Cookie. + /// + /// The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes.. + Client setCookie(String value); + /// Set ImpersonateUserId. /// /// Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data.. diff --git a/lib/src/client_base.dart b/lib/src/client_base.dart index c806e36a..cbb1452d 100644 --- a/lib/src/client_base.dart +++ b/lib/src/client_base.dart @@ -21,6 +21,10 @@ abstract class ClientBase implements Client { @override ClientBase setDevKey(value); + /// The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes. + @override + ClientBase setCookie(value); + /// Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data. @override ClientBase setImpersonateUserId(value); diff --git a/lib/src/client_browser.dart b/lib/src/client_browser.dart index 7852c778..1eb34c10 100644 --- a/lib/src/client_browser.dart +++ b/lib/src/client_browser.dart @@ -40,8 +40,8 @@ class ClientBrowser extends ClientBase with ClientMixin { 'x-sdk-name': 'Flutter', 'x-sdk-platform': 'client', 'x-sdk-language': 'flutter', - 'x-sdk-version': '24.0.0', - 'X-Appwrite-Response-Format': '1.9.2', + 'x-sdk-version': '24.1.0', + 'X-Appwrite-Response-Format': '1.9.5', }; config = {}; @@ -95,6 +95,14 @@ class ClientBrowser extends ClientBase with ClientMixin { return this; } + /// The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes. + @override + ClientBrowser setCookie(value) { + config['cookie'] = value; + addHeader('Cookie', value); + return this; + } + /// Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data. @override ClientBrowser setImpersonateUserId(value) { diff --git a/lib/src/client_io.dart b/lib/src/client_io.dart index c705abe1..ecc5c698 100644 --- a/lib/src/client_io.dart +++ b/lib/src/client_io.dart @@ -58,8 +58,8 @@ class ClientIO extends ClientBase with ClientMixin { 'x-sdk-name': 'Flutter', 'x-sdk-platform': 'client', 'x-sdk-language': 'flutter', - 'x-sdk-version': '24.0.0', - 'X-Appwrite-Response-Format': '1.9.2', + 'x-sdk-version': '24.1.0', + 'X-Appwrite-Response-Format': '1.9.5', }; config = {}; @@ -121,6 +121,14 @@ class ClientIO extends ClientBase with ClientMixin { return this; } + /// The user cookie to authenticate with. Used by SDKs that forward an incoming Cookie header in server-side runtimes. + @override + ClientIO setCookie(value) { + config['cookie'] = value; + addHeader('Cookie', value); + return this; + } + /// Impersonate a user by ID on an already user-authenticated request. Requires the current request to be authenticated as a user with impersonator capability; X-Appwrite-Key alone is not sufficient. Impersonator users are intentionally granted users.read so they can discover a target before impersonation begins. Internal audit logs still attribute actions to the original impersonator and record the impersonated target only in internal audit payload data. @override ClientIO setImpersonateUserId(value) { @@ -233,7 +241,6 @@ class ClientIO extends ClientBase with ClientMixin { '${packageInfo.packageName}/${packageInfo.version} $device', ); } catch (e) { - debugPrint('Error getting device info: $e'); device = Platform.operatingSystem; addHeader('user-agent', device); } diff --git a/lib/src/enums/o_auth_provider.dart b/lib/src/enums/o_auth_provider.dart index dc08c932..b88e0a55 100644 --- a/lib/src/enums/o_auth_provider.dart +++ b/lib/src/enums/o_auth_provider.dart @@ -16,9 +16,12 @@ enum OAuthProvider { etsy(value: 'etsy'), facebook(value: 'facebook'), figma(value: 'figma'), + fusionauth(value: 'fusionauth'), github(value: 'github'), gitlab(value: 'gitlab'), google(value: 'google'), + keycloak(value: 'keycloak'), + kick(value: 'kick'), linkedin(value: 'linkedin'), microsoft(value: 'microsoft'), notion(value: 'notion'), diff --git a/lib/src/models/document.dart b/lib/src/models/document.dart index be08d599..ecadacfb 100644 --- a/lib/src/models/document.dart +++ b/lib/src/models/document.dart @@ -45,7 +45,7 @@ class Document implements Model { $createdAt: map['\$createdAt'].toString(), $updatedAt: map['\$updatedAt'].toString(), $permissions: List.from(map['\$permissions'] ?? []), - data: map["data"] ?? map, + data: Map.from(map["data"] ?? {}), ); } diff --git a/lib/src/models/insight.dart b/lib/src/models/insight.dart new file mode 100644 index 00000000..4527e5f1 --- /dev/null +++ b/lib/src/models/insight.dart @@ -0,0 +1,121 @@ +part of '../../models.dart'; + +/// Insight +class Insight implements Model { + /// Insight ID. + final String $id; + + /// Insight creation date in ISO 8601 format. + final String $createdAt; + + /// Insight update date in ISO 8601 format. + final String $updatedAt; + + /// Parent report ID. Insights always belong to a report. + final String reportId; + + /// Insight type. One of databaseIndex (legacy), tablesDBIndex, documentsDBIndex, vectorsDBIndex, databasePerformance, sitePerformance, siteAccessibility, siteSeo, functionPerformance. The index types are engine-specific so each CTA can pair the right service+method (databases.createIndex, tablesDB.createIndex, documentsDB.createIndex, or vectorsDB.createIndex). + final String type; + + /// Insight severity. One of info, warning, critical. + final String severity; + + /// Insight status. One of active, dismissed. + final String status; + + /// Type of the resource the insight is about. Plural noun, e.g. databases, sites, functions. + final String resourceType; + + /// ID of the resource the insight is about. + final String resourceId; + + /// Plural noun for the parent resource that contains the insight's resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent. + final String parentResourceType; + + /// ID of the parent resource. Empty when the resource has no parent. + final String parentResourceId; + + /// Insight title. + final String title; + + /// Short markdown summary describing the insight. + final String summary; + + /// List of call-to-action buttons attached to this insight. + final List ctas; + + /// Time the insight was analyzed in ISO 8601 format. + final String? analyzedAt; + + /// Time the insight was dismissed in ISO 8601 format. Empty when not dismissed. + final String? dismissedAt; + + /// User ID that dismissed the insight. Empty when not dismissed. + final String? dismissedBy; + + Insight({ + required this.$id, + required this.$createdAt, + required this.$updatedAt, + required this.reportId, + required this.type, + required this.severity, + required this.status, + required this.resourceType, + required this.resourceId, + required this.parentResourceType, + required this.parentResourceId, + required this.title, + required this.summary, + required this.ctas, + this.analyzedAt, + this.dismissedAt, + this.dismissedBy, + }); + + factory Insight.fromMap(Map map) { + return Insight( + $id: map['\$id'].toString(), + $createdAt: map['\$createdAt'].toString(), + $updatedAt: map['\$updatedAt'].toString(), + reportId: map['reportId'].toString(), + type: map['type'].toString(), + severity: map['severity'].toString(), + status: map['status'].toString(), + resourceType: map['resourceType'].toString(), + resourceId: map['resourceId'].toString(), + parentResourceType: map['parentResourceType'].toString(), + parentResourceId: map['parentResourceId'].toString(), + title: map['title'].toString(), + summary: map['summary'].toString(), + ctas: + List.from(map['ctas'].map((p) => InsightCTA.fromMap(p))), + analyzedAt: map['analyzedAt']?.toString(), + dismissedAt: map['dismissedAt']?.toString(), + dismissedBy: map['dismissedBy']?.toString(), + ); + } + + @override + Map toMap() { + return { + "\$id": $id, + "\$createdAt": $createdAt, + "\$updatedAt": $updatedAt, + "reportId": reportId, + "type": type, + "severity": severity, + "status": status, + "resourceType": resourceType, + "resourceId": resourceId, + "parentResourceType": parentResourceType, + "parentResourceId": parentResourceId, + "title": title, + "summary": summary, + "ctas": ctas.map((p) => p.toMap()).toList(), + "analyzedAt": analyzedAt, + "dismissedAt": dismissedAt, + "dismissedBy": dismissedBy, + }; + } +} diff --git a/lib/src/models/insight_cta.dart b/lib/src/models/insight_cta.dart new file mode 100644 index 00000000..7e7b1de7 --- /dev/null +++ b/lib/src/models/insight_cta.dart @@ -0,0 +1,42 @@ +part of '../../models.dart'; + +/// InsightCTA +class InsightCTA implements Model { + /// Human-readable label for the CTA, used in UI. + final String label; + + /// Public API service (SDK namespace) the client should invoke. Must match the engine that owns the resource — for index suggestions: databases (legacy), tablesDB, documentsDB, or vectorsDB. + final String service; + + /// Public API method on the chosen service the client should invoke when this CTA is triggered. + final String method; + + /// Parameter map the client should pass to the service method when this CTA is triggered. Keys match the target API's parameter names (e.g. databaseId/tableId/columns for tablesDB, databaseId/collectionId/attributes for the legacy Databases API). + final Map params; + + InsightCTA({ + required this.label, + required this.service, + required this.method, + required this.params, + }); + + factory InsightCTA.fromMap(Map map) { + return InsightCTA( + label: map['label'].toString(), + service: map['service'].toString(), + method: map['method'].toString(), + params: map['params'], + ); + } + + @override + Map toMap() { + return { + "label": label, + "service": service, + "method": method, + "params": params, + }; + } +} diff --git a/lib/src/models/insight_list.dart b/lib/src/models/insight_list.dart new file mode 100644 index 00000000..e68635dd --- /dev/null +++ b/lib/src/models/insight_list.dart @@ -0,0 +1,31 @@ +part of '../../models.dart'; + +/// Insights List +class InsightList implements Model { + /// Total number of insights that matched your query. + final int total; + + /// List of insights. + final List insights; + + InsightList({ + required this.total, + required this.insights, + }); + + factory InsightList.fromMap(Map map) { + return InsightList( + total: map['total'], + insights: + List.from(map['insights'].map((p) => Insight.fromMap(p))), + ); + } + + @override + Map toMap() { + return { + "total": total, + "insights": insights.map((p) => p.toMap()).toList(), + }; + } +} diff --git a/lib/src/models/presence.dart b/lib/src/models/presence.dart new file mode 100644 index 00000000..6b59be6f --- /dev/null +++ b/lib/src/models/presence.dart @@ -0,0 +1,74 @@ +part of '../../models.dart'; + +/// Presence +class Presence implements Model { + /// Presence ID. + final String $id; + + /// Presence creation date in ISO 8601 format. + final String $createdAt; + + /// Presence update date in ISO 8601 format. + final String $updatedAt; + + /// Presence permissions. [Learn more about permissions](https://appwrite.io/docs/permissions). + final List $permissions; + + /// User ID. + final String userId; + + /// Presence status. + final String? status; + + /// Presence source. + final String source; + + /// Presence expiry date in ISO 8601 format. + final String? expiresAt; + + final Map metadata; + + Presence({ + required this.$id, + required this.$createdAt, + required this.$updatedAt, + required this.$permissions, + required this.userId, + this.status, + required this.source, + this.expiresAt, + required this.metadata, + }); + + factory Presence.fromMap(Map map) { + return Presence( + $id: map['\$id'].toString(), + $createdAt: map['\$createdAt'].toString(), + $updatedAt: map['\$updatedAt'].toString(), + $permissions: List.from(map['\$permissions'] ?? []), + userId: map['userId'].toString(), + status: map['status']?.toString(), + source: map['source'].toString(), + expiresAt: map['expiresAt']?.toString(), + metadata: Map.from(map["metadata"] ?? {}), + ); + } + + @override + Map toMap() { + return { + "\$id": $id, + "\$createdAt": $createdAt, + "\$updatedAt": $updatedAt, + "\$permissions": $permissions, + "userId": userId, + "status": status, + "source": source, + "expiresAt": expiresAt, + "metadata": metadata, + }; + } + + T convertTo(T Function(Map) fromJson) => + fromJson(metadata); +} diff --git a/lib/src/models/presence_list.dart b/lib/src/models/presence_list.dart new file mode 100644 index 00000000..ddd87e31 --- /dev/null +++ b/lib/src/models/presence_list.dart @@ -0,0 +1,34 @@ +part of '../../models.dart'; + +/// Presences List +class PresenceList implements Model { + /// Total number of presences that matched your query. + final int total; + + /// List of presences. + final List presences; + + PresenceList({ + required this.total, + required this.presences, + }); + + factory PresenceList.fromMap(Map map) { + return PresenceList( + total: map['total'], + presences: + List.from(map['presences'].map((p) => Presence.fromMap(p))), + ); + } + + @override + Map toMap() { + return { + "total": total, + "presences": presences.map((p) => p.toMap()).toList(), + }; + } + + List convertTo(T Function(Map) fromJson) => + presences.map((d) => d.convertTo(fromJson)).toList(); +} diff --git a/lib/src/models/report.dart b/lib/src/models/report.dart new file mode 100644 index 00000000..6dd6b445 --- /dev/null +++ b/lib/src/models/report.dart @@ -0,0 +1,91 @@ +part of '../../models.dart'; + +/// Report +class Report implements Model { + /// Report ID. + final String $id; + + /// Report creation date in ISO 8601 format. + final String $createdAt; + + /// Report update date in ISO 8601 format. + final String $updatedAt; + + /// ID of the third-party app that submitted the report. + final String appId; + + /// Analyzer that produced this report. e.g. lighthouse, audit, databaseAnalyzer. + final String type; + + /// Short, human-readable title for the report. + final String title; + + /// Markdown summary describing the report. + final String summary; + + /// Plural noun describing what the report analyzes, e.g. databases, sites, urls. + final String targetType; + + /// Free-form target identifier (URL for lighthouse, resource ID for db). + final String target; + + /// Categories covered by the report, e.g. performance, accessibility. + final List categories; + + /// Insights nested under this report. + final List insights; + + /// Time the report was analyzed in ISO 8601 format. + final String? analyzedAt; + + Report({ + required this.$id, + required this.$createdAt, + required this.$updatedAt, + required this.appId, + required this.type, + required this.title, + required this.summary, + required this.targetType, + required this.target, + required this.categories, + required this.insights, + this.analyzedAt, + }); + + factory Report.fromMap(Map map) { + return Report( + $id: map['\$id'].toString(), + $createdAt: map['\$createdAt'].toString(), + $updatedAt: map['\$updatedAt'].toString(), + appId: map['appId'].toString(), + type: map['type'].toString(), + title: map['title'].toString(), + summary: map['summary'].toString(), + targetType: map['targetType'].toString(), + target: map['target'].toString(), + categories: List.from(map['categories'] ?? []), + insights: + List.from(map['insights'].map((p) => Insight.fromMap(p))), + analyzedAt: map['analyzedAt']?.toString(), + ); + } + + @override + Map toMap() { + return { + "\$id": $id, + "\$createdAt": $createdAt, + "\$updatedAt": $updatedAt, + "appId": appId, + "type": type, + "title": title, + "summary": summary, + "targetType": targetType, + "target": target, + "categories": categories, + "insights": insights.map((p) => p.toMap()).toList(), + "analyzedAt": analyzedAt, + }; + } +} diff --git a/lib/src/models/report_list.dart b/lib/src/models/report_list.dart new file mode 100644 index 00000000..f7ea39d5 --- /dev/null +++ b/lib/src/models/report_list.dart @@ -0,0 +1,30 @@ +part of '../../models.dart'; + +/// Reports List +class ReportList implements Model { + /// Total number of reports that matched your query. + final int total; + + /// List of reports. + final List reports; + + ReportList({ + required this.total, + required this.reports, + }); + + factory ReportList.fromMap(Map map) { + return ReportList( + total: map['total'], + reports: List.from(map['reports'].map((p) => Report.fromMap(p))), + ); + } + + @override + Map toMap() { + return { + "total": total, + "reports": reports.map((p) => p.toMap()).toList(), + }; + } +} diff --git a/lib/src/models/row.dart b/lib/src/models/row.dart index a825eab0..7d311490 100644 --- a/lib/src/models/row.dart +++ b/lib/src/models/row.dart @@ -45,7 +45,7 @@ class Row implements Model { $createdAt: map['\$createdAt'].toString(), $updatedAt: map['\$updatedAt'].toString(), $permissions: List.from(map['\$permissions'] ?? []), - data: map["data"] ?? map, + data: Map.from(map["data"] ?? {}), ); } diff --git a/lib/src/realtime.dart b/lib/src/realtime.dart index b8e085a5..28e4c43d 100644 --- a/lib/src/realtime.dart +++ b/lib/src/realtime.dart @@ -60,6 +60,32 @@ abstract class Realtime extends Service { /// subscription when you want to tear everything down. Future disconnect(); + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// Fire-and-forget: returns void and does not await the server response. + /// Mirrors the `subscribe()` shape — call without `await`. Throws synchronously + /// if there is no open WebSocket connection. + /// + /// ```dart + /// realtime.subscribe(['account']); + /// await Future.delayed(Duration(seconds: 1)); // let the WS open + /// realtime.upsertPresence( + /// status: 'online', + /// presenceId: 'p-1', + /// metadata: {'device': 'web'}, + /// ); + /// ``` + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }); + /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. /// /// Before the connection has been closed, this will be `null`. diff --git a/lib/src/realtime_base.dart b/lib/src/realtime_base.dart index 11c7a4ff..4690ba67 100644 --- a/lib/src/realtime_base.dart +++ b/lib/src/realtime_base.dart @@ -10,4 +10,12 @@ abstract class RealtimeBase implements Realtime { @override Future disconnect(); + + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }); } diff --git a/lib/src/realtime_browser.dart b/lib/src/realtime_browser.dart index 8ae3c740..3563e7d9 100644 --- a/lib/src/realtime_browser.dart +++ b/lib/src/realtime_browser.dart @@ -41,4 +41,19 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { }) { return subscribeTo(channels, queries); } + + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + upsertPresenceTo( + status: status, + presenceId: presenceId, + permissions: permissions, + metadata: metadata, + ); + } } diff --git a/lib/src/realtime_io.dart b/lib/src/realtime_io.dart index 6040a5fc..762787b5 100644 --- a/lib/src/realtime_io.dart +++ b/lib/src/realtime_io.dart @@ -50,6 +50,21 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { return subscribeTo(channels, queries); } + @override + void upsertPresence({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + upsertPresenceTo( + status: status, + presenceId: presenceId, + permissions: permissions, + metadata: metadata, + ); + } + // https://github.com/jonataslaw/getsocket/blob/f25b3a264d8cc6f82458c949b86d286cd0343792/lib/src/io.dart#L104 // and from official dart sdk websocket_impl.dart connect method Future _connectForSelfSignedCert( diff --git a/lib/src/realtime_mixin.dart b/lib/src/realtime_mixin.dart index a199f7fd..72edf5ee 100644 --- a/lib/src/realtime_mixin.dart +++ b/lib/src/realtime_mixin.dart @@ -30,6 +30,8 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; + Map? _pendingPresence; + bool _appConnected = false; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -44,6 +46,7 @@ mixin RealtimeMixin { Future _closeConnection() async { _stopHeartbeat(); + _appConnected = false; await _websocketSubscription?.cancel(); await _websok?.sink.close(status.normalClosure, 'Ending session'); _lastUrl = null; @@ -71,11 +74,15 @@ mixin RealtimeMixin { allChannels.addAll(subscription.channels); } + // Single-flight guard: another caller is already opening a socket. + // Both subscribe() and upsertPresence() may land here concurrently. if (_creatingSocket) { _pendingSocketRebuild = true; return; } - if (allChannels.isEmpty) return; + // Open the socket when either a subscription exists or a presence + // payload is queued (presence-only usage). + if (allChannels.isEmpty && _pendingPresence == null) return; _creatingSocket = true; final uri = _prepareUri(); try { @@ -121,14 +128,11 @@ mixin RealtimeMixin { 'queries': entry.value.queries, }; } + _appConnected = true; _sendPendingSubscribes(); + _flushPendingPresence(); _startHeartbeat(); // Start heartbeat after successful connection break; - case 'response': - // The SDK generates subscriptionIds client-side and sends them on - // every subscribe/unsubscribe, so subscribe/unsubscribe acks carry - // no state the SDK needs to reconcile. - break; case 'pong': break; case 'event': @@ -153,9 +157,11 @@ mixin RealtimeMixin { break; } }, onDone: () { + _appConnected = false; _stopHeartbeat(); _retry(); }, onError: (err, stack) { + _appConnected = false; _stopHeartbeat(); for (var subscription in _subscriptions.values) { subscription.controller.addError(err, stack); @@ -259,6 +265,7 @@ mixin RealtimeMixin { } _subscriptions.clear(); _pendingSubscribes.clear(); + _pendingPresence = null; await _closeConnection(); _reconnect = true; // allow future subscribeTo() calls to reconnect } @@ -267,6 +274,16 @@ mixin RealtimeMixin { if (_websok == null || _websok?.closeCode != null) { return; } + // The WebSocket transitions to "open" before the server has emitted + // its application-level `connected` event. Sending a subscribe frame + // in that window triggers a policy-violation close on real Appwrite, + // which reconnects and re-sends, looping forever. The `connected` + // branch in _createSocket re-enqueues every active subscription and + // re-calls this method once `_appConnected` flips true, so the queued + // rows are guaranteed to go out — just deferred. + if (!_appConnected) { + return; + } if (_pendingSubscribes.isEmpty) { return; @@ -281,6 +298,14 @@ mixin RealtimeMixin { })); } + void _flushPendingPresence() { + final data = _pendingPresence; + if (data == null) return; + if (_websok == null || _websok?.closeCode != null) return; + if (!_appConnected) return; + _websok!.sink.add(jsonEncode({'type': 'presence', 'data': data})); + } + /// Convert channel value to string /// Handles String and Channel instances (which have toString()) String _channelToString(Object channel) { @@ -362,4 +387,37 @@ mixin RealtimeMixin { _retry(); } } + + /// Fire-and-forget presence upsert. Records the latest payload in state so + /// that — if the WebSocket isn't open yet, or later reconnects — the most + /// recent presence is automatically (re)sent on the next `connected` event. + /// When the socket is already open, the frame is sent immediately. + void upsertPresenceTo({ + required String status, + required String presenceId, + List? permissions, + Map? metadata, + }) { + final data = { + 'status': status, + 'presenceId': presenceId, + }; + if (permissions != null) data['permissions'] = permissions; + if (metadata != null) data['metadata'] = metadata; + + _pendingPresence = data; + + // Both subscribe() and upsertPresence() may need to open the socket. + // _createSocket() is single-flight (see `_creatingSocket`), so calling + // it here is a no-op when one is already in flight or healthy. + if (_websok == null || _websok?.closeCode != null) { + // Fire-and-forget; keeps upsertPresence's documented void return. + _createSocket(); + } + + // Opportunistic send for when the socket is already past `connected`. + // The _appConnected gate inside _flushPendingPresence keeps this a no-op + // until the application-level handshake completes. + _flushPendingPresence(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 79776241..ae53b96f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: appwrite -version: 24.0.0 +version: 24.1.0 description: Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API homepage: https://appwrite.io repository: https://github.com/appwrite/sdk-for-flutter diff --git a/test/channel_test.dart b/test/channel_test.dart index 84b11123..9e0e11d3 100644 --- a/test/channel_test.dart +++ b/test/channel_test.dart @@ -125,4 +125,38 @@ void main() { 'memberships.membership1.update'); }); }); + + group('presences()', () { + test('returns presences global channel', () { + expect(Channel.presences(), 'presences'); + }); + + test('throws when presence id is missing', () { + expect(() => Channel.presence(''), throwsArgumentError); + }); + + test('returns presence channel with specific presence ID', () { + expect(Channel.presence('presence1').toString(), 'presences.presence1'); + }); + + test('returns presence channel with create action', () { + expect(Channel.presence('presence1').create().toString(), + 'presences.presence1.create'); + }); + + test('returns presence channel with upsert action', () { + expect(Channel.presence('presence1').upsert().toString(), + 'presences.presence1.upsert'); + }); + + test('returns presence channel with update action', () { + expect(Channel.presence('presence1').update().toString(), + 'presences.presence1.update'); + }); + + test('returns presence channel with delete action', () { + expect(Channel.presence('presence1').delete().toString(), + 'presences.presence1.delete'); + }); + }); } diff --git a/test/services/advisor_test.dart b/test/services/advisor_test.dart new file mode 100644 index 00000000..c1fbca22 --- /dev/null +++ b/test/services/advisor_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:appwrite/models.dart' as models; +import 'package:appwrite/enums.dart' as enums; +import 'package:appwrite/src/enums.dart'; +import 'package:appwrite/src/response.dart'; +import 'dart:typed_data'; +import 'package:appwrite/appwrite.dart'; + +class MockClient extends Mock implements Client { + Map config = {'project': 'testproject'}; + String endPoint = 'https://localhost/v1'; + @override + Future call( + HttpMethod? method, { + String path = '', + Map headers = const {}, + Map params = const {}, + ResponseType? responseType, + }) async { + return super.noSuchMethod(Invocation.method(#call, [method]), + returnValue: Response()); + } + + @override + Future webAuth( + Uri? url, { + String? callbackUrlScheme, + }) async { + return super + .noSuchMethod(Invocation.method(#webAuth, [url]), returnValue: 'done'); + } + + @override + Future chunkedUpload({ + String? path, + Map? params, + String? paramName, + String? idParamName, + Map? headers, + Function(UploadProgress)? onProgress, + }) async { + return super.noSuchMethod( + Invocation.method( + #chunkedUpload, [path, params, paramName, idParamName, headers]), + returnValue: Response(data: {})); + } +} + +void main() { + group('Advisor test', () { + late MockClient client; + late Advisor advisor; + + setUp(() { + client = MockClient(); + advisor = Advisor(client); + }); + + test('test method listReports()', () async { + final Map data = { + 'total': 5, + 'reports': [], + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await advisor.listReports(); + expect(response, isA()); + }); + + test('test method getReport()', () async { + final Map data = { + '\$id': '5e5ea5c16897e', + '\$createdAt': '2020-10-15T06:38:00.000+00:00', + '\$updatedAt': '2020-10-15T06:38:00.000+00:00', + 'appId': '5e5ea5c16897e', + 'type': 'lighthouse', + 'title': 'Lighthouse audit for https://appwrite.io/', + 'summary': 'Performance score 78. 4 opportunities found.', + 'targetType': 'urls', + 'target': 'https://appwrite.io/', + 'categories': [], + 'insights': [], + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await advisor.getReport( + reportId: '', + ); + expect(response, isA()); + }); + + test('test method listInsights()', () async { + final Map data = { + 'total': 5, + 'insights': [], + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await advisor.listInsights( + reportId: '', + ); + expect(response, isA()); + }); + + test('test method getInsight()', () async { + final Map data = { + '\$id': '5e5ea5c16897e', + '\$createdAt': '2020-10-15T06:38:00.000+00:00', + '\$updatedAt': '2020-10-15T06:38:00.000+00:00', + 'reportId': '5e5ea5c16897e', + 'type': 'tablesDBIndex', + 'severity': 'warning', + 'status': 'active', + 'resourceType': 'databases', + 'resourceId': 'main', + 'parentResourceType': 'tables', + 'parentResourceId': 'orders', + 'title': 'Missing index on collection orders', + 'summary': + 'Queries against `orders.status` are scanning the full collection.', + 'ctas': [], + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await advisor.getInsight( + reportId: '', + insightId: '', + ); + expect(response, isA()); + }); + }); +} diff --git a/test/services/presences_test.dart b/test/services/presences_test.dart new file mode 100644 index 00000000..7fed0439 --- /dev/null +++ b/test/services/presences_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:appwrite/models.dart' as models; +import 'package:appwrite/enums.dart' as enums; +import 'package:appwrite/src/enums.dart'; +import 'package:appwrite/src/response.dart'; +import 'dart:typed_data'; +import 'package:appwrite/appwrite.dart'; + +class MockClient extends Mock implements Client { + Map config = {'project': 'testproject'}; + String endPoint = 'https://localhost/v1'; + @override + Future call( + HttpMethod? method, { + String path = '', + Map headers = const {}, + Map params = const {}, + ResponseType? responseType, + }) async { + return super.noSuchMethod(Invocation.method(#call, [method]), + returnValue: Response()); + } + + @override + Future webAuth( + Uri? url, { + String? callbackUrlScheme, + }) async { + return super + .noSuchMethod(Invocation.method(#webAuth, [url]), returnValue: 'done'); + } + + @override + Future chunkedUpload({ + String? path, + Map? params, + String? paramName, + String? idParamName, + Map? headers, + Function(UploadProgress)? onProgress, + }) async { + return super.noSuchMethod( + Invocation.method( + #chunkedUpload, [path, params, paramName, idParamName, headers]), + returnValue: Response(data: {})); + } +} + +void main() { + group('Presences test', () { + late MockClient client; + late Presences presences; + + setUp(() { + client = MockClient(); + presences = Presences(client); + }); + + test('test method list()', () async { + final Map data = { + 'total': 5, + 'presences': [], + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await presences.list(); + expect(response, isA()); + }); + + test('test method get()', () async { + final Map data = { + '\$id': '5e5ea5c16897e', + '\$createdAt': '2020-10-15T06:38:00.000+00:00', + '\$updatedAt': '2020-10-15T06:38:00.000+00:00', + '\$permissions': [], + 'userId': '674af8f3e12a5f9ac0be', + 'source': 'HTTP', + }; + + when(client.call( + HttpMethod.get, + )).thenAnswer((_) async => Response(data: data)); + + final response = await presences.get( + presenceId: '', + ); + expect(response, isA()); + }); + + test('test method upsert()', () async { + final Map data = { + '\$id': '5e5ea5c16897e', + '\$createdAt': '2020-10-15T06:38:00.000+00:00', + '\$updatedAt': '2020-10-15T06:38:00.000+00:00', + '\$permissions': [], + 'userId': '674af8f3e12a5f9ac0be', + 'source': 'HTTP', + }; + + when(client.call( + HttpMethod.put, + )).thenAnswer((_) async => Response(data: data)); + + final response = await presences.upsert( + presenceId: '', + status: '', + ); + expect(response, isA()); + }); + + test('test method update()', () async { + final Map data = { + '\$id': '5e5ea5c16897e', + '\$createdAt': '2020-10-15T06:38:00.000+00:00', + '\$updatedAt': '2020-10-15T06:38:00.000+00:00', + '\$permissions': [], + 'userId': '674af8f3e12a5f9ac0be', + 'source': 'HTTP', + }; + + when(client.call( + HttpMethod.patch, + )).thenAnswer((_) async => Response(data: data)); + + final response = await presences.update( + presenceId: '', + ); + expect(response, isA()); + }); + + test('test method delete()', () async { + final data = ''; + + when(client.call( + HttpMethod.delete, + )).thenAnswer((_) async => Response(data: data)); + + final response = await presences.delete( + presenceId: '', + ); + }); + }); +} diff --git a/test/src/models/insight_cta_test.dart b/test/src/models/insight_cta_test.dart new file mode 100644 index 00000000..abaaa864 --- /dev/null +++ b/test/src/models/insight_cta_test.dart @@ -0,0 +1,23 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('InsightCTA', () { + test('model', () { + final model = InsightCTA( + label: 'Create missing index', + service: 'tablesDB', + method: 'createIndex', + params: {}, + ); + + final map = model.toMap(); + final result = InsightCTA.fromMap(map); + + expect(result.label, 'Create missing index'); + expect(result.service, 'tablesDB'); + expect(result.method, 'createIndex'); + expect(result.params, {}); + }); + }); +} diff --git a/test/src/models/insight_list_test.dart b/test/src/models/insight_list_test.dart new file mode 100644 index 00000000..001d800d --- /dev/null +++ b/test/src/models/insight_list_test.dart @@ -0,0 +1,19 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('InsightList', () { + test('model', () { + final model = InsightList( + total: 5, + insights: [], + ); + + final map = model.toMap(); + final result = InsightList.fromMap(map); + + expect(result.total, 5); + expect(result.insights, []); + }); + }); +} diff --git a/test/src/models/insight_test.dart b/test/src/models/insight_test.dart new file mode 100644 index 00000000..08321bda --- /dev/null +++ b/test/src/models/insight_test.dart @@ -0,0 +1,45 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Insight', () { + test('model', () { + final model = Insight( + $id: '5e5ea5c16897e', + $createdAt: '2020-10-15T06:38:00.000+00:00', + $updatedAt: '2020-10-15T06:38:00.000+00:00', + reportId: '5e5ea5c16897e', + type: 'tablesDBIndex', + severity: 'warning', + status: 'active', + resourceType: 'databases', + resourceId: 'main', + parentResourceType: 'tables', + parentResourceId: 'orders', + title: 'Missing index on collection orders', + summary: + 'Queries against `orders.status` are scanning the full collection.', + ctas: [], + ); + + final map = model.toMap(); + final result = Insight.fromMap(map); + + expect(result.$id, '5e5ea5c16897e'); + expect(result.$createdAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.$updatedAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.reportId, '5e5ea5c16897e'); + expect(result.type, 'tablesDBIndex'); + expect(result.severity, 'warning'); + expect(result.status, 'active'); + expect(result.resourceType, 'databases'); + expect(result.resourceId, 'main'); + expect(result.parentResourceType, 'tables'); + expect(result.parentResourceId, 'orders'); + expect(result.title, 'Missing index on collection orders'); + expect(result.summary, + 'Queries against `orders.status` are scanning the full collection.'); + expect(result.ctas, []); + }); + }); +} diff --git a/test/src/models/presence_list_test.dart b/test/src/models/presence_list_test.dart new file mode 100644 index 00000000..46f69af6 --- /dev/null +++ b/test/src/models/presence_list_test.dart @@ -0,0 +1,19 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('PresenceList', () { + test('model', () { + final model = PresenceList( + total: 5, + presences: [], + ); + + final map = model.toMap(); + final result = PresenceList.fromMap(map); + + expect(result.total, 5); + expect(result.presences, []); + }); + }); +} diff --git a/test/src/models/presence_test.dart b/test/src/models/presence_test.dart new file mode 100644 index 00000000..713ba013 --- /dev/null +++ b/test/src/models/presence_test.dart @@ -0,0 +1,28 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Presence', () { + test('model', () { + final model = Presence( + $id: '5e5ea5c16897e', + $createdAt: '2020-10-15T06:38:00.000+00:00', + $updatedAt: '2020-10-15T06:38:00.000+00:00', + $permissions: [], + userId: '674af8f3e12a5f9ac0be', + source: 'HTTP', + metadata: {}, + ); + + final map = model.toMap(); + final result = Presence.fromMap(map); + + expect(result.$id, '5e5ea5c16897e'); + expect(result.$createdAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.$updatedAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.$permissions, []); + expect(result.userId, '674af8f3e12a5f9ac0be'); + expect(result.source, 'HTTP'); + }); + }); +} diff --git a/test/src/models/report_list_test.dart b/test/src/models/report_list_test.dart new file mode 100644 index 00000000..42f988ec --- /dev/null +++ b/test/src/models/report_list_test.dart @@ -0,0 +1,19 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ReportList', () { + test('model', () { + final model = ReportList( + total: 5, + reports: [], + ); + + final map = model.toMap(); + final result = ReportList.fromMap(map); + + expect(result.total, 5); + expect(result.reports, []); + }); + }); +} diff --git a/test/src/models/report_test.dart b/test/src/models/report_test.dart new file mode 100644 index 00000000..4cbdb6f8 --- /dev/null +++ b/test/src/models/report_test.dart @@ -0,0 +1,37 @@ +import 'package:appwrite/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Report', () { + test('model', () { + final model = Report( + $id: '5e5ea5c16897e', + $createdAt: '2020-10-15T06:38:00.000+00:00', + $updatedAt: '2020-10-15T06:38:00.000+00:00', + appId: '5e5ea5c16897e', + type: 'lighthouse', + title: 'Lighthouse audit for https://appwrite.io/', + summary: 'Performance score 78. 4 opportunities found.', + targetType: 'urls', + target: 'https://appwrite.io/', + categories: [], + insights: [], + ); + + final map = model.toMap(); + final result = Report.fromMap(map); + + expect(result.$id, '5e5ea5c16897e'); + expect(result.$createdAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.$updatedAt, '2020-10-15T06:38:00.000+00:00'); + expect(result.appId, '5e5ea5c16897e'); + expect(result.type, 'lighthouse'); + expect(result.title, 'Lighthouse audit for https://appwrite.io/'); + expect(result.summary, 'Performance score 78. 4 opportunities found.'); + expect(result.targetType, 'urls'); + expect(result.target, 'https://appwrite.io/'); + expect(result.categories, []); + expect(result.insights, []); + }); + }); +}