client = new Client(); $this->client->setEndpoint($this->endpoint); $format = System::getEnv('_APP_E2E_RESPONSE_FORMAT'); if (!empty($format)) { if ( !\preg_match('/^\d+\.\d+\.\d+$/', $format) || !\version_compare($format, APP_VERSION_STABLE, '<=') ) { throw new \Exception('E2E response format must be ' . APP_VERSION_STABLE . ' or lower.'); } $this->client->setResponseFormat($format); } } protected function tearDown(): void { $this->client = null; } /** * @var array|null Cached console variables */ protected static ?array $consoleVariables = null; /** * Fetch console variables from the API */ protected function getConsoleVariables(): array { if (self::$consoleVariables !== null) { return self::$consoleVariables; } $root = $this->getRoot(); for ($i = 0; $i < 3; $i++) { $response = $this->client->call(Client::METHOD_GET, '/console/variables', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $root['session'], ]); if ($response['headers']['status-code'] === 200 && !empty($response['body'])) { self::$consoleVariables = $response['body']; return self::$consoleVariables; } \usleep(500000); } self::$consoleVariables = $response['body'] ?? []; return self::$consoleVariables; } /** * Check if the database adapter supports relationships */ protected function getSupportForRelationships(): bool { return $this->getConsoleVariables()['supportForRelationships'] ?? true; } /** * Check if the database adapter supports operators */ protected function getSupportForOperators(): bool { return $this->getConsoleVariables()['supportForOperators'] ?? true; } /** * Check if the database adapter supports spatial attributes */ protected function getSupportForSpatials(): bool { return $this->getConsoleVariables()['supportForSpatials'] ?? true; } /** * Check if the database adapter supports spatial indexes on nullable columns */ protected function getSupportForSpatialIndexNull(): bool { return $this->getConsoleVariables()['supportForSpatialIndexNull'] ?? false; } /** * Check if the database adapter supports fulltext wildcard search */ protected function getSupportForFulltextWildcard(): bool { return $this->getConsoleVariables()['supportForFulltextWildcard'] ?? true; } /** * Check if the database adapter supports multiple fulltext indexes per collection */ protected function getSupportForMultipleFulltextIndexes(): bool { return $this->getConsoleVariables()['supportForMultipleFulltextIndexes'] ?? true; } /** * Check if the database adapter supports resizing attributes */ protected function getSupportForAttributeResizing(): bool { return $this->getConsoleVariables()['supportForAttributeResizing'] ?? true; } /** * Check if the database adapter supports fixed schemas with row width limits */ protected function getSupportForSchemas(): bool { return $this->getConsoleVariables()['supportForSchemas'] ?? true; } /** * Check if the database adapter supports attributes */ protected function getSupportForAttributes(): bool { return $this->getConsoleVariables()['supportForAttributes'] ?? true; } /** * Get the maximum index length supported by the database adapter */ protected function getMaxIndexLength(): int { return $this->getConsoleVariables()['maxIndexLength'] ?? 767; } /** * Check if the database adapter uses integer sequence IDs */ protected function getSupportForIntegerIds(): bool { return $this->getConsoleVariables()['supportForIntegerIds'] ?? true; } protected function getLastEmail(int $limit = 1, ?callable $probe = null): array { $result = []; $this->assertEventually(function () use (&$result, $limit, $probe) { $emails = json_decode(file_get_contents('http://maildev:1080/email'), true); $this->assertNotEmpty($emails, 'Maildev should have at least one email'); $this->assertIsArray($emails); if ($probe !== null && $limit === 1) { for ($i = count($emails) - 1; $i >= 0; $i--) { try { $probe($emails[$i]); $result = $emails[$i]; return; } catch (\Throwable) { continue; } } $this->fail('No email matching probe found'); } elseif ($limit === 1) { $result = end($emails); } else { $result = array_slice($emails, -1 * $limit); $this->assertCount($limit, $result, "Expected {$limit} emails but only got " . count($result)); } $this->assertNotEmpty($result, 'Expected email result to be non-empty'); }, 15_000, 500); return $result; } /** * Get the last email sent to a specific address. * This is more reliable than getLastEmail() when tests run in parallel. */ protected function getLastEmailByAddress(string $address, ?callable $probe = null): array { $result = []; $this->assertEventually(function () use (&$result, $address, $probe) { $emails = json_decode(file_get_contents('http://maildev:1080/email'), true); $this->assertNotEmpty($emails, 'Maildev should have at least one email'); $this->assertIsArray($emails); // Search from the end (most recent) to the beginning for ($i = count($emails) - 1; $i >= 0; $i--) { $email = $emails[$i]; if (isset($email['to']) && is_array($email['to'])) { foreach ($email['to'] as $recipient) { if (isset($recipient['address']) && $recipient['address'] === $address) { if ($probe !== null) { try { $probe($email); } catch (\Throwable) { continue 2; } } $result = $email; return; } } } } $this->fail("No email found for address: {$address}" . ($probe !== null ? ' matching probe' : '')); }, 15_000, 500); return $result; } protected function extractQueryParamsFromEmailLink(string $html): array { foreach (['/join-us?', '/verification?', '/recovery?'] as $prefix) { $linkStart = strpos($html, $prefix); if ($linkStart !== false) { $hrefStart = strrpos(substr($html, 0, $linkStart), 'href="'); if ($hrefStart === false) { continue; } $hrefStart += 6; $hrefEnd = strpos($html, '"', $hrefStart); if ($hrefEnd === false || $hrefStart >= $hrefEnd) { continue; } $link = substr($html, $hrefStart, $hrefEnd - $hrefStart); $link = strtok($link, '#'); // Remove `#title` $queryStart = strpos($link, '?'); if ($queryStart === false) { continue; } $queryString = substr($link, $queryStart + 1); parse_str(html_entity_decode($queryString), $queryParams); return $queryParams; } } return []; } protected function assertLastRequest(callable $probe, string $type, $timeoutMs = 20_000, $waitMs = 500): array { $hostname = match ($type) { 'webhook' => 'request-catcher-webhook', 'sms' => 'request-catcher-sms', default => throw new \Exception('Invalid request catcher type.'), }; $this->assertEventually(function () use (&$request, $probe, $hostname) { $request = json_decode(file_get_contents('http://' . $hostname . ':5000/__last_request__'), true); $request['data'] = json_decode($request['data'], true); call_user_func($probe, $request); }, $timeoutMs, $waitMs); return $request; } protected function assertSamePixels(string $expectedImagePath, string $actualImageBlob): void { $expected = new \Imagick($expectedImagePath); $actual = new \Imagick(); $actual->readImageBlob($actualImageBlob); foreach ([$expected, $actual] as $image) { $image->setImageFormat('PNG'); $image->stripImage(); $image->setOption('png:exclude-chunks', 'date,time,iCCP,sRGB,gAMA,cHRM'); } $this->assertSame($expected->getImageSignature(), $actual->getImageSignature()); } /** * @deprecated Use getLastRequestForProject instead. Used only historically in webhook tests */ protected function getLastRequest(?callable $probe = null): array { $project = $this->getProject(); $this->assertArrayHasKey('$id', $project, 'Project must have an $id'); return $this->getLastRequestForProject($project['$id'], self::REQUEST_TYPE_WEBHOOK, [], 10, 500, $probe); } /** * Get the last webhook request for a specific project. * Polls with retry to handle parallel test race conditions. */ protected function getLastRequestForProject( string $projectId, string $type = self::REQUEST_TYPE_WEBHOOK, array $queryParams = [], int $maxAttempts = 10, int $delayMs = 500, ?callable $probe = null ): array { $hostname = match ($type) { self::REQUEST_TYPE_WEBHOOK => 'request-catcher-webhook', self::REQUEST_TYPE_SMS => 'request-catcher-sms', default => throw new \Exception('Invalid request catcher type.'), }; $enforceProjectId = $type === self::REQUEST_TYPE_WEBHOOK; if (empty($queryParams)) { $queryParams = [ 'header_X-Appwrite-Webhook-Project-Id' => $projectId, ]; } $query = http_build_query($queryParams); for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { $requests = json_decode(file_get_contents('http://' . $hostname . ':5000/__find_request__?' . $query), true); if (is_array($requests)) { for ($i = count($requests) - 1; $i >= 0; $i--) { $request = $this->decodeRequestData($requests[$i]); if ($probe !== null) { try { $probe($request); return $request; } catch (\Throwable $error) { continue; } } if ($enforceProjectId) { $requestProjectId = $request['headers']['X-Appwrite-Webhook-Project-Id'] ?? ''; if ($requestProjectId === $projectId) { return $request; } } else { return $request; } } } usleep($delayMs * 1000); } $requests = json_decode(file_get_contents('http://' . $hostname . ':5000/__find_request__?' . $query), true); if (is_array($requests)) { for ($i = count($requests) - 1; $i >= 0; $i--) { $request = $this->decodeRequestData($requests[$i]); if ($probe !== null) { try { $probe($request); return $request; } catch (\Throwable $error) { continue; } } if ($enforceProjectId) { $requestProjectId = $request['headers']['X-Appwrite-Webhook-Project-Id'] ?? ''; if ($requestProjectId === $projectId) { return $request; } } else { return $request; } } } return []; } protected function decodeRequestData(array $request): array { if (!array_key_exists('data', $request)) { return $request; } if (is_array($request['data'])) { return $request; } if (!is_string($request['data']) || $request['data'] === '') { return $request; } $decoded = json_decode($request['data'], true); if (json_last_error() === JSON_ERROR_NONE) { $request['data'] = $decoded; return $request; } parse_str($request['data'], $parsed); if (!empty($parsed)) { $request['data'] = $parsed; } return $request; } /** * @return array */ abstract public function getHeaders(bool $devKey = true): array; /** * @return array */ abstract public function getProject(): array; /** * @var array */ protected static $root = []; /** * @return array */ public function getRoot(): array { if ((self::$root)) { return self::$root; } $maxRetries = 5; for ($attempt = 0; $attempt < $maxRetries; $attempt++) { // Use more entropy to avoid collisions in parallel test execution $email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test'; $password = 'password'; $name = 'User Name'; $root = $this->client->call(Client::METHOD_POST, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', ], [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); if ($root['headers']['status-code'] !== 201) { \usleep(500000); continue; } $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', ], [ 'email' => $email, 'password' => $password, ]); if (empty($session['cookies']['a_session_console'])) { \usleep(500000); continue; } // Verify session is valid before returning $verify = $this->client->call(Client::METHOD_GET, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_console=' . $session['cookies']['a_session_console'], 'x-appwrite-project' => 'console', ]); if ($verify['headers']['status-code'] === 200) { self::$root = [ '$id' => ID::custom($root['body']['$id']), 'name' => $root['body']['name'], 'email' => $root['body']['email'], 'session' => $session['cookies']['a_session_console'], ]; return self::$root; } \usleep(500000); } $this->fail('Failed to create and verify root session after ' . $maxRetries . ' attempts'); } /** * @var array */ protected static $user = []; /** * @return array */ public function getUser(bool $fresh = false): array { $projectId = $this->getProject()['$id']; if (!$fresh && isset(self::$user[$projectId])) { return self::$user[$projectId]; } // Use more entropy to avoid collisions in parallel test execution $email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test'; $password = 'password'; $name = 'User Name'; $user = $this->client->call(Client::METHOD_POST, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ], [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $this->assertEquals(201, $user['headers']['status-code']); $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ], [ 'email' => $email, 'password' => $password, ]); self::$user[$projectId] = [ '$id' => ID::custom($user['body']['$id']), 'name' => $user['body']['name'], 'email' => $user['body']['email'], 'session' => $session['cookies']['a_session_' . $projectId], 'sessionId' => $session['body']['$id'], ]; return self::$user[$projectId]; } }