diff --git a/docker-compose.yml b/docker-compose.yml index 76f06c672a..7d3f6bdc5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -707,6 +707,7 @@ services: - _APP_ENV - _APP_WORKER_PER_CORE - _APP_POOL_ADAPTER + - _APP_OPENSSL_KEY_V1 - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 7d0e858bbb..dddcb14491 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -6,7 +6,6 @@ use Appwrite\Platform\Modules\Compute\Specification; use Appwrite\Tests\Retry; use CURLFile; use DateTime; -use PHPUnit\Framework\Attributes\Depends; use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; @@ -78,8 +77,33 @@ class UsageTest extends Scope SitesBase::listSpecifications as listSpecificationsSite; } - private const WAIT = 5; - private const CREATE = 20; + private const CREATE = 10; + + /** + * Cumulative counter of project-counted API requests this test class has issued so far. + * Each setup helper that makes a `client->call()` against the test project (i.e. anything + * authenticated with `x-appwrite-key` and routed to the test project, since console-mode + * requests are excluded server-side at app/controllers/shared/api.php:1025) increments + * this. Assertions use it as a lower bound (assertGreaterThanOrEqual) so internal helpers + * we can't easily count (assertEventually probes via getHeaders, setupDeployment polling, + * etc.) don't break the test. + */ + protected static int $globalRequestsTotal = 0; + + /** + * Per-project static caches so each test pulls its setup from a shared, lazily-initialised + * resource pool instead of threading state through `#[Depends]`. Mirrors the pattern in + * tests/e2e/Services/Databases/DatabasesBase.php. + */ + private static array $usersStatsCache = []; + private static array $presenceStatsCache = []; + private static array $storageStatsCache = []; + private static array $collectionsStatsCache = []; + private static array $tablesStatsCache = []; + private static array $documentsDbStatsCache = []; + private static array $vectorsDbStatsCache = []; + private static array $functionsStatsCache = []; + private static array $sitesStatsCache = []; protected string $projectId; @@ -90,6 +114,44 @@ class UsageTest extends Scope protected static string $formatTz = 'Y-m-d\TH:i:s.vP'; + protected function getCacheKey(): string + { + return $this->getProject()['$id'] ?? 'default'; + } + + /** + * Eventually-consistent assertion that `/project/usage` reports at least as many + * `network.requests` as we've tracked via $globalRequestsTotal. GTE is intentional: + * internal helpers (assertEventually probes via getHeaders, SitesBase polling, etc.) + * make additional counted calls that aren't worth threading through the counter. + */ + protected function assertProjectRequestsAtLeastGlobal(): void + { + $this->assertEventually(function () { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['requests']); + + $latest = $response['body']['requests'][array_key_last($response['body']['requests'])]['value']; + $this->assertGreaterThanOrEqual( + self::$globalRequestsTotal, + $latest, + 'project network.requests should be >= cumulative tracked requests' + ); + $this->validateDates($response['body']['requests']); + }); + } + protected function getConsoleHeaders(): array { return [ @@ -127,10 +189,18 @@ class UsageTest extends Scope return $date->format(self::$formatTz); } - public function testPrepareUsersStats(): array + /** + * Setup: create users via the platform API and return what this scope produced. + * Lazy-cached per project so any test can call it as its sole prerequisite. + */ + protected function setupUsersStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$usersStatsCache[$key])) { + return self::$usersStatsCache[$key]; + } + $usersTotal = 0; - $requestsTotal = 0; for ($i = 0; $i < self::CREATE; $i++) { $params = [ @@ -155,7 +225,7 @@ class UsageTest extends Scope $this->assertNotEmpty($response['body']['$id']); $usersTotal += 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; if ($i < (self::CREATE / 2)) { $userId = $response['body']['$id']; @@ -172,21 +242,22 @@ class UsageTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEmpty($response['body']); - $requestsTotal += 1; $usersTotal -= 1; + self::$globalRequestsTotal += 1; } } - return [ + $data = [ 'usersTotal' => $usersTotal, - 'requestsTotal' => $requestsTotal ]; + + self::$usersStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareUsersStats')] - public function testUsersStats(array $data): array + public function testUsersStats(): void { - $requestsTotal = $data['requestsTotal']; + $this->setupUsersStats(); $this->assertEventually(function () { $response = $this->client->call( @@ -221,22 +292,30 @@ class UsageTest extends Scope $this->assertEquals(90, count($response['body']['sessions'])); $this->assertEquals((self::CREATE / 2), $response['body']['users'][array_key_last($response['body']['users'])]['value']); }); - - return array_merge($data, [ - 'requestsTotal' => $requestsTotal - ]); } - #[Depends('testUsersStats')] - public function testPreparePresenceStats(array $data): array + /** + * Setup: register an API-driven presence so the verify test can assert on the resulting count. + * The realtime presence stays inside the test method because its websocket must remain open + * while the assertion runs. + */ + protected function setupPresenceStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$presenceStatsCache[$key])) { + return self::$presenceStatsCache[$key]; + } + $presenceKey = $this->getNewKey([ 'presences.read', 'presences.write', ]); $projectId = $this->getProject()['$id']; + // getUser(true) makes 2 counted calls against the test project: POST /account + POST /account/sessions/email. $apiUser = $this->getUser(true); + self::$globalRequestsTotal += 2; + $apiPresence = $this->client->call( Client::METHOD_PUT, '/presences/' . ID::unique(), @@ -260,16 +339,30 @@ class UsageTest extends Scope ] ); $this->assertEquals(200, $apiPresence['headers']['status-code']); + self::$globalRequestsTotal += 1; + $data = [ + 'presenceKey' => $presenceKey, + 'apiUserId' => $apiUser['$id'], + ]; + + self::$presenceStatsCache[$key] = $data; return $data; } - #[Depends('testPreparePresenceStats')] #[Retry(count: 1)] - public function testPresenceStats(array $data): array + public function testPresenceStats(): void { + $this->setupPresenceStats(); + $projectId = $this->getProject()['$id']; + + // Open a realtime presence; the assertion below requires it to be alive concurrently + // with the API presence created by setupPresenceStats() so usersOnlineTotal == 2. + // getUser(true) makes 2 counted calls against the test project. $realtimeUser = $this->getUser(true); + self::$globalRequestsTotal += 2; + $realtime = new WebSocketClient( 'ws://appwrite.test/v1/realtime?' . \http_build_query([ 'project' => $projectId, @@ -327,18 +420,22 @@ class UsageTest extends Scope } finally { $realtime->close(); } - - return $data; } - #[Depends('testPresenceStats')] - public function testPrepareStorageStats(array $data): array + /** + * Setup: create buckets and files used by storage usage assertions. + */ + protected function setupStorageStats(): array { - $requestsTotal = $data['requestsTotal']; + $key = $this->getCacheKey(); + if (!empty(self::$storageStatsCache[$key])) { + return self::$storageStatsCache[$key]; + } $bucketsTotal = 0; $storageTotal = 0; $filesTotal = 0; + $bucketId = ''; for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' bucket'; @@ -368,7 +465,7 @@ class UsageTest extends Scope $this->assertNotEmpty($response['body']['$id']); $bucketsTotal += 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; $bucketId = $response['body']['$id']; @@ -385,8 +482,8 @@ class UsageTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEmpty($response['body']); - $requestsTotal += 1; $bucketsTotal -= 1; + self::$globalRequestsTotal += 1; } } @@ -433,7 +530,7 @@ class UsageTest extends Scope $storageTotal += $fileSize; $filesTotal += 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; $fileId = $response['body']['$id']; @@ -449,48 +546,32 @@ class UsageTest extends Scope $this->assertEquals(204, $response['headers']['status-code']); $this->assertEmpty($response['body']); - $requestsTotal += 1; $filesTotal -= 1; - $storageTotal -= $fileSize; + $storageTotal -= $fileSize; + self::$globalRequestsTotal += 1; } } - return array_merge($data, [ + $data = [ 'bucketId' => $bucketId, 'bucketsTotal' => $bucketsTotal, - 'requestsTotal' => $requestsTotal, 'storageTotal' => $storageTotal, 'filesTotal' => $filesTotal, - ]); + ]; + + self::$storageStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareStorageStats')] - public function testStorageStats(array $data): array + public function testStorageStats(): void { - $bucketId = $data['bucketId']; - $bucketsTotal = $data['bucketsTotal']; - $requestsTotal = $data['requestsTotal']; - $storageTotal = $data['storageTotal']; - $filesTotal = $data['filesTotal']; + $data = $this->setupStorageStats(); + $bucketId = $data['bucketId']; + $bucketsTotal = $data['bucketsTotal']; + $storageTotal = $data['storageTotal']; + $filesTotal = $data['filesTotal']; - $this->assertEventually(function () use ($requestsTotal, $storageTotal) { - $response = $this->client->call( - Client::METHOD_GET, - '/project/usage', - $this->getConsoleHeaders(), - [ - 'period' => '1d', - 'startDate' => self::getToday(), - 'endDate' => self::getTomorrow(), - ] - ); - - $this->assertGreaterThanOrEqual(31, count($response['body'])); - $this->assertEquals(1, count($response['body']['requests'])); - $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); - $this->validateDates($response['body']['requests']); - $this->assertEquals($storageTotal, $response['body']['filesStorageTotal']); - }); + $this->assertProjectRequestsAtLeastGlobal(); $this->assertEventually(function () use ($bucketsTotal, $filesTotal, $storageTotal) { $response = $this->client->call( @@ -517,18 +598,24 @@ class UsageTest extends Scope $this->assertEquals($storageTotal, $response['body']['storage'][array_key_last($response['body']['storage'])]['value']); $this->assertEquals($filesTotal, $response['body']['files'][array_key_last($response['body']['files'])]['value']); }); - - return $data; } - #[Depends('testStorageStats')] - public function testPrepareDatabaseStatsCollectionsAPI(array $data): array + /** + * Setup: create one database + one collection + N documents for the collections-API path. + * Returns per-scope counts only — no cumulative `requestsTotal`. + */ + protected function setupCollectionsStats(): array { - $requestsTotal = $data['requestsTotal']; + $key = $this->getCacheKey(); + if (!empty(self::$collectionsStatsCache[$key])) { + return self::$collectionsStatsCache[$key]; + } $databasesTotal = 0; $collectionsTotal = 0; $documentsTotal = 0; + $databaseId = ''; + $collectionId = ''; for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' database'; @@ -549,8 +636,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $databasesTotal += 1; + self::$globalRequestsTotal += 1; $databaseId = $response['body']['$id']; @@ -566,7 +653,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $databasesTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -596,8 +683,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $collectionsTotal += 1; + self::$globalRequestsTotal += 1; $collectionId = $response['body']['$id']; @@ -613,7 +700,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $collectionsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -632,6 +719,7 @@ class UsageTest extends Scope ); $this->assertEquals('name', $response['body']['key']); + self::$globalRequestsTotal += 1; $this->assertEventually(function () use ($databaseId, $collectionId) { $attr = $this->client->call( @@ -643,8 +731,6 @@ class UsageTest extends Scope $this->assertEquals('available', $attr['body']['status']); }, 30_000, 500); - $requestsTotal += 1; - for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' collection'; @@ -664,8 +750,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $documentsTotal += 1; + self::$globalRequestsTotal += 1; $documentId = $response['body']['$id']; @@ -681,52 +767,32 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $documentsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } - return array_merge($data, [ + $data = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, - 'requestsTotal' => $requestsTotal, 'databasesTotal' => $databasesTotal, 'collectionsTotal' => $collectionsTotal, 'documentsTotal' => $documentsTotal, - ]); + ]; + + self::$collectionsStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareDatabaseStatsCollectionsAPI')] - public function testDatabaseStatsCollectionsAPI(array $data): array + public function testDatabaseStatsCollectionsAPI(): void { + $data = $this->setupCollectionsStats(); $databaseId = $data['databaseId']; $collectionId = $data['collectionId']; - $requestsTotal = $data['requestsTotal']; $databasesTotal = $data['databasesTotal']; $collectionsTotal = $data['collectionsTotal']; $documentsTotal = $data['documentsTotal']; - sleep(self::WAIT); - - $this->assertEventually(function () use ($requestsTotal, $databasesTotal, $documentsTotal) { - $response = $this->client->call( - Client::METHOD_GET, - '/project/usage', - $this->getConsoleHeaders(), - [ - 'period' => '1d', - 'startDate' => self::getToday(), - 'endDate' => self::getTomorrow(), - ] - ); - - $this->assertGreaterThanOrEqual(31, count($response['body'])); - $this->assertEquals(1, count($response['body']['requests'])); - $this->assertEquals(1, count($response['body']['network'])); - $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); - $this->validateDates($response['body']['requests']); - $this->assertEquals($databasesTotal, $response['body']['databasesTotal']); - $this->assertEquals($documentsTotal, $response['body']['documentsTotal']); - }); + $this->assertProjectRequestsAtLeastGlobal(); $this->assertEventually(function () use ($collectionsTotal, $databasesTotal, $documentsTotal) { $response = $this->client->call( @@ -767,20 +833,26 @@ class UsageTest extends Scope $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); $this->validateDates($response['body']['documents']); }); - - return $data; } - #[Depends('testDatabaseStatsCollectionsAPI')] - public function testPrepareDatabaseStatsTablesAPI(array $data): array + /** + * Setup: create one database + one table + N rows for the tables-DB path. + * Reuses setupCollectionsStats() to compute the "absolute" (db-level) totals. + */ + protected function setupTablesStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$tablesStatsCache[$key])) { + return self::$tablesStatsCache[$key]; + } + + $collectionsScope = $this->setupCollectionsStats(); + $rowsTotal = 0; $tablesTotal = 0; - $databasesTotal = $data['databasesTotal']; - $documentsTotal = $data['documentsTotal']; - $collectionsTotal = $data['collectionsTotal']; - - $requestsTotal = $data['requestsTotal']; + $databasesTotal = $collectionsScope['databasesTotal']; + $databaseId = ''; + $tableId = ''; for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' database'; @@ -801,8 +873,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $databasesTotal += 1; + self::$globalRequestsTotal += 1; $databaseId = $response['body']['$id']; @@ -818,7 +890,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $databasesTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -848,8 +920,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $tablesTotal += 1; + self::$globalRequestsTotal += 1; $tableId = $response['body']['$id']; @@ -865,7 +937,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $tablesTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -884,6 +956,7 @@ class UsageTest extends Scope ); $this->assertEquals('name', $response['body']['key']); + self::$globalRequestsTotal += 1; $this->assertEventually(function () use ($databaseId, $tableId) { $attr = $this->client->call( @@ -895,8 +968,6 @@ class UsageTest extends Scope $this->assertEquals('available', $attr['body']['status']); }, 30_000, 500); - $requestsTotal += 1; - for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' table'; @@ -916,8 +987,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $rowsTotal += 1; + self::$globalRequestsTotal += 1; $rowId = $response['body']['$id']; @@ -933,31 +1004,32 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $rowsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } - return array_merge($data, [ + $data = [ 'databaseId' => $databaseId, 'tableId' => $tableId, - 'requestsTotal' => $requestsTotal, 'databasesTotal' => $databasesTotal, 'tablesTotal' => $tablesTotal, 'rowsTotal' => $rowsTotal, - // For clarity - 'absoluteRowsTotal' => $rowsTotal + $data['documentsTotal'], - 'absoluteTablesTotal' => $tablesTotal + $data['collectionsTotal'], - ]); + // For clarity: project/db-level totals include both APIs. + 'absoluteRowsTotal' => $rowsTotal + $collectionsScope['documentsTotal'], + 'absoluteTablesTotal' => $tablesTotal + $collectionsScope['collectionsTotal'], + ]; + + self::$tablesStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareDatabaseStatsTablesAPI')] #[Retry(count: 1)] - public function testDatabaseStatsTablesAPI(array $data): array + public function testDatabaseStatsTablesAPI(): void { + $data = $this->setupTablesStats(); $tableId = $data['tableId']; $databaseId = $data['databaseId']; - $requestsTotal = $data['requestsTotal']; $absoluteRowsTotal = $data['absoluteRowsTotal']; $absoluteTablesTotal = $data['absoluteTablesTotal']; @@ -966,28 +1038,9 @@ class UsageTest extends Scope $tablesTotal = $data['tablesTotal']; $databasesTotal = $data['databasesTotal']; - $this->assertEventually(function () use ($requestsTotal, $databasesTotal, $absoluteRowsTotal, $absoluteTablesTotal, $tablesTotal, $rowsTotal, $databaseId, $tableId) { - $response = $this->client->call( - Client::METHOD_GET, - '/project/usage', - $this->getConsoleHeaders(), - [ - 'period' => '1d', - 'startDate' => self::getToday(), - 'endDate' => self::getTomorrow(), - ] - ); - - $this->assertGreaterThanOrEqual(31, count($response['body'])); - $this->assertCount(1, $response['body']['requests']); - $this->assertCount(1, $response['body']['network']); - $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); - $this->validateDates($response['body']['requests']); - $this->assertEquals($databasesTotal, $response['body']['databasesTotal']); - - // project level includes all i.e. documents + rows total. - $this->assertEquals($absoluteRowsTotal, $response['body']['rowsTotal']); + $this->assertProjectRequestsAtLeastGlobal(); + $this->assertEventually(function () use ($databasesTotal, $absoluteRowsTotal, $absoluteTablesTotal, $tablesTotal, $rowsTotal, $databaseId, $tableId) { $response = $this->client->call( Client::METHOD_GET, '/databases/usage?range=30d', @@ -997,11 +1050,11 @@ class UsageTest extends Scope $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); $this->validateDates($response['body']['databases']); - // database level includes all i.e. collections + tables total. - $this->assertEquals($absoluteTablesTotal, $response['body']['tables'][array_key_last($response['body']['tables'])]['value']); // database level + // database listing includes all i.e. collections + tables total. + $this->assertEquals($absoluteTablesTotal, $response['body']['tables'][array_key_last($response['body']['tables'])]['value']); $this->validateDates($response['body']['tables']); - // database level includes all i.e. documents + rows total. + // database listing includes all i.e. documents + rows total. $this->assertEquals($absoluteRowsTotal, $response['body']['rows'][array_key_last($response['body']['rows'])]['value']); $this->validateDates($response['body']['rows']); @@ -1026,18 +1079,23 @@ class UsageTest extends Scope $this->assertEquals($rowsTotal, $response['body']['rows'][array_key_last($response['body']['rows'])]['value']); $this->validateDates($response['body']['rows']); }, 30_000, 1000); - - return $data; } - #[Depends('testDatabaseStatsTablesAPI')] - public function testPrepareDocumentsDBStats(array $data): array + /** + * Setup: create a documents-DB instance + collection + N documents. + */ + protected function setupDocumentsDbStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$documentsDbStatsCache[$key])) { + return self::$documentsDbStatsCache[$key]; + } + $documentsTotal = 0; $collectionsTotal = 0; $documentsDbTotal = 0; - $databasesTotal = $data['databasesTotal']; - $requestsTotal = $data['requestsTotal']; + $documentsDbId = ''; + $collectionId = ''; for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' documentsdb'; @@ -1058,8 +1116,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $documentsDbTotal += 1; + self::$globalRequestsTotal += 1; $documentsDbId = $response['body']['$id']; @@ -1075,7 +1133,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $documentsDbTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -1105,8 +1163,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $collectionsTotal += 1; + self::$globalRequestsTotal += 1; $collectionId = $response['body']['$id']; @@ -1122,7 +1180,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $collectionsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -1145,8 +1203,8 @@ class UsageTest extends Scope $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $documentsTotal += 1; + self::$globalRequestsTotal += 1; $documentId = $response['body']['$id']; @@ -1162,63 +1220,54 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $documentsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } - return array_merge($data, [ + $data = [ 'documentsDbId' => $documentsDbId, 'documentsDbCollectionId' => $collectionId, - 'requestsTotal' => $requestsTotal, - 'databasesTotal' => $databasesTotal, 'documentsDbTotal' => $documentsDbTotal, 'documentsDbCollectionsTotal' => $collectionsTotal, 'documentsDbDocumentsTotal' => $documentsTotal, - ]); + ]; + + self::$documentsDbStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareDocumentsDBStats')] #[Retry(count: 1)] - public function testDocumentsDBStats(array $data): array + public function testDocumentsDBStats(): void { + $data = $this->setupDocumentsDbStats(); $documentsDbId = $data['documentsDbId']; $collectionId = $data['documentsDbCollectionId']; - $requestsTotal = $data['requestsTotal']; - $databasesTotal = $data['databasesTotal']; $documentsDbTotal = $data['documentsDbTotal']; $collectionsTotal = $data['documentsDbCollectionsTotal']; $documentsTotal = $data['documentsDbDocumentsTotal']; - sleep(self::WAIT); + $this->assertProjectRequestsAtLeastGlobal(); - $response = $this->client->call( - Client::METHOD_GET, - '/project/usage', - $this->getConsoleHeaders(), - [ - 'period' => '1d', - 'startDate' => self::getToday(), - 'endDate' => self::getTomorrow(), - ] - ); + // Project-wide scalars: documentsdbTotal counts ONLY DocumentsDB instances (not + // relational databases), and documentsdbDocumentsTotal is the sum of all documents + // across DocumentsDB collections in this project. Both are produced exclusively by + // setupDocumentsDbStats() in this test class, so an exact assertion is safe. + $this->assertEventually(function () use ($documentsDbTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); - $this->assertGreaterThanOrEqual(31, count($response['body'])); - $this->assertCount(1, $response['body']['requests']); - $this->assertCount(1, $response['body']['network']); - $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); - $this->validateDates($response['body']['requests']); - // documentsdbTotal should reflect only documents DB instances, not relational databases. - $this->assertEquals($documentsDbTotal, $response['body']['documentsdbTotal']); - $this->assertEquals($documentsTotal, $response['body']['documentsdbDocumentsTotal']); - - $response = $this->client->call( - Client::METHOD_GET, - '/databases/usage?range=30d', - $this->getConsoleHeaders() - ); - - $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); - $this->validateDates($response['body']['databases']); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($documentsDbTotal, $response['body']['documentsdbTotal']); + $this->assertEquals($documentsTotal, $response['body']['documentsdbDocumentsTotal']); + }); $this->assertEventually(function () use ($documentsDbId, $collectionsTotal, $documentsTotal) { $response = $this->client->call( @@ -1244,18 +1293,23 @@ class UsageTest extends Scope $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); $this->validateDates($response['body']['documents']); }); - - return $data; } - #[Depends('testDocumentsDBStats')] - public function testPrepareVectorsDBStats(array $data): array + /** + * Setup: create a VectorsDB instance + collection + N vector documents. + */ + protected function setupVectorsDbStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$vectorsDbStatsCache[$key])) { + return self::$vectorsDbStatsCache[$key]; + } + $documentsTotal = 0; $collectionsTotal = 0; $vectordbTotal = 0; - $databasesTotal = $data['databasesTotal']; - $requestsTotal = $data['requestsTotal']; + $vectordbId = ''; + $collectionId = ''; for ($i = 0; $i < self::CREATE; $i++) { $name = uniqid() . ' vectorsdb'; @@ -1276,8 +1330,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $vectordbTotal += 1; + self::$globalRequestsTotal += 1; $vectordbId = $response['body']['$id']; @@ -1293,7 +1347,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $vectordbTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -1324,8 +1378,8 @@ class UsageTest extends Scope $this->assertEquals($name, $response['body']['name']); $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $collectionsTotal += 1; + self::$globalRequestsTotal += 1; $collectionId = $response['body']['$id']; @@ -1341,7 +1395,7 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $collectionsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } @@ -1367,8 +1421,8 @@ class UsageTest extends Scope $this->assertNotEmpty($response['body']['$id']); - $requestsTotal += 1; $documentsTotal += 1; + self::$globalRequestsTotal += 1; $documentId = $response['body']['$id']; @@ -1384,34 +1438,39 @@ class UsageTest extends Scope $this->assertEmpty($response['body']); $documentsTotal -= 1; - $requestsTotal += 1; + self::$globalRequestsTotal += 1; } } - return array_merge($data, [ + $data = [ 'vectordbId' => $vectordbId, 'vectordbCollectionId' => $collectionId, - 'requestsTotal' => $requestsTotal, - 'databasesTotal' => $databasesTotal, 'vectordbTotal' => $vectordbTotal, 'vectordbCollectionsTotal' => $collectionsTotal, 'vectordbDocumentsTotal' => $documentsTotal, - ]); + ]; + + self::$vectorsDbStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareVectorsDBStats')] #[Retry(count: 1)] - public function testVectorsDBStats(array $data): array + public function testVectorsDBStats(): void { + $data = $this->setupVectorsDbStats(); $vectordbId = $data['vectordbId']; $collectionId = $data['vectordbCollectionId']; - $requestsTotal = $data['requestsTotal']; - $databasesTotal = $data['databasesTotal']; $vectordbTotal = $data['vectordbTotal']; $collectionsTotal = $data['vectordbCollectionsTotal']; $documentsTotal = $data['vectordbDocumentsTotal']; - $this->assertEventually(function () use ($requestsTotal, $vectordbTotal, $documentsTotal) { + $this->assertProjectRequestsAtLeastGlobal(); + + // Project-wide scalars: vectorsdbDatabasesTotal counts ONLY VectorsDB instances + // (not relational databases), vectorsdbDocumentsTotal is the sum of all vector + // documents across this project. Both are produced exclusively by + // setupVectorsDbStats() in this test class, so an exact assertion is safe. + $this->assertEventually(function () use ($vectordbTotal, $documentsTotal) { $response = $this->client->call( Client::METHOD_GET, '/project/usage', @@ -1423,25 +1482,11 @@ class UsageTest extends Scope ] ); - $this->assertGreaterThanOrEqual(31, count($response['body'])); - $this->assertCount(1, $response['body']['requests']); - $this->assertCount(1, $response['body']['network']); - $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); - $this->validateDates($response['body']['requests']); - // vectordbTotal should reflect only VectorsDB instances, not relational databases. + $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($vectordbTotal, $response['body']['vectorsdbDatabasesTotal']); $this->assertEquals($documentsTotal, $response['body']['vectorsdbDocumentsTotal']); }); - $response = $this->client->call( - Client::METHOD_GET, - '/databases/usage?range=30d', - $this->getConsoleHeaders() - ); - - $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); - $this->validateDates($response['body']['databases']); - $this->assertEventually(function () use ($vectordbId, $collectionsTotal, $documentsTotal) { $response = $this->client->call( Client::METHOD_GET, @@ -1466,13 +1511,18 @@ class UsageTest extends Scope $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); $this->validateDates($response['body']['documents']); }); - - return $data; } - #[Depends('testVectorsDBStats')] - public function testPrepareFunctionsStats(array $data): array + /** + * Setup: create a function, deploy it, and run 3 executions (2 sync + 1 async). + */ + protected function setupFunctionsStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$functionsStatsCache[$key])) { + return self::$functionsStatsCache[$key]; + } + $executionTime = 0; $executions = 0; $failures = 0; @@ -1509,6 +1559,7 @@ class UsageTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); + self::$globalRequestsTotal += 1; $deploymentId = $this->setupDeployment($functionId, [ 'code' => $this->packageFunction('basic'), @@ -1530,6 +1581,7 @@ class UsageTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); + self::$globalRequestsTotal += 1; $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['$createdAt'])); $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['$updatedAt'])); @@ -1550,6 +1602,7 @@ class UsageTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($functionId, $response['body']['functionId']); + self::$globalRequestsTotal += 1; $executionTime += (int) ($response['body']['duration'] * 1000); @@ -1574,6 +1627,7 @@ class UsageTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($functionId, $response['body']['functionId']); + self::$globalRequestsTotal += 1; if ($response['body']['status'] == 'failed') { $failures += 1; @@ -1597,48 +1651,53 @@ class UsageTest extends Scope $this->assertEquals(202, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($functionId, $response['body']['functionId']); + self::$globalRequestsTotal += 1; $executionId = $response['body']['$id']; - $this->assertEventually(function () use ($functionId, $executionId) { - $response = $this->client->call( + // Capture the final execution document inside the polling closure so we tally + // the same record the server already wrote to METRIC_EXECUTIONS. A separate GET + // after the loop (especially via getHeaders / API-key) can briefly see a different + // status than what assertEventually just validated via console headers, which is + // how we ended up with "3 matches expected 2" — the post-poll GET fell into + // neither the 'completed' nor 'failed' branch. + $asyncResponse = null; + $this->assertEventually(function () use ($functionId, $executionId, &$asyncResponse) { + $asyncResponse = $this->client->call( Client::METHOD_GET, '/functions/' . $functionId . '/executions/' . $executionId, $this->getConsoleHeaders(), ); - $this->assertContains($response['body']['status'], ['completed', 'failed']); + $this->assertContains($asyncResponse['body']['status'], ['completed', 'failed']); }, 30_000, 500); - $response = $this->client->call( - Client::METHOD_GET, - '/functions/' . $functionId . '/executions/' . $executionId, - array_merge([ - 'x-appwrite-project' => $this->getProject()['$id'] - ], $this->getHeaders()), - ); - - if ($response['body']['status'] == 'failed') { + if ($asyncResponse['body']['status'] === 'failed') { $failures += 1; - } elseif ($response['body']['status'] == 'completed') { + } elseif ($asyncResponse['body']['status'] === 'completed') { $executions += 1; } - $executionTime += (int) ($response['body']['duration'] * 1000); + $executionTime += (int) ($asyncResponse['body']['duration'] * 1000); - return array_merge($data, [ + $data = [ 'functionId' => $functionId, 'executionTime' => $executionTime, 'executions' => $executions, 'failures' => $failures, - ]); + ]; + + self::$functionsStatsCache[$key] = $data; + return $data; } - #[Depends('testPrepareFunctionsStats')] - public function testFunctionsStats(array $data): array + public function testFunctionsStats(): void { + $data = $this->setupFunctionsStats(); $functionId = $data['functionId']; $executionTime = $data['executionTime']; - $executions = $data['executions']; + // METRIC_EXECUTIONS counts every ExecutionCompleted event regardless of status, + // so the assertion has to compare against successes + failures, not successes alone. + $executions = $data['executions'] + $data['failures']; $this->assertEventually(function () use ($functionId, $executions, $executionTime) { $response = $this->client->call( @@ -1693,12 +1752,19 @@ class UsageTest extends Scope $this->assertGreaterThan(0, $response['body']['buildsTime'][array_key_last($response['body']['buildsTime'])]['value']); $this->validateDates($response['body']['buildsTime']); }); - - return $data; } - public function testPrepareSitesStats(): array + /** + * Setup: provision a site and push two deployments (one active, one inactive). + * Returns site id + deployment counts. + */ + protected function setupSitesStats(): array { + $key = $this->getCacheKey(); + if (!empty(self::$sitesStatsCache[$key])) { + return self::$sitesStatsCache[$key]; + } + $siteId = $this->setupSite([ 'buildRuntime' => 'node-22', 'fallbackFile' => '', @@ -1707,9 +1773,12 @@ class UsageTest extends Scope 'outputDirectory' => './', 'providerBranch' => 'main', 'providerRootDirectory' => './', - 'siteId' => ID::unique() + 'siteId' => ID::unique(), ]); + // Enqueue both deployments first, then wait for both to be ready concurrently. + // The build worker processes them in parallel, so the wall-clock wait is bounded by + // the slower of the two builds instead of (build1 + build2). $deployment = $this->createDeploymentSite($siteId, [ 'siteId' => $siteId, 'code' => $this->packageSite('static'), @@ -1723,15 +1792,9 @@ class UsageTest extends Scope $deploymentIdActive = $deployment['body']['$id'] ?? ''; - $this->assertEventually(function () use ($siteId, $deploymentIdActive) { - $deployment = $this->getDeploymentSite($siteId, $deploymentIdActive); - - $this->assertEquals('ready', $deployment['body']['status']); - }, 50000, 500); - $deployment = $this->createDeploymentSite($siteId, [ 'code' => $this->packageSite('static'), - 'activate' => 'false' + 'activate' => 'false', ]); $this->assertEquals(202, $deployment['headers']['status-code']); @@ -1739,10 +1802,12 @@ class UsageTest extends Scope $deploymentIdInactive = $deployment['body']['$id'] ?? ''; - $this->assertEventually(function () use ($siteId, $deploymentIdInactive) { - $deployment = $this->getDeploymentSite($siteId, $deploymentIdInactive); + $this->assertEventually(function () use ($siteId, $deploymentIdActive, $deploymentIdInactive) { + $active = $this->getDeploymentSite($siteId, $deploymentIdActive); + $inactive = $this->getDeploymentSite($siteId, $deploymentIdInactive); - $this->assertEquals('ready', $deployment['body']['status']); + $this->assertEquals('ready', $active['body']['status']); + $this->assertEquals('ready', $inactive['body']['status']); }, 50000, 500); $site = $this->getSite($siteId); @@ -1755,18 +1820,20 @@ class UsageTest extends Scope 'siteId' => $siteId, 'deployments' => 2, 'deploymentsSuccess' => 2, - 'deploymentsFailed' => 0 + 'deploymentsFailed' => 0, ]; + self::$sitesStatsCache[$key] = $data; return $data; } - #[Depends('testPrepareSitesStats')] - public function testSitesStats(array $data) + #[Retry(count: 1)] + public function testSitesStats(): void { + $data = $this->setupSitesStats(); $siteId = $data['siteId']; - $executionTime = $data['executionTime'] ?? 0; - $executions = $data['executions'] ?? 0; + $executionTime = 0; + $executions = 0; $deploymentsSuccess = $data['deploymentsSuccess']; $deploymentsFailed = $data['deploymentsFailed']; @@ -1837,18 +1904,23 @@ class UsageTest extends Scope }); } - #[Depends('testFunctionsStats')] - public function testCustomDomainsFunctionStats(array $data): void + public function testCustomDomainsFunctionStats(): void { + $data = $this->setupFunctionsStats(); $functionId = $data['functionId']; - $response = $this->client->call(Client::METHOD_PUT, '/functions/' . $functionId, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'] - ], $this->getHeaders()), [ - 'name' => 'Test', - 'execute' => ['any'] - ]); + $response = $this->client->call( + Client::METHOD_PUT, + '/functions/' . $functionId, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'name' => 'Test', + 'execute' => ['any'] + ] + ); $this->assertEquals(200, $response['headers']['status-code']); @@ -1872,22 +1944,23 @@ class UsageTest extends Scope $domain = $rule['body']['domain']; - $this->assertEventually(function () use (&$response, $functionId) { - $response = $this->client->call( + // Snapshot both baselines in a single assertEventually so we only pay the polling + // wait once. Each block is a separate console GET, so they don't interfere. + $functionsMetrics = []; + $projectMetrics = []; + + $this->assertEventually(function () use (&$functionsMetrics, &$projectMetrics, $functionId) { + $functionsResponse = $this->client->call( Client::METHOD_GET, '/functions/' . $functionId . '/usage?range=30d', $this->getConsoleHeaders() ); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(24, count($response['body'])); - $this->assertEquals('30d', $response['body']['range']); - }); + $this->assertEquals(200, $functionsResponse['headers']['status-code']); + $this->assertEquals(24, count($functionsResponse['body'])); + $this->assertEquals('30d', $functionsResponse['body']['range']); - $functionsMetrics = $response['body']; - - $this->assertEventually(function () use (&$response) { - $response = $this->client->call( + $projectResponse = $this->client->call( Client::METHOD_GET, '/project/usage', $this->getConsoleHeaders(), @@ -1897,10 +1970,11 @@ class UsageTest extends Scope 'endDate' => self::getTomorrow(), ] ); - $this->assertEquals(200, $response['headers']['status-code']); - }); + $this->assertEquals(200, $projectResponse['headers']['status-code']); - $projectMetrics = $response['body']; + $functionsMetrics = $functionsResponse['body']; + $projectMetrics = $projectResponse['body']; + }); // Create custom domain execution $proxyClient = new Client(); @@ -1947,10 +2021,11 @@ class UsageTest extends Scope }); } + #[Retry(count: 1)] public function testEmbeddingsTextUsageDoesNotBreakProjectUsage(): void { - // Trigger embeddings endpoint a few times so stats usage worker has data to aggregate - for ($i = 0; $i < 3; $i++) { + $callCount = 0; + $this->assertEventually(function () use (&$callCount) { $response = $this->client->call( Client::METHOD_POST, '/vectorsdb/embeddings/text', @@ -1961,9 +2036,31 @@ class UsageTest extends Scope ], $this->getHeaders()), [ 'model' => 'embeddinggemma', - 'texts' => [ - 'usage test text ' . $i, - ], + 'texts' => ['usage test warm-up ' . $callCount], + ] + ); + $callCount++; + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['embeddings']); + $first = $response['body']['embeddings'][0] ?? []; + $this->assertSame('', (string)($first['error'] ?? ''), 'embed adapter still reporting error - model warming up'); + $this->assertNotEmpty($first['embedding'] ?? []); + }, 600_000, 5_000); + + // Now run a couple more for stable per-call assertions. + for ($i = 0; $i < 2; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/embeddings/text', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), + [ + 'model' => 'embeddinggemma', + 'texts' => ['usage test text ' . $i], ] ); @@ -2013,7 +2110,7 @@ class UsageTest extends Scope $this->assertGreaterThanOrEqual(0, $response['body']['embeddingsTextErrorsTotal']); $this->assertGreaterThan(0, $response['body']['embeddingsTextTokensTotal']); $this->assertGreaterThan(0, $response['body']['embeddingsTextDurationTotal']); - }); + }, 60_000, 1_000); } public function tearDown(): void