getProject(true); self::$project = $projectBackup; return self::$destinationProject; } /** * Set up a database for migration tests with static caching * @return array */ protected function setupMigrationDatabase(): array { if (!empty(self::$cachedDatabaseData)) { return self::$cachedDatabaseData; } $response = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Test Database' ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); self::$cachedDatabaseData = [ 'databaseId' => $response['body']['$id'], ]; return self::$cachedDatabaseData; } /** * Set up a table with column for migration tests with static caching * @return array */ protected function setupMigrationTable(): array { if (!empty(self::$cachedTableData)) { return self::$cachedTableData; } // Ensure database exists first $dbData = $this->setupMigrationDatabase(); $databaseId = $dbData['databaseId']; $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'tableId' => ID::unique(), 'name' => 'Test Table', ]); $this->assertEquals(201, $table['headers']['status-code']); $tableId = $table['body']['$id']; // Create Column $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'key' => 'name', 'size' => 100, 'encrypt' => false, 'required' => true ]); $this->assertEquals(202, $response['headers']['status-code']); // Wait for column to be ready $this->assertEventually(function () use ($databaseId, $tableId) { $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('available', $response['body']['status']); }, 5000, 500); self::$cachedTableData = [ 'databaseId' => $databaseId, 'tableId' => $tableId, ]; return self::$cachedTableData; } public function performMigrationSync(array $body): array { $migration = $this->client->call(Client::METHOD_POST, '/migrations/appwrite', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ], $body); $this->assertEquals(202, $migration['headers']['status-code']); $this->assertNotEmpty($migration['body']); $this->assertNotEmpty($migration['body']['$id']); $migrationResult = []; $this->assertEventually(function () use ($migration, &$migrationResult) { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migration['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); if ($response['body']['status'] === 'failed') { $this->fail('Migration failed' . json_encode($response['body'], JSON_PRETTY_PRINT)); } $this->assertNotEquals('failed', $response['body']['status']); $this->assertEquals('completed', $response['body']['status']); $migrationResult = $response['body']; return true; }, 60_000, 1_000); return $migrationResult; } /** * Get migration status by ID (without creating a new migration) * * @param string $migrationId * @return array */ public function getMigrationStatus(string $migrationId): array { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); return $response['body']; } /** * Appwrite E2E Migration Tests */ public function testCreateAppwriteMigration(): void { $response = $this->performMigrationSync([ 'resources' => Appwrite::getSupportedResources(), 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals(Appwrite::getSupportedResources(), $response['resources']); $this->assertEquals('Appwrite', $response['source']); $this->assertEquals('Appwrite', $response['destination']); $this->assertEmpty($response['statusCounters']); } /** * Auth */ public function testAppwriteMigrationAuthUserPassword(): void { $response = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => 'test@test.com', 'password' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals('test@test.com', $response['body']['email']); $user = $response['body']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_USER], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_USER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_USER]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['warning']); $response = $this->client->call(Client::METHOD_GET, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($user['email'], $response['body']['email']); $this->assertEquals($user['password'], $response['body']['password']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } public function testAppwriteMigrationAuthUserPhone(): void { $response = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'phone' => '+12065550100', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals('+12065550100', $response['body']['phone']); $user = $response['body']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_USER], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_USER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_USER]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['warning']); $response = $this->client->call(Client::METHOD_GET, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($user['phone'], $response['body']['phone']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationAuthTeam(): void { $user = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => 'test@test.com', 'password' => 'password', ]); $this->assertEquals(201, $user['headers']['status-code']); $this->assertNotEmpty($user['body']); $this->assertNotEmpty($user['body']['$id']); $this->assertEquals('test@test.com', $user['body']['email']); $team = $this->client->call(Client::METHOD_POST, '/teams', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'teamId' => ID::unique(), 'name' => 'Test Team', ]); $this->assertEquals(201, $team['headers']['status-code']); $this->assertNotEmpty($team['body']); $this->assertNotEmpty($team['body']['$id']); $membership = $this->client->call(Client::METHOD_POST, '/teams/' . $team['body']['$id'] . '/memberships', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'teamId' => $team['body']['$id'], 'userId' => $user['body']['$id'], 'roles' => ['owner'], ]); $this->assertEquals(201, $membership['headers']['status-code']); $this->assertNotEmpty($membership['body']); $this->assertNotEmpty($membership['body']['$id']); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_USER, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_USER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_USER]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_USER]['warning']); $this->assertArrayHasKey(Resource::TYPE_TEAM, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TEAM]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TEAM]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_TEAM]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TEAM]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TEAM]['warning']); $this->assertArrayHasKey(Resource::TYPE_MEMBERSHIP, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MEMBERSHIP]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MEMBERSHIP]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_MEMBERSHIP]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MEMBERSHIP]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MEMBERSHIP]['warning']); $response = $this->client->call(Client::METHOD_GET, '/teams/' . $team['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($team['body']['name'], $response['body']['name']); $response = $this->client->call(Client::METHOD_GET, '/teams/' . $team['body']['$id'] . '/memberships', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $membership = $response['body']['memberships'][0]; $this->assertEquals($user['body']['$id'], $membership['userId']); $this->assertEquals($team['body']['$id'], $membership['teamId']); $this->assertEquals(['owner'], $membership['roles']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $user['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $user['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/teams/' . $team['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } /** * Databases */ public function testAppwriteMigrationDatabase(): void { $response = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Test Database' ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $databaseId = $response['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_DATABASE], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_DATABASE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['warning']); $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($databaseId, $response['body']['$id']); $this->assertEquals('Test Database', $response['body']['name']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); // Cleanup on source $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } public function testAppwriteMigrationDatabasesTable(): void { // Set up database using helper method (with static caching) $data = $this->setupMigrationDatabase(); $databaseId = $data['databaseId']; $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'tableId' => ID::unique(), 'name' => 'Test Table', ]); $this->assertEquals(201, $table['headers']['status-code']); $tableId = $table['body']['$id']; // Create Column $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'key' => 'name', 'size' => 100, 'encrypt' => false, 'required' => true ]); $this->assertEquals(202, $response['headers']['status-code']); // Wait for column to be ready $this->assertEventually(function () use ($databaseId, $tableId) { $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('available', $response['body']['status']); }, 5000, 500); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN], $result['resources']); foreach ([Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN] as $resource) { $this->assertArrayHasKey($resource, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][$resource]['error']); $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); $this->assertEquals(1, $result['statusCounters'][$resource]['success']); $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); } $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($tableId, $response['body']['$id']); $this->assertEquals('Test Table', $response['body']['name']); $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals('name', $response['body']['key']); $this->assertEquals(100, $response['body']['size']); $this->assertEquals(true, $response['body']['required']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); // Cleanup on source $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); // Clear the cache since we cleaned up self::$cachedDatabaseData = []; } public function testAppwriteMigrationDatabasesRow(): void { // Set up table using helper method (with static caching) $data = $this->setupMigrationTable(); $tableId = $data['tableId']; $databaseId = $data['databaseId']; $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'rowId' => ID::unique(), 'data' => [ 'name' => 'Test Row', ] ]); $this->assertEquals(201, $row['headers']['status-code']); $this->assertNotEmpty($row['body']); $this->assertNotEmpty($row['body']['$id']); $rowId = $row['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $finalStats = $this->client->call(Client::METHOD_GET, '/project/usage', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'startDate' => UsageTest::getYesterday(), 'endDate' => UsageTest::getTomorrow(), ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW], $result['resources']); // TODO: Add TYPE_ROW to the migration status counters once pending issue is resolved foreach ([Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN] as $resource) { $this->assertArrayHasKey($resource, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][$resource]['error']); $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); $this->assertEquals(1, $result['statusCounters'][$resource]['success']); $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); } $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($rowId, $response['body']['$id']); $this->assertEquals('Test Row', $response['body']['name']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); // Cleanup on source $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); // Clear the caches since we cleaned up self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */ public function testAppwriteMigrationRowsOnDuplicate(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => ID::unique(), 'data' => ['name' => 'Original'], ]); $this->assertEquals(201, $row['headers']['status-code']); $rowId = $row['body']['$id']; $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; // First migration: destination is empty, strict completion expected. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // Mutate destination row to prove onDuplicate=skip preserves it. $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [ 'data' => ['name' => 'Mutated'], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals('Mutated', $mutate['body']['name']); // Re-migration with onDuplicate=skip — completion is strict because // DestinationAppwrite tolerates existing schema resources. $skipResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); $this->assertEquals('completed', $skipResult['status']); $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); // Re-migration with onDuplicate=overwrite — strict completion; destination // row restored to source value. $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']); $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Unchanged source under Skip/Overwrite is a no-op — every resource Tolerated. */ public function testAppwriteMigrationReRunIsIdempotent(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; // Seed two rows on source so the row-level tolerance is exercised too. foreach (['row-a', 'row-b'] as $rowId) { $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => $rowId, 'data' => ['name' => 'Seeded ' . $rowId], ]); $this->assertEquals(201, $row['headers']['status-code']); } $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; // First migration: fresh destination. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // Re-run under Skip: nothing on source has changed. Destination // schema + rows are already correct — expect clean completion. $reRunSkip = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); $this->assertEquals('completed', $reRunSkip['status']); // Re-run under Overwrite: same unchanged source. Schema tolerance path // fires for each resource; rows go through DB-native upsert. $reRunOverwrite = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $reRunOverwrite['status']); foreach (['row-a', 'row-b'] as $rowId) { $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $check['headers']['status-code']); $this->assertEquals('Seeded ' . $rowId, $check['body']['name']); } $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */ public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $rowId = 'persist-me'; $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => $rowId, 'data' => ['name' => 'SeedRow'], ]); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; // First migration — dest empty, strict completion. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // `_updatedAt` is stored at second granularity (strtotime) — ensure // the source edits below produce a strictly-newer timestamp than // dest's first-migration timestamp. sleep(1); // Mutate source: rename database + toggle table enabled. $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [ 'name' => 'Renamed Source DB', ]); $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ 'name' => 'Renamed Source Table', 'permissions' => [Permission::read(Role::any())], 'rowSecurity' => true, 'enabled' => false, ]); // Overwrite re-migration: UpdateInPlace path fires for database + table. $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); // Assert dest database metadata reflects source's new values. $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders); $this->assertEquals(200, $destDb['headers']['status-code']); $this->assertEquals('Renamed Source DB', $destDb['body']['name']); // Assert dest table metadata reflects source's new values. $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); $this->assertEquals(200, $destTable['headers']['status-code']); $this->assertEquals('Renamed Source Table', $destTable['body']['name']); $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false'); $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true'); // Child row untouched — UpdateInPlace only rewrites container metadata. $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Skip preserves dest container drift even when source has diverged. */ public function testAppwriteMigrationSkipPreservesContainerDrift(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ]; // First migration: dest gets whatever source had. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); sleep(1); // Mutate dest: ops tightens permissions and renames the table for // its production-specific branding. $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [ 'name' => 'Dest-Managed Table', 'permissions' => [Permission::read(Role::users())], 'rowSecurity' => false, 'enabled' => true, ]); // Also mutate source so the second run has a real divergence. $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ 'name' => 'Source Renamed', 'permissions' => [Permission::read(Role::any())], 'rowSecurity' => true, 'enabled' => false, ]); // Skip re-migration: must tolerate existing destination — no update. $skipResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); $this->assertEquals('completed', $skipResult['status']); // Dest kept its tightened values. $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); $this->assertEquals(200, $destTable['headers']['status-code']); $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift'); $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */ public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; // First migration: dest mirrors source (one column 'name'). $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // Add an orphan column directly on destination (not on source). // Simulates the post-rename state: source dropped a column, dest // still has it — or a dest-only column added by a separate app. $orphanResp = $this->client->call( Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $destHeaders, [ 'key' => 'orphan_col', 'size' => 50, 'required' => false, ] ); $this->assertEquals(202, $orphanResp['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 5000, 500); // Seed a row on source so per-table orphan cleanup fires inside // createRecord (before rows land), not just at end of run. $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => ID::unique(), 'data' => ['name' => 'seed'], ]); // Overwrite re-migration: orphan_col must be dropped from dest. $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); // Orphan column dropped. $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares'); // Source's column preserved. $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Skip preserves orphan columns; cleanup is Overwrite-only. */ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); $orphanResp = $this->client->call( Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $destHeaders, [ 'key' => 'dest_only_col', 'size' => 50, 'required' => false, ] ); $this->assertEquals(202, $orphanResp['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 5000, 500); $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => ID::unique(), 'data' => ['name' => 'seed'], ]); // Skip re-migration: orphan column must NOT be dropped. $skipResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); $this->assertEquals('completed', $skipResult['status']); $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */ public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $rowId = 'persist-on-inplace'; // Seed a row that proves drop+recreate didn't happen — recreate would // have wiped this column's data on the destination. $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => $rowId, 'data' => ['name' => 'SeedRow'], ]); $this->assertEquals(201, $row['headers']['status-code']); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; // First migration — dest gets the column as required:true. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $beforeUpdate['headers']['status-code']); $this->assertTrue($beforeUpdate['body']['required']); // _updatedAt has second granularity; ensure source's PATCH produces a // strictly-newer timestamp than the dest's first-migration value. sleep(1); // SDK-reachable change set: required true→false, default null→'unknown'. // Both fields are supported by PATCH /columns/string/:key — must route // through updateAttributeInPlace, not DropAndRecreate. $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ 'required' => false, 'default' => 'unknown', ]); $this->assertEquals(200, $patch['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertFalse($r['body']['required']); $this->assertEquals('unknown', $r['body']['default']); }, 5000, 500); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false'); $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default'); }, 10000, 500); // Pre-existing row preserved — proof that the path was UpdateInPlace // and not DropAndRecreate (which would have nulled this column). $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfter['headers']['status-code']); $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */ public function testAppwriteMigrationSkipPreservesAttributeDrift(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); sleep(1); // Dest divergence: ops loosens the column for a production-only need. $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [ 'required' => false, 'default' => 'dest-default', ]); $this->assertEquals(200, $destPatch['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertFalse($r['body']['required']); }, 5000, 500); sleep(1); // Source advances strictly later (and to a different value). Under // Overwrite this would propagate to dest; under Skip it must not. $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ 'required' => true, 'default' => null, ]); $this->assertEquals(200, $sourcePatch['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertTrue($r['body']['required']); }, 5000, 500); $skipResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); $this->assertEquals('completed', $skipResult['status']); $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $destAttr['headers']['status-code']); $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift'); $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */ public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $databaseId = ID::unique(); $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ 'databaseId' => $databaseId, 'name' => 'Rel In-Place DB', ]); $this->assertEquals(201, $createDb['headers']['status-code']); foreach (['parents', 'children'] as $tbl) { $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ 'tableId' => $tbl, 'name' => $tbl, ]); $this->assertEquals(201, $createTable['headers']['status-code']); } // Two-way: parents.kids ↔ children.parent. Required to hit the in-place path. $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, 'twoWay' => true, 'key' => 'kids', 'twoWayKey' => 'parent', 'onDelete' => Database::RELATION_MUTATE_CASCADE, ]); $this->assertEquals(202, $createRel['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); }, 10000, 500); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // Both sides land on dest with onDelete=cascade. $this->assertEventually(function () use ($databaseId, $destHeaders) { $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); $this->assertEquals(200, $parent['headers']['status-code']); $this->assertEquals('available', $parent['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']); $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); $this->assertEquals(200, $child['headers']['status-code']); $this->assertEquals('available', $child['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']); }, 10000, 500); sleep(1); // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete. $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ 'onDelete' => Database::RELATION_MUTATE_RESTRICT, ]); $this->assertEquals(200, $patch['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); }, 5000, 500); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); // Both sides on dest must reflect onDelete=restrict. Asserting the // partner side is the regression guard for the previously-missed // partner meta refresh in updateRelationshipInPlace. $this->assertEventually(function () use ($databaseId, $destHeaders) { $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); $this->assertEquals(200, $parent['headers']['status-code']); $this->assertEquals('available', $parent['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source'); $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType'); $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay'); $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); $this->assertEquals(200, $child['headers']['status-code']); $this->assertEquals('available', $child['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update'); }, 10000, 500); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */ public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $databaseId = ID::unique(); $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ 'databaseId' => $databaseId, 'name' => 'Two-Way Recreate DB', ]); $this->assertEquals(201, $createDb['headers']['status-code']); foreach (['parents', 'children'] as $tbl) { $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ 'tableId' => $tbl, 'name' => $tbl, 'permissions' => [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $this->assertEquals(201, $createTable['headers']['status-code']); } // Add a non-relationship column on parents so we can POST a row with // non-empty data. tablesdb POST /rows rejects empty data arrays in // 1.9.x (Create.php:161 — getSupportForEmptyDocument() defaults false). $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [ 'key' => 'label', 'size' => 32, 'required' => false, ]); $this->assertEquals(202, $createLabel['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, 'twoWay' => true, 'key' => 'kids', 'twoWayKey' => 'parent', 'onDelete' => Database::RELATION_MUTATE_CASCADE, ]); $this->assertEquals(202, $createRel['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [ 'rowId' => 'parent-1', 'data' => ['label' => 'p1'], ]); $this->assertEquals(201, $parentRow['headers']['status-code']); $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [ 'rowId' => 'child-1', 'data' => ['parent' => 'parent-1'], ]); $this->assertEquals(201, $childRow['headers']['status-code']); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); // Recreate the relationship on source so its createdAt advances past // dest's stored value — forces SchemaAction::DropAndRecreate on the // parent side, which is the path the partner-side dedup guards. sleep(1); $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(204, $deleteRel['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(404, $r['headers']['status-code']); }, 10000, 500); sleep(1); $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, 'twoWay' => true, 'key' => 'kids', 'twoWayKey' => 'parent', 'onDelete' => Database::RELATION_MUTATE_CASCADE, ]); $this->assertEquals(202, $recreate['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); // Child-row's relationship was wiped by the source-side delete. Re-link. $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [ 'data' => ['parent' => 'parent-1'], ]); $this->assertEquals(200, $relink['headers']['status-code']); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $destHeaders) { $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); $this->assertEquals(200, $parent['headers']['status-code']); $this->assertEquals('available', $parent['body']['status']); $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); $this->assertEquals(200, $child['headers']['status-code']); $this->assertEquals('available', $child['body']['status']); }, 10000, 500); // Both rows survive the re-migration. If the partner-side dedup were // missing and the partner pass re-fired DropAndRecreate, the partner // (children) table's row would have been wiped before the row pass. $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders); $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration'); $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */ public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $databaseId = ID::unique(); $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ 'databaseId' => $databaseId, 'name' => 'One-Way DropAndRecreate DB', ]); $this->assertEquals(201, $createDb['headers']['status-code']); foreach (['parents', 'children'] as $tbl) { $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ 'tableId' => $tbl, 'name' => $tbl, ]); $this->assertEquals(201, $createTable['headers']['status-code']); } $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, 'twoWay' => false, 'key' => 'kids', 'onDelete' => Database::RELATION_MUTATE_CASCADE, ]); $this->assertEquals(202, $createRel['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); $this->assertEventually(function () use ($databaseId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); }, 10000, 500); sleep(1); $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ 'onDelete' => Database::RELATION_MUTATE_RESTRICT, ]); $this->assertEquals(200, $patch['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); $this->assertEquals('available', $r['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); }, 5000, 500); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete'); $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType'); $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false'); }, 10000, 500); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */ public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $rowId = 'row-after-recreate'; $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => $rowId, 'data' => ['name' => 'before-recreate'], ]); $this->assertEquals(201, $row['headers']['status-code']); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); sleep(1); // Drop + recreate the column on source. createdAt advances → re-migration // must take the createdAt-diff DropAndRecreate path on dest. $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(204, $delete['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(404, $r['headers']['status-code']); }, 10000, 500); // Recreate with `array: true` — a non-SDK change (`array` is in // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail // and the caller to fall through to drop+recreate, which is what // this test pins. $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ 'key' => 'name', 'size' => 100, 'required' => false, 'array' => true, ]); $this->assertEquals(202, $recreate['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now). $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ 'data' => ['name' => ['after-recreate']], ]); $this->assertEquals(200, $relink['headers']['status-code']); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $col['headers']['status-code']); $this->assertEquals('available', $col['body']['status']); $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)'); $this->assertFalse($col['body']['required']); }, 10000, 500); $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfter['headers']['status-code']); $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */ public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; $rowId = 'row-spec-match'; $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ 'rowId' => $rowId, 'data' => ['name' => 'before-recreate'], ]); $resources = [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, ]; $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $first['status']); $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $destBefore['headers']['status-code']); $destCreatedAtBefore = $destBefore['body']['$createdAt']; sleep(1); // Drop + recreate with the EXACT same spec as setupMigrationTable // (size=100, required=true). Source's $createdAt advances but the // spec is identical → spec-match guard must force Tolerate. $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(204, $delete['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(404, $r['headers']['status-code']); }, 10000, 500); $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ 'key' => 'name', 'size' => 100, 'required' => true, ]); $this->assertEquals(202, $recreate['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); $this->assertEquals(200, $r['headers']['status-code']); $this->assertEquals('available', $r['body']['status']); }, 10000, 500); $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ 'data' => ['name' => 'after-recreate'], ]); $this->assertEquals(200, $relink['headers']['status-code']); $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'overwrite', ]); $this->assertEquals('completed', $overwriteResult['status']); // Spec-match guard fired → dest column's $createdAt stayed at the // first-migration value. If DropAndRecreate had run, $createdAt // would have been bumped to source's NEW createdAt. $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $destAfter['headers']['status-code']); $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched'); $this->assertEquals(100, $destAfter['body']['size']); $this->assertTrue($destAfter['body']['required']); // Row pass under Overwrite still propagated source's new row value. $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfter['headers']['status-code']); $this->assertEquals('after-recreate', $rowAfter['body']['name']); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } /** * Storage */ public function testAppwriteMigrationStorageBucket(): void { $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'bucketId' => ID::unique(), 'name' => 'Test Bucket', 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], 'maximumFileSize' => 1000000, 'allowedFileExtensions' => ['pdf'], 'compression' => 'gzip', 'encryption' => false, 'antivirus' => false ]); $this->assertEquals(201, $bucket['headers']['status-code']); $this->assertNotEmpty($bucket['body']); $this->assertNotEmpty($bucket['body']['$id']); $this->assertEquals('Test Bucket', $bucket['body']['name']); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_BUCKET ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_BUCKET], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_BUCKET, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_BUCKET]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['warning']); $response = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucket['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($bucket['body']['$id'], $response['body']['$id']); $this->assertEquals($bucket['body']['name'], $response['body']['name']); $this->assertEquals($bucket['body']['$permissions'], $response['body']['$permissions']); $this->assertEquals($bucket['body']['maximumFileSize'], $response['body']['maximumFileSize']); $this->assertEquals($bucket['body']['allowedFileExtensions'], $response['body']['allowedFileExtensions']); $this->assertEquals($bucket['body']['compression'], $response['body']['compression']); $this->assertEquals($bucket['body']['encryption'], $response['body']['encryption']); $this->assertEquals($bucket['body']['antivirus'], $response['body']['antivirus']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucket['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucket['body']['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } public function testAppwriteMigrationStorageFiles(): void { $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'bucketId' => ID::unique(), 'name' => 'Test Bucket', 'fileSecurity' => true, 'maximumFileSize' => 2000000, //2MB 'allowedFileExtensions' => ['jpg', 'png', 'jfif'], 'permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $this->assertEquals(201, $bucket['headers']['status-code']); $this->assertNotEmpty($bucket['body']['$id']); $bucketId = $bucket['body']['$id']; $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), 'permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], ]); $this->assertEquals(201, $file['headers']['status-code']); $this->assertNotEmpty($file['body']['$id']); $fileId = $file['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_BUCKET, Resource::TYPE_FILE ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_BUCKET, Resource::TYPE_FILE], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_BUCKET, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_BUCKET]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_BUCKET]['warning']); $this->assertArrayHasKey(Resource::TYPE_FILE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FILE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FILE]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_FILE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FILE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FILE]['warning']); $response = $this->client->call(Client::METHOD_GET, '/storage/buckets/' . $bucketId . '/files/' . $fileId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($fileId, $response['body']['$id']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId . '/files/' . $fileId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } /** * Functions */ public function testAppwriteMigrationFunction(): void { $functionId = $this->setupFunction([ 'functionId' => ID::unique(), 'name' => 'Test', 'runtime' => 'node-22', 'entrypoint' => 'index.js' ]); $deploymentId = $this->setupDeployment($functionId, [ 'code' => $this->packageFunction('basic'), 'activate' => true ]); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_FUNCTION, Resource::TYPE_DEPLOYMENT ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_FUNCTION, Resource::TYPE_DEPLOYMENT], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_FUNCTION, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FUNCTION]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FUNCTION]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_FUNCTION]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FUNCTION]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_FUNCTION]['warning']); $this->assertArrayHasKey(Resource::TYPE_DEPLOYMENT, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEPLOYMENT]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEPLOYMENT]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DEPLOYMENT]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEPLOYMENT]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DEPLOYMENT]['warning']); $response = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($functionId, $response['body']['$id']); $this->assertEquals('Test', $response['body']['name']); $this->assertEquals('node-22', $response['body']['runtime']); $this->assertEquals('index.js', $response['body']['entrypoint']); $this->assertEventually(function () use ($functionId) { $deployments = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ])); $this->assertEquals(200, $deployments['headers']['status-code']); $this->assertNotEmpty($deployments['body']); $this->assertEquals(1, $deployments['body']['total']); $this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT)); }, 100000, 500); // Attempt execution $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ], [ 'body' => 'test' ]); $this->assertEquals(201, $execution['headers']['status-code']); $this->assertStringContainsString('body-is-test', $execution['body']['logs']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } /** * Sites */ public function testAppwriteMigrationSite(): void { $site = $this->client->call(Client::METHOD_POST, '/sites', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'siteId' => ID::unique(), 'name' => 'Test Site', 'framework' => 'other', 'buildRuntime' => 'node-22', 'adapter' => 'static', 'outputDirectory' => './', ]); $this->assertEquals(201, $site['headers']['status-code'], 'Create site failed: ' . json_encode($site['body'], JSON_PRETTY_PRINT)); $this->assertNotEmpty($site['body']['$id']); $siteId = $site['body']['$id']; // Create deployment $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'code' => $this->packageSite('static'), 'activate' => true, ]); $this->assertEquals(202, $deployment['headers']['status-code']); $this->assertNotEmpty($deployment['body']['$id']); $deploymentId = $deployment['body']['$id']; // Wait for deployment to be ready $this->assertEventually(function () use ($siteId, $deploymentId) { $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('ready', $response['body']['status'], 'Deployment status is not ready, deployment: ' . json_encode($response['body'], JSON_PRETTY_PRINT)); }, 300000, 500); // Create environment variable $variable = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/variables', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], 'x-appwrite-response-format' => '1.9.3' ], [ 'key' => 'TEST_VAR', 'value' => 'test_value', ]); $this->assertEquals(201, $variable['headers']['status-code']); // Perform migration $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE], $result['resources']); foreach ([Resource::TYPE_SITE, Resource::TYPE_SITE_DEPLOYMENT, Resource::TYPE_SITE_VARIABLE] as $resource) { $this->assertArrayHasKey($resource, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][$resource]['error']); $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); $this->assertEquals(1, $result['statusCounters'][$resource]['success']); $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); } // Verify site in destination $response = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($siteId, $response['body']['$id']); $this->assertEquals('Test Site', $response['body']['name']); $this->assertEquals('node-22', $response['body']['buildRuntime']); $this->assertEquals('other', $response['body']['framework']); $this->assertEquals('static', $response['body']['adapter']); // Verify deployment in destination $this->assertEventually(function () use ($siteId) { $deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $deployments['headers']['status-code']); $this->assertNotEmpty($deployments['body']); $this->assertEquals(1, $deployments['body']['total']); $this->assertEquals('ready', $deployments['body']['deployments'][0]['status'], 'Deployment status is not ready, deployment: ' . json_encode($deployments['body']['deployments'][0], JSON_PRETTY_PRINT)); }, 100000, 500); // Verify variable in destination $variables = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/variables', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $variables['headers']['status-code']); $this->assertEquals(1, $variables['body']['total']); $this->assertEquals('TEST_VAR', $variables['body']['variables'][0]['key']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } private function packageSite(string $site): CURLFile { $stdout = ''; $stderr = ''; $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); } /** * Integrations */ public function testAppwriteMigrationPlatform(): void { $sourceHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]; $destinationHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; // Create platform on source project $response = $this->client->call(Client::METHOD_POST, '/project/platforms/web', $sourceHeaders, [ 'platformId' => ID::unique(), 'name' => 'Test Platform', 'hostname' => 'localhost', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $platform = $response['body']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_PLATFORM, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_PLATFORM], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_PLATFORM, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_PLATFORM]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PLATFORM]['warning']); // Verify platform on destination project using the project's API key $response = $this->client->call(Client::METHOD_GET, '/project/platforms', $destinationHeaders); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertGreaterThan(0, $response['body']['total']); $foundPlatform = null; foreach ($response['body']['platforms'] as $p) { if ($p['name'] === 'Test Platform' && $p['type'] === 'web') { $foundPlatform = $p; break; } } $this->assertNotNull($foundPlatform); $this->assertEquals('web', $foundPlatform['type']); $this->assertEquals('Test Platform', $foundPlatform['name']); $this->assertEquals('localhost', $foundPlatform['hostname']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/project/platforms/' . $foundPlatform['$id'], $destinationHeaders); // Cleanup on source $this->client->call(Client::METHOD_DELETE, '/project/platforms/' . $platform['$id'], $sourceHeaders); } /** * Import documents from a CSV file. */ public function testCreateCSVImport(): void { // Make a database $response = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'databaseId' => ID::unique(), 'name' => 'Test Database' ]); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals('Test Database', $response['body']['name']); $databaseId = $response['body']['$id']; // make a table $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'name' => 'Test table', 'tableId' => ID::unique(), ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals($response['body']['name'], 'Test table'); $tableId = $response['body']['$id']; // make columns $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $this->assertEquals($response['body']['key'], 'name'); $this->assertEquals($response['body']['type'], 'string'); $this->assertEquals($response['body']['size'], 256); $this->assertEquals($response['body']['required'], true); $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'age', 'min' => 18, 'max' => 65, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $this->assertEquals($response['body']['key'], 'age'); $this->assertEquals($response['body']['type'], 'integer'); $this->assertEquals($response['body']['min'], 18); $this->assertEquals($response['body']['max'], 65); $this->assertEquals($response['body']['required'], true); // make a bucket, upload a file to it! $bucketOne = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'bucketId' => ID::unique(), 'name' => 'Test Bucket', 'maximumFileSize' => 2000000, //2MB 'allowedFileExtensions' => ['csv'], 'compression' => 'gzip', 'encryption' => true ]); $this->assertEquals(201, $bucketOne['headers']['status-code']); $this->assertNotEmpty($bucketOne['body']['$id']); $bucketOneId = $bucketOne['body']['$id']; $bucketIds = [ 'default' => $bucketOneId, 'missing-row' => $bucketOneId, 'missing-column' => $bucketOneId, 'irrelevant-column' => $bucketOneId, 'documents-internals' => $bucketOneId, ]; $fileIds = []; foreach ($bucketIds as $label => $bucketId) { $csvFileName = match ($label) { 'missing-row', 'missing-column', 'irrelevant-column', 'documents-internals' => "{$label}.csv", default => 'documents.csv', }; $mimeType = match ($csvFileName) { default => 'text/csv', 'missing-column.csv', 'missing-row.csv' => 'text/plain', // invalid csv structure, falls back to plain text! }; $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/'.$csvFileName), $mimeType, $csvFileName), ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($csvFileName, $response['body']['name']); $this->assertEquals($mimeType, $response['body']['mimeType']); $fileIds[$label] = $response['body']['$id']; } // missing column, fail in worker. $missingColumn = $this->performCsvMigration( [ 'fileId' => $fileIds['missing-column'], 'bucketId' => $bucketIds['missing-column'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($missingColumn) { $migrationId = $missingColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('failed', $migration['body']['status']); $this->assertEquals('CSV', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertEmpty($migration['body']['statusCounters']); $errorJson = $migration['body']['errors'][0]; $errorData = json_decode($errorJson, true); $this->assertThat( implode("\n", $migration['body']['errors']), $this->stringContains("CSV header validation failed: Missing required column: 'age'") ); }, 60_000, 500); // missing row data, fail in worker. $missingColumn = $this->performCsvMigration( [ 'fileId' => $fileIds['missing-row'], 'bucketId' => $bucketIds['missing-row'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($missingColumn) { $migrationId = $missingColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('failed', $migration['body']['status']); $this->assertEquals('CSV', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertEmpty($migration['body']['statusCounters']); $errorJson = $migration['body']['errors'][0]; $errorData = json_decode($errorJson, true); $this->assertThat( implode("\n", $migration['body']['errors']), $this->stringContains('CSV row does not match the number of header columns') ); }, 60_000, 500); // irrelevant column - email, success. $irrelevantColumn = $this->performCsvMigration( [ 'fileId' => $fileIds['irrelevant-column'], 'bucketId' => $bucketIds['irrelevant-column'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($irrelevantColumn) { $migrationId = $irrelevantColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('CSV', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // all data exists, pass. $migration = $this->performCsvMigration( [ 'endpoint' => $this->webEndpoint, 'fileId' => $fileIds['default'], 'bucketId' => $bucketIds['default'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('CSV', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // get rows count $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::limit(150)->toString() ] ]); $this->assertEquals(200, $rows['headers']['status-code']); $this->assertIsArray($rows['body']['rows']); $this->assertIsNumeric($rows['body']['total']); $this->assertEquals(200, $rows['body']['total']); // all data exists and includes internals, pass. $migration = $this->performCsvMigration( [ 'endpoint' => $this->webEndpoint, 'fileId' => $fileIds['documents-internals'], 'bucketId' => $bucketIds['documents-internals'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('CSV', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(25, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); } /** * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests. * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge]. * * @return array{string,string,string,string,string,string,int} */ private function prepareCsvImportFixture(string $testLabel): array { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // database $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ 'databaseId' => ID::unique(), 'name' => 'Test DB ' . $testLabel, ]); $this->assertEquals(201, $response['headers']['status-code']); $databaseId = $response['body']['$id']; // table $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ 'name' => 'Test table ' . $testLabel, 'tableId' => ID::unique(), ]); $this->assertEquals(201, $response['headers']['status-code']); $tableId = $response['body']['$id']; // columns: name, age (match documents.csv fixture) $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ 'key' => 'age', 'min' => 18, 'max' => 65, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); // Columns are created async (202). Wait for both to be `available` // before proceeding so the migration worker doesn't race the schema. foreach (['name', 'age'] as $column) { $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('available', $response['body']['status']); }, 5000, 500); } // bucket $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ 'bucketId' => ID::unique(), 'name' => 'Bucket ' . $testLabel, 'maximumFileSize' => 2000000, 'allowedFileExtensions' => ['csv'], ]); $this->assertEquals(201, $response['headers']['status-code']); $bucketId = $response['body']['$id']; // upload documents.csv (100 rows with $id, name, age columns) $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'), ]); $this->assertEquals(201, $response['headers']['status-code']); $fileId = $response['body']['$id']; // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56 return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; } /** * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged. */ public function testCreateCSVImportSkipDuplicates(): void { [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip'); // First import: 100 rows created $first = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // Mutate one row so we can prove skip does NOT overwrite it $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'data' => ['age' => 22], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); // Second import with onDuplicate=skip: no errors, mutated row preserved $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, 'onDuplicate' => 'skip', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); // Mutated row kept its mutated value (not overwritten by CSV's original age) $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); // Row count still 100 (no duplicates created) $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [Query::limit(150)->toString()], ]); $this->assertEquals(100, $rows['body']['total']); } /** * onDuplicate=overwrite on re-import: existing rows are replaced with imported values. */ public function testCreateCSVImportOverwrite(): void { [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite'); // First import: 100 rows created $first = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // Mutate one row so we can prove overwrite restores it to the CSV's original value $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'data' => ['age' => 22], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); // Second import with onDuplicate=overwrite: mutated row restored to CSV value $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, 'onDuplicate' => 'overwrite', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); // Mutated row is back to CSV's original age (proving overwrite actually replaced the row) $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); // Row count still 100 (no duplicates created) $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [Query::limit(150)->toString()], ]); $this->assertEquals(100, $rows['body']['total']); } /** * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException. * Regression guard so the skip/overwrite additions don't silently change the default. */ public function testCreateCSVImportDefaultFailsOnDuplicate(): void { [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default'); // First import: succeeds $first = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); // Second import with no flags: should fail on duplicate ids $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('failed', $migration['body']['status']); $this->assertNotEmpty($migration['body']['errors']); }, 60_000, 500); } private function performCsvMigration(array $body): array { return $this->client->call(Client::METHOD_POST, '/migrations/csv', [ 'content-type' => 'application/json', 'x-appwrite-key' => $this->getProject()['apiKey'], 'x-appwrite-project' => $this->getProject()['$id'], ], $body); } /** * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests. * Mirrors prepareCsvImportFixture but uploads documents.json instead. * * @return array{string,string,string,string,string,string,int} */ private function prepareJsonImportFixture(string $testLabel): array { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // database $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ 'databaseId' => ID::unique(), 'name' => 'Test JSON DB ' . $testLabel, ]); $this->assertEquals(201, $response['headers']['status-code']); $databaseId = $response['body']['$id']; // table $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ 'name' => 'Test JSON table ' . $testLabel, 'tableId' => ID::unique(), ]); $this->assertEquals(201, $response['headers']['status-code']); $tableId = $response['body']['$id']; // columns: name, age (match documents.json fixture) $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ 'key' => 'age', 'min' => 18, 'max' => 65, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); foreach (['name', 'age'] as $column) { $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('available', $response['body']['status']); }, 5000, 500); } // bucket $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ 'bucketId' => ID::unique(), 'name' => 'JSON Bucket ' . $testLabel, 'maximumFileSize' => 2000000, 'allowedFileExtensions' => ['json'], ]); $this->assertEquals(201, $response['headers']['status-code']); $bucketId = $response['body']['$id']; // upload documents.json (same row shape as documents.csv) $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'), ]); $this->assertEquals(201, $response['headers']['status-code']); $fileId = $response['body']['$id']; // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56 return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; } /** * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged. */ public function testCreateJSONImportSkipDuplicates(): void { [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip'); $first = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // Mutate one row so we can prove skip does NOT overwrite it $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'data' => ['age' => 22], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); $second = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, 'onDuplicate' => 'skip', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [Query::limit(150)->toString()], ]); $this->assertEquals(100, $rows['body']['total']); } /** * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values. */ public function testCreateJSONImportOverwrite(): void { [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite'); $first = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'data' => ['age' => 22], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); $second = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, 'onDuplicate' => 'overwrite', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [Query::limit(150)->toString()], ]); $this->assertEquals(100, $rows['body']['total']); } /** * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids. */ public function testCreateJSONImportDefaultFailsOnDuplicate(): void { [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default'); $first = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($first) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); $second = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('failed', $migration['body']['status']); $this->assertNotEmpty($migration['body']['errors']); }, 60_000, 500); } /** * Test CSV export with email notification */ public function testCreateCSVExport(): void { // Create a database $database = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'databaseId' => ID::unique(), 'name' => 'Test Export Database' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create a collection $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'collectionId' => ID::unique(), 'name' => 'Test Export Collection', 'permissions' => [] ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Create a simple attribute like the basic test $name = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'name', 'size' => 255, 'required' => true, ]); $this->assertEquals(202, $name['headers']['status-code']); // Create a simple attribute like the basic test $email = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'email', 'size' => 255, 'required' => false, ]); $this->assertEquals(202, $email['headers']['status-code']); $text = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/text', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'regulartext', 'required' => false, ]); $this->assertEquals(202, $text['headers']['status-code']); $varchar = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/varchar', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'varchar', 'size' => 1000, 'required' => false, ]); $this->assertEquals(202, $varchar['headers']['status-code']); $bigint = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/bigint', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'bigint', 'min' => 2147483648, 'max' => 9223372036854775807, 'required' => false, ]); $this->assertEquals(202, $bigint['headers']['status-code']); $mediumtext = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/mediumtext', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'mediumtext', 'required' => false, ]); $this->assertEquals(202, $mediumtext['headers']['status-code']); $longtext = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/longtext', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'longtext', 'required' => false, ]); $this->assertEquals(202, $longtext['headers']['status-code']); $this->assertEventually(function () use ($databaseId, $collectionId) { $collection = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $collection['headers']['status-code']); $this->assertNotEmpty($collection['body']['attributes']); foreach ($collection['body']['attributes'] as $attr) { $this->assertEquals('available', $attr['status'], "Attribute '{$attr['key']}' is not available yet"); } }, 30_000, 500); // Create sample documents for ($i = 1; $i <= 10; $i++) { $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'documentId' => ID::unique(), 'data' => [ 'name' => 'Test User ' . $i, 'email' => 'user' . $i . '@appwrite.io', 'regulartext' => 'regularText', 'mediumtext' => 'mediumText', 'longtext' => 'longText', 'varchar' => 'varchar', 'bigint' => 2147483648 + $i, ] ]); $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); } // Verify documents were created $docs = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]); $this->assertEquals(200, $docs['headers']['status-code']); $this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']); // Perform CSV export with notification enabled (uses internal bucket) $migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders()), [ 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => 'test-export', 'columns' => [], 'delimiter' => ',', 'enclosure' => '"', 'escape' => '\\', 'header' => true, 'notify' => true ]); $this->assertEquals(202, $migration['headers']['status-code']); $this->assertNotEmpty($migration['body']['$id']); $migrationId = $migration['body']['$id']; $this->assertEventually(function () use ($migrationId) { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('finished', $response['body']['stage']); $this->assertEquals('completed', $response['body']['status']); $this->assertEquals('Appwrite', $response['body']['source']); $this->assertEquals('CSV', $response['body']['destination']); return true; }, 30_000, 500); // Check that email was sent with download link $lastEmail = $this->getLastEmail(probe: function ($email) { $this->assertEquals('Your CSV export is ready', $email['subject']); }); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); // Extract download URL from email HTML \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches); $this->assertNotEmpty($matches[1], 'Download URL not found in email'); $downloadUrl = html_entity_decode($matches[1]); // Parse the URL to extract components $components = \parse_url($downloadUrl); $this->assertNotEmpty($components); \parse_str($components['query'] ?? '', $queryParams); $this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL'); $this->assertNotEmpty($queryParams['jwt']); $this->assertArrayHasKey('project', $queryParams, 'Project not found in download URL'); $this->assertStringContainsString('/storage/buckets/default/files/', $downloadUrl); // Test download with JWT $path = \str_replace('/v1', '', $components['path']); $downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); $this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT'); // Verify the downloaded content is valid CSV $csvData = $downloadWithJwt['body']; $this->assertNotEmpty($csvData, 'CSV export should not be empty'); $this->assertStringContainsString('name', $csvData, 'CSV should contain the name column header'); $this->assertStringContainsString('email', $csvData, 'CSV should contain the email column header'); $this->assertStringContainsString('Test User 1', $csvData, 'CSV should contain test data'); $this->assertStringContainsString('regularText', $csvData, 'CSV should contain the text column header'); $this->assertStringContainsString('mediumText', $csvData, 'CSV should contain the medium column header'); $this->assertStringContainsString('longText', $csvData, 'CSV should contain the long text column header'); $this->assertStringContainsString('varchar', $csvData, 'CSV should contain the varchar column header'); $this->assertStringContainsString('bigint', $csvData, 'CSV should contain the bigint column header'); $this->assertStringContainsString('2147483649', $csvData, 'CSV should contain bigint test data'); // Cleanup $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]); } /** * Messaging */ public function testAppwriteMigrationMessagingProvider(): void { $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Sendgrid', 'apiKey' => 'my-apikey', 'from' => 'migration@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $this->assertNotEmpty($provider['body']['$id']); $providerId = $provider['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_PROVIDER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_PROVIDER], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['warning']); $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($providerId, $response['body']['$id']); $this->assertEquals('Migration Sendgrid', $response['body']['name']); $this->assertEquals('email', $response['body']['type']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingProviderSMTP(): void { $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/smtp', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration SMTP', 'host' => 'smtp.test.com', 'port' => 587, 'from' => 'migration-smtp@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_PROVIDER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($providerId, $response['body']['$id']); $this->assertEquals('Migration SMTP', $response['body']['name']); $this->assertEquals('email', $response['body']['type']); $this->assertEquals('smtp', $response['body']['provider']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingProviderTwilio(): void { $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Twilio', 'from' => '+15551234567', 'accountSid' => 'test-account-sid', 'authToken' => 'test-auth-token', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_PROVIDER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_PROVIDER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_PROVIDER]['error']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_PROVIDER]['success']); $response = $this->client->call(Client::METHOD_GET, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($providerId, $response['body']['$id']); $this->assertEquals('Migration Twilio', $response['body']['name']); $this->assertEquals('sms', $response['body']['type']); $this->assertEquals('twilio', $response['body']['provider']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingTopic(): void { $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Sendgrid Topic', 'apiKey' => 'my-apikey', 'from' => 'migration-topic@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'topicId' => ID::unique(), 'name' => 'Migration Topic', ]); $this->assertEquals(201, $topic['headers']['status-code']); $this->assertNotEmpty($topic['body']['$id']); $topicId = $topic['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_TOPIC, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_TOPIC]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TOPIC]['warning']); $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($topicId, $response['body']['$id']); $this->assertEquals('Migration Topic', $response['body']['name']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingSubscriber(): void { $user = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => uniqid() . '-migration-sub@test.com', 'password' => 'password', ]); $this->assertEquals(201, $user['headers']['status-code']); $userId = $user['body']['$id']; $this->assertEquals(1, \count($user['body']['targets'])); $targetId = $user['body']['targets'][0]['$id']; $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Sendgrid Subscriber', 'apiKey' => 'my-apikey', 'from' => uniqid() . '-migration-sub@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'topicId' => ID::unique(), 'name' => 'Migration Subscriber Topic', ]); $this->assertEquals(201, $topic['headers']['status-code']); $topicId = $topic['body']['$id']; $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'subscriberId' => ID::unique(), 'targetId' => $targetId, ]); $this->assertEquals(201, $subscriber['headers']['status-code']); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_SUBSCRIBER, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_SUBSCRIBER]['warning']); $response = $this->client->call(Client::METHOD_GET, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($topicId, $response['body']['$id']); $this->assertGreaterThanOrEqual(1, $response['body']['emailTotal']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingMessage(): void { $this->getDestinationProject(true); $user = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => uniqid() . '-migration-msg@test.com', 'password' => 'password', ]); $this->assertEquals(201, $user['headers']['status-code']); $userId = $user['body']['$id']; $this->assertEquals(1, \count($user['body']['targets'])); $targetId = $user['body']['targets'][0]['$id']; $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Sendgrid Message', 'apiKey' => 'my-apikey', 'from' => 'migration-msg@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'topicId' => ID::unique(), 'name' => 'Migration Message Topic', ]); $this->assertEquals(201, $topic['headers']['status-code']); $topicId = $topic['body']['$id']; $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'messageId' => ID::unique(), 'targets' => [$targetId], 'topics' => [$topicId], 'subject' => 'Migration Test Email', 'content' => 'This is a migration test email', 'draft' => true, ]); $this->assertEquals(201, $message['headers']['status-code']); $this->assertNotEmpty($message['body']['$id']); $messageId = $message['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, Resource::TYPE_MESSAGE, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['warning']); $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($messageId, $response['body']['$id']); $this->assertEquals('draft', $response['body']['status']); $this->assertEquals('Migration Test Email', $response['body']['data']['subject']); $this->assertEquals('This is a migration test email', $response['body']['data']['content']); $this->assertContains($topicId, $response['body']['topics']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingSmsMessage(): void { $this->getDestinationProject(true); $user = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => uniqid() . '-migration-sms@test.com', 'phone' => '+1' . str_pad((string) rand(200000000, 999999999), 10, '0', STR_PAD_LEFT), 'password' => 'password', ]); $this->assertEquals(201, $user['headers']['status-code']); $userId = $user['body']['$id']; $this->assertGreaterThanOrEqual(1, \count($user['body']['targets'])); $smsTarget = null; foreach ($user['body']['targets'] as $target) { if ($target['providerType'] === 'sms') { $smsTarget = $target; break; } } $this->assertNotNull($smsTarget); $targetId = $smsTarget['$id']; $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/twilio', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Twilio SMS Msg', 'from' => '+15559876543', 'accountSid' => 'test-account-sid', 'authToken' => 'test-auth-token', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'topicId' => ID::unique(), 'name' => 'Migration SMS Topic', ]); $this->assertEquals(201, $topic['headers']['status-code']); $topicId = $topic['body']['$id']; $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/sms', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'messageId' => ID::unique(), 'targets' => [$targetId], 'topics' => [$topicId], 'content' => 'Migration SMS test content', 'draft' => true, ]); $this->assertEquals(201, $message['headers']['status-code']); $messageId = $message['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, Resource::TYPE_MESSAGE, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($messageId, $response['body']['$id']); $this->assertEquals('draft', $response['body']['status']); $this->assertEquals('Migration SMS test content', $response['body']['data']['content']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } public function testAppwriteMigrationMessagingScheduledMessage(): void { $this->getDestinationProject(true); $user = $this->client->call(Client::METHOD_POST, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'userId' => ID::unique(), 'email' => uniqid() . '-migration-sched@test.com', 'password' => 'password', ]); $this->assertEquals(201, $user['headers']['status-code']); $userId = $user['body']['$id']; $targetId = $user['body']['targets'][0]['$id']; $provider = $this->client->call(Client::METHOD_POST, '/messaging/providers/sendgrid', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'providerId' => ID::unique(), 'name' => 'Migration Sendgrid Scheduled', 'apiKey' => 'my-apikey', 'from' => 'migration-sched@test.com', ]); $this->assertEquals(201, $provider['headers']['status-code']); $providerId = $provider['body']['$id']; $topic = $this->client->call(Client::METHOD_POST, '/messaging/topics', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'topicId' => ID::unique(), 'name' => 'Migration Scheduled Topic', ]); $this->assertEquals(201, $topic['headers']['status-code']); $topicId = $topic['body']['$id']; $subscriber = $this->client->call(Client::METHOD_POST, '/messaging/topics/' . $topicId . '/subscribers', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'subscriberId' => ID::unique(), 'targetId' => $targetId, ]); $this->assertEquals(201, $subscriber['headers']['status-code']); // Create a scheduled message with a future date using topics only // Direct targets use source IDs which won't resolve in the destination via API $futureDate = (new \DateTime('+1 year'))->format(\DateTime::ATOM); $message = $this->client->call(Client::METHOD_POST, '/messaging/messages/email', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'messageId' => ID::unique(), 'topics' => [$topicId], 'subject' => 'Migration Scheduled Email', 'content' => 'This is a scheduled migration test email', 'scheduledAt' => $futureDate, ]); $this->assertEquals(201, $message['headers']['status-code']); $messageId = $message['body']['$id']; $this->assertEquals('scheduled', $message['body']['status']); $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_USER, Resource::TYPE_PROVIDER, Resource::TYPE_TOPIC, Resource::TYPE_SUBSCRIBER, Resource::TYPE_MESSAGE, ], 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertArrayHasKey(Resource::TYPE_MESSAGE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_MESSAGE]['error']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_MESSAGE]['success']); $response = $this->client->call(Client::METHOD_GET, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($messageId, $response['body']['$id']); $this->assertEquals('scheduled', $response['body']['status']); $this->assertEquals('Migration Scheduled Email', $response['body']['data']['subject']); $this->assertEquals( (new \DateTime($futureDate))->getTimestamp(), (new \DateTime($response['body']['scheduledAt']))->getTimestamp(), ); // Cleanup $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/messages/' . $messageId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/topics/' . $topicId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/messaging/providers/' . $providerId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } /** * Import VectorsDB documents from CSV */ public function testImportVectordbCSV(): void { $databaseId = null; $collectionId = null; $bucketId = null; try { $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'databaseId' => ID::unique(), 'name' => 'Vector CSV Import DB' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'collectionId' => ID::unique(), 'name' => 'Vector CSV Import Collection', 'dimension' => 3, 'documentSecurity' => true, ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'bucketId' => ID::unique(), 'name' => 'Vector CSV Bucket', 'maximumFileSize' => 2000000, 'allowedFileExtensions' => ['csv'], ]); $this->assertEquals(201, $bucket['headers']['status-code']); $bucketId = $bucket['body']['$id']; $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/vectorsdb-documents.csv'), 'text/csv', 'vectorsdb-documents.csv'), ]); $this->assertEquals(201, $file['headers']['status-code']); $fileId = $file['body']['$id']; $migration = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $collectionId, ]); $this->assertEquals(202, $migration['headers']['status-code']); $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $status = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $status['headers']['status-code']); $this->assertEquals('finished', $status['body']['stage']); $this->assertEquals('completed', $status['body']['status']); $this->assertContains(Resource::TYPE_DOCUMENT, $status['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $status['body']['statusCounters']); $this->assertEquals(2, $status['body']['statusCounters'][Resource::TYPE_DOCUMENT]['success']); return true; }, 60_000, 500); $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'queries' => [ Query::limit(10)->toString(), ], ]); $this->assertEquals(200, $documents['headers']['status-code']); $this->assertEquals(2, $documents['body']['total']); $titles = array_map(fn ($doc) => $doc['metadata']['title'] ?? null, $documents['body']['documents']); $this->assertContains('Vector Alpha', $titles); $this->assertContains('Vector Beta', $titles); } finally { if ($bucketId) { $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } if ($databaseId) { $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } } } /** * Export VectorsDB documents to CSV */ #[Retry(count: 1)] public function testExportVectordbCSV(): void { $databaseId = null; try { $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Vector CSV Export DB', ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; $collectionId = null; $this->assertEventually(function () use ($databaseId, &$collectionId) { $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'collectionId' => ID::unique(), 'name' => 'Vector CSV Export Collection', 'dimension' => 3, 'documentSecurity' => true, ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; }); $documentsPayload = [ [ 'documentId' => ID::unique(), 'data' => [ 'embeddings' => [0.11, 0.22, 0.33], 'metadata' => ['title' => 'Vector Sample One', 'category' => 'alpha'], ], ], [ 'documentId' => ID::unique(), 'data' => [ 'embeddings' => [0.44, 0.55, 0.66], 'metadata' => ['title' => 'Vector Sample Two', 'category' => 'beta'], ], ], ]; foreach ($documentsPayload as $payload) { $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], $payload); $this->assertEquals(201, $response['headers']['status-code']); } $filename = 'vectorsdb-export-' . ID::unique(); $migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => $filename, 'columns' => [], 'queries' => [], 'delimiter' => ',', 'enclosure' => '"', 'escape' => '\\', 'header' => true, 'notify' => true, ]); $this->assertEquals(202, $migration['headers']['status-code']); $migrationId = $migration['body']['$id']; $this->assertEventually(function () use ($migrationId) { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('finished', $response['body']['stage']); $this->assertEquals('completed', $response['body']['status']); return true; }, 30_000, 500); $this->assertEventually(function () { $email = $this->getLastEmail(1, function (array $email) { $this->assertEquals('Your CSV export is ready', $email['subject']); }); $this->assertNotEmpty($email); $this->assertEquals('Your CSV export is ready', $email['subject']); \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $email['html'], $matches); $this->assertNotEmpty($matches[1], 'Download URL not found in email'); $downloadUrl = html_entity_decode($matches[1]); $components = \parse_url($downloadUrl); $this->assertNotEmpty($components); \parse_str($components['query'] ?? '', $queryParams); $this->assertArrayHasKey('jwt', $queryParams); $this->assertArrayHasKey('project', $queryParams); $path = \str_replace('/v1', '', $components['path']); $downloadResponse = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); $this->assertEquals(200, $downloadResponse['headers']['status-code']); $csvData = $downloadResponse['body']; $this->assertStringContainsString('Vector Sample One', $csvData); $this->assertStringContainsString('Vector Sample Two', $csvData); $this->assertStringContainsString('[0.11,0.22,0.33]', $csvData); }, 30_000, 500); } finally { if ($databaseId) { $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } } } /** * DocumentsDB (schemaless) */ public function testAppwriteMigrationDocumentsDBDatabase(): array { $response = $this->client->call(Client::METHOD_POST, '/documentsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'DocsDB - Migration DB' ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $databaseId = $response['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_DOCUMENTSDB, ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_DATABASE_DOCUMENTSDB], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($databaseId, $response['body']['$id']); $this->assertEquals('DocsDB - Migration DB', $response['body']['name']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); return [ 'databaseId' => $databaseId, ]; } /** * VectorsDB (embeddings collections) */ public function testAppwriteMigrationVectorsDBDatabase(): array { $response = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'VDB - Migration DB' ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $databaseId = $response['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_VECTORSDB, ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $this->assertEquals([Resource::TYPE_DATABASE_VECTORSDB], $result['resources']); $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error'] ?? 0); $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($databaseId, $response['body']['$id']); $this->assertEquals('VDB - Migration DB', $response['body']['name']); // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); return [ 'databaseId' => $databaseId, ]; } #[Depends('testAppwriteMigrationVectorsDBDatabase')] public function testAppwriteMigrationVectorsDBCollection(array $data): array { $databaseId = $data['databaseId']; $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'collectionId' => ID::unique(), 'name' => 'VDB - Movies', 'dimension' => 3, ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_VECTORSDB, Resource::TYPE_COLLECTION, Resource::TYPE_ATTRIBUTE, ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($collectionId, $response['body']['$id']); $this->assertEquals('VDB - Movies', $response['body']['name']); // Verify attributes are present (embeddings and metadata are default attributes) $this->assertArrayHasKey('attributes', $response['body']); $this->assertIsArray($response['body']['attributes']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, ]; } #[Depends('testAppwriteMigrationVectorsDBCollection')] public function testAppwriteMigrationVectorsDBDocument(array $data): void { $databaseId = $data['databaseId']; $collectionId = $data['collectionId']; $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'documentId' => ID::unique(), 'data' => [ 'embeddings' => [1.0, 0.0, 0.0], 'metadata' => ['title' => 'Migration Test Movie'], ] ]); $this->assertEquals(201, $document['headers']['status-code']); $documentId = $document['body']['$id']; // Ensure attributes are exported before documents $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_VECTORSDB, Resource::TYPE_COLLECTION, Resource::TYPE_ATTRIBUTE, Resource::TYPE_DOCUMENT, ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); // Verify that TYPE_ATTRIBUTE appears in the resources array for VectorsDB $this->assertContains(Resource::TYPE_ATTRIBUTE, $result['resources'], 'TYPE_ATTRIBUTE should be in resources array for VectorsDB'); // Verify attributes exist on destination before checking document $collectionResponse = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $collectionResponse['headers']['status-code']); $this->assertArrayHasKey('attributes', $collectionResponse['body']); $this->assertIsArray($collectionResponse['body']['attributes']); $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($documentId, $response['body']['$id']); $this->assertEquals('Migration Test Movie', $response['body']['metadata']['title']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } #[Depends('testAppwriteMigrationDocumentsDBDatabase')] public function testAppwriteMigrationDocumentsDBCollection(array $data): array { $databaseId = $data['databaseId']; $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'collectionId' => ID::unique(), 'name' => 'DocsDB - Movies', ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION, // collections in DocumentsDB map to tables in migration ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION] as $resource) { $this->assertArrayHasKey($resource, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][$resource]['error']); $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); $this->assertEquals(1, $result['statusCounters'][$resource]['success']); $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); } $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($collectionId, $response['body']['$id']); $this->assertEquals('DocsDB - Movies', $response['body']['name']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, ]; } #[Depends('testAppwriteMigrationDocumentsDBCollection')] public function testAppwriteMigrationDocumentsDBDocument(array $data): void { $databaseId = $data['databaseId']; $collectionId = $data['collectionId']; $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'documentId' => ID::unique(), 'data' => [ 'title' => 'Migration Test Movie', 'releaseYear' => 1999, ] ]); $this->assertEquals(201, $document['headers']['status-code']); $documentId = $document['body']['$id']; $result = $this->performMigrationSync([ 'resources' => [ Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION, Resource::TYPE_DOCUMENT, ], 'endpoint' => $this->endpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], ]); $this->assertEquals('completed', $result['status']); foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB] as $resource) { $this->assertArrayHasKey($resource, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][$resource]['error']); $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); $this->assertEquals(1, $result['statusCounters'][$resource]['success']); } $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertEquals($documentId, $response['body']['$id']); $this->assertEquals('Migration Test Movie', $response['body']['title']); $this->assertEquals(1999, $response['body']['releaseYear']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); } /** * Migrate a project that contains both SQL Databases (/databases) and * schemaless DocumentsDB (/documentsdb) in a single run and verify results. * Uses a dedicated isolated source project to avoid interference from other tests. */ public function testAppwriteMigrationMixedDatabases(): void { // Create a fresh isolated source project for this test $sourceProject = $this->getProject(true); // ====== Create SQL Database (/databases) with table, column, and row ====== $sql = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Mixed SQL DB', ]); $this->assertEquals(201, $sql['headers']['status-code']); $this->assertNotEmpty($sql['body']['$id']); $sqlDatabaseId = $sql['body']['$id']; // Create Table in SQL Database $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'tableId' => ID::unique(), 'name' => 'Products', ]); $this->assertEquals(201, $table['headers']['status-code']); $tableId = $table['body']['$id']; // Create Column in Table $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'key' => 'productName', 'size' => 255, 'required' => true, ]); $this->assertEquals(202, $column['headers']['status-code']); // Wait for column to be ready $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sourceProject) { $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('available', $response['body']['status']); }, 5000, 500); $sqlIndexKey = 'product_unique'; $sqlIndex = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'key' => $sqlIndexKey, 'type' => Database::INDEX_UNIQUE, 'columns' => ['productName'], ]); $this->assertEquals(202, $sqlIndex['headers']['status-code']); $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sqlIndexKey, $sourceProject) { $index = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $index['headers']['status-code']); $this->assertEquals('available', $index['body']['status']); }, 30000, 500); // Create Row in Table $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'rowId' => ID::unique(), 'data' => [ 'productName' => 'Laptop', ], ]); $this->assertEquals(201, $row['headers']['status-code']); $rowId = $row['body']['$id']; // ====== Create DocumentsDB (/documentsdb) with collection and document ====== $docs = $this->client->call(Client::METHOD_POST, '/documentsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Mixed DocsDB', ]); $this->assertEquals(201, $docs['headers']['status-code']); $this->assertNotEmpty($docs['body']['$id']); $docsDatabaseId = $docs['body']['$id']; // Create Collection in DocumentsDB $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'collectionId' => ID::unique(), 'name' => 'Users', ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; $documentsIndexKey = 'email_unique'; $documentsIndex = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'key' => $documentsIndexKey, 'type' => Database::INDEX_UNIQUE, 'attributes' => ['email'], ]); $this->assertEquals(202, $documentsIndex['headers']['status-code']); $this->assertEventually(function () use ($docsDatabaseId, $collectionId, $documentsIndexKey, $sourceProject) { $index = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $index['headers']['status-code']); $this->assertEquals('available', $index['body']['status']); }, 30000, 500); // Create Document in Collection $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'documentId' => ID::unique(), 'data' => [ 'name' => 'John Doe', 'email' => 'john@example.com', ], ]); $this->assertEquals(201, $document['headers']['status-code']); $documentId = $document['body']['$id']; // ====== Create VectorsDB (/vectorsdb) with collection and document ====== $vector = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'databaseId' => ID::unique(), 'name' => 'Mixed VectorsDB', ]); $this->assertEquals(201, $vector['headers']['status-code']); $this->assertNotEmpty($vector['body']['$id']); $vectorDatabaseId = $vector['body']['$id']; // Create Collection in VectorsDB $vectorCollection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'collectionId' => ID::unique(), 'name' => 'Products', 'dimension' => 3, ]); $this->assertEquals(201, $vectorCollection['headers']['status-code']); $vectorCollectionId = $vectorCollection['body']['$id']; // Wait for VectorsDB collection attributes to be ready $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $sourceProject) { $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertArrayHasKey('attributes', $response['body']); $this->assertIsArray($response['body']['attributes']); // Check that default attributes (embeddings and metadata) are present and ready $attributeKeys = array_column($response['body']['attributes'], 'key'); $this->assertContains('embeddings', $attributeKeys); $this->assertContains('metadata', $attributeKeys); // Check that attributes are available (if status field exists) foreach ($response['body']['attributes'] as $attribute) { if (isset($attribute['status']) && $attribute['status'] !== 'available') { return false; } } return true; }, 10000, 500); $metadataIndexKey = '_key_metadata'; $vectorIndexes = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $vectorIndexes['headers']['status-code']); $metadataIndex = null; foreach ($vectorIndexes['body']['indexes'] ?? [] as $index) { if (($index['key'] ?? '') === $metadataIndexKey) { $metadataIndex = $index; break; } } $this->assertNotNull($metadataIndex, 'Default metadata index should exist on source collection'); $this->assertEquals(Database::INDEX_OBJECT, $metadataIndex['type']); $vectorEmbeddingIndexKey = 'embedding_euclidean'; $vectorEmbeddingIndex = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'key' => $vectorEmbeddingIndexKey, 'type' => Database::INDEX_HNSW_EUCLIDEAN, 'attributes' => ['embeddings'], ]); $this->assertEquals(202, $vectorEmbeddingIndex['headers']['status-code']); $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $vectorEmbeddingIndexKey, $sourceProject) { $index = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes/' . $vectorEmbeddingIndexKey, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->assertEquals(200, $index['headers']['status-code']); $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $index['body']['type']); if (isset($index['body']['status'])) { $this->assertEquals('available', $index['body']['status']); } }, 30000, 500); // Create Document in VectorsDB Collection $vectorDocument = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ], [ 'documentId' => ID::unique(), 'data' => [ 'embeddings' => [0.5, 0.3, 0.2], 'metadata' => ['name' => 'Product Vector'], ], ]); $this->assertEquals(201, $vectorDocument['headers']['status-code']); $vectorDocumentId = $vectorDocument['body']['$id']; // ====== Perform migration including all three database kinds with all child resources ====== $migrationConfig = [ 'resources' => [ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION, Resource::TYPE_DOCUMENT, Resource::TYPE_DATABASE_VECTORSDB, Resource::TYPE_ATTRIBUTE, Resource::TYPE_INDEX, ], 'endpoint' => $this->endpoint, 'projectId' => $sourceProject['$id'], 'apiKey' => $sourceProject['apiKey'], ]; // Perform migration sync once and get migration ID $result = $this->performMigrationSync($migrationConfig); $migrationId = $result['$id']; $this->assertEquals('completed', $result['status']); $this->assertEquals('Appwrite', $result['source']); $this->assertEquals('Appwrite', $result['destination']); $this->assertEquals([ Resource::TYPE_DATABASE, Resource::TYPE_TABLE, Resource::TYPE_COLUMN, Resource::TYPE_ROW, Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION, Resource::TYPE_DOCUMENT, Resource::TYPE_DATABASE_VECTORSDB, Resource::TYPE_ATTRIBUTE, Resource::TYPE_INDEX, ], $result['resources']); // Get migration status before asserting SQL Database counters $result = $this->getMigrationStatus($migrationId); // Assert SQL Database counters $this->assertArrayHasKey(Resource::TYPE_DATABASE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['warning']); // Get migration status before asserting Table counters $result = $this->getMigrationStatus($migrationId); // Assert Table counters $this->assertArrayHasKey(Resource::TYPE_TABLE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_TABLE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['warning']); // Get migration status before asserting Column counters $result = $this->getMigrationStatus($migrationId); // Assert Column counters $this->assertArrayHasKey(Resource::TYPE_COLUMN, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_COLUMN]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['warning']); // Get migration status before asserting Row counters $result = $this->getMigrationStatus($migrationId); // Assert Row counters $this->assertArrayHasKey(Resource::TYPE_ROW, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_ROW]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['warning']); // Get migration status before asserting DocumentsDB counters $result = $this->getMigrationStatus($migrationId); // Assert DocumentsDB counters $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); // Wait for all collections to be fully processed and status counters to be updated // Note: Collections are being transferred but status counters may not be updated immediately // This wait ensures the migration worker has finished processing all collections $result = null; $this->assertEventually(function () use ($migrationId, &$result) { $result = $this->getMigrationStatus($migrationId); // Check if collections status counters exist if (!isset($result['statusCounters'][Resource::TYPE_COLLECTION])) { return false; } $pendingCount = $result['statusCounters'][Resource::TYPE_COLLECTION]['pending'] ?? 0; // Return true only when pending count is 0 return $pendingCount === 0; }, 30000, 1000); // 30 second timeout, check every 1 second // Assert Collection counters (covers both DocumentsDB and VectorsDB collections) $this->assertArrayHasKey(Resource::TYPE_COLLECTION, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_COLLECTION]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['warning']); // Get migration status before asserting Document counters $result = $this->getMigrationStatus($migrationId); // Assert Document counters (covers both DocumentsDB and VectorsDB documents) $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_DOCUMENT]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['warning']); // Get migration status before asserting VectorsDB counters $result = $this->getMigrationStatus($migrationId); // Assert VectorsDB counters $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['pending']); $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['warning']); // Get migration status before asserting Attribute counters $result = $this->getMigrationStatus($migrationId); // Assert Attribute counters (for VectorsDB) $this->assertArrayHasKey(Resource::TYPE_ATTRIBUTE, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['pending']); $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['warning']); // Get migration status before asserting Index counters $result = $this->getMigrationStatus($migrationId); $this->assertArrayHasKey(Resource::TYPE_INDEX, $result['statusCounters']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['error']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['pending']); $this->assertGreaterThanOrEqual(4, $result['statusCounters'][Resource::TYPE_INDEX]['success']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['processing']); $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['warning']); // Get migration status before asserting counter count $result = $this->getMigrationStatus($migrationId); // Ensure only expected counters exist (10 total) $this->assertCount(10, $result['statusCounters']); // ====== Validate on destination: SQL Database resources ====== $response = $this->client->call(Client::METHOD_GET, '/databases/' . $sqlDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($sqlDatabaseId, $response['body']['$id']); $this->assertEquals('Mixed SQL DB', $response['body']['name']); // Validate Table $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($tableId, $response['body']['$id']); $this->assertEquals('Products', $response['body']['name']); // Validate Column $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('productName', $response['body']['key']); $this->assertEquals(255, $response['body']['size']); $this->assertEquals(true, $response['body']['required']); // Validate Row $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($rowId, $response['body']['$id']); $this->assertEquals('Laptop', $response['body']['productName']); $sqlIndexDestination = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $sqlIndexDestination['headers']['status-code']); $this->assertEquals($sqlIndexKey, $sqlIndexDestination['body']['key']); $this->assertEquals(Database::INDEX_UNIQUE, $sqlIndexDestination['body']['type']); if (isset($sqlIndexDestination['body']['columns'])) { $this->assertEquals(['productName'], $sqlIndexDestination['body']['columns']); } // ====== Validate on destination: DocumentsDB resources ====== $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($docsDatabaseId, $response['body']['$id']); $this->assertEquals('Mixed DocsDB', $response['body']['name']); // Validate Collection $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($collectionId, $response['body']['$id']); $this->assertEquals('Users', $response['body']['name']); // Validate Document $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($documentId, $response['body']['$id']); $this->assertEquals('John Doe', $response['body']['name']); $this->assertEquals('john@example.com', $response['body']['email']); $documentsIndexDestination = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $documentsIndexDestination['headers']['status-code']); $this->assertEquals($documentsIndexKey, $documentsIndexDestination['body']['key']); $this->assertEquals(Database::INDEX_UNIQUE, $documentsIndexDestination['body']['type']); if (isset($documentsIndexDestination['body']['attributes'])) { $this->assertEquals(['email'], $documentsIndexDestination['body']['attributes']); } // ====== Validate on destination: VectorsDB resources ====== $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($vectorDatabaseId, $response['body']['$id']); $this->assertEquals('Mixed VectorsDB', $response['body']['name']); // Validate VectorsDB Collection $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($vectorCollectionId, $response['body']['$id']); $this->assertEquals('Products', $response['body']['name']); // Verify attributes are present (embeddings and metadata are default attributes) $this->assertArrayHasKey('attributes', $response['body']); $this->assertIsArray($response['body']['attributes']); $vectorIndexesDestination = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $vectorIndexesDestination['headers']['status-code']); $indexByKey = []; foreach ($vectorIndexesDestination['body']['indexes'] ?? [] as $index) { if (isset($index['key'])) { $indexByKey[$index['key']] = $index; } } $this->assertArrayHasKey($metadataIndexKey, $indexByKey, 'Metadata index should exist on destination'); $this->assertEquals(Database::INDEX_OBJECT, $indexByKey[$metadataIndexKey]['type']); $this->assertArrayHasKey($vectorEmbeddingIndexKey, $indexByKey, 'Embeddings HNSW index should exist on destination'); $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $indexByKey[$vectorEmbeddingIndexKey]['type']); // Validate VectorsDB Document $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents/' . $vectorDocumentId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($vectorDocumentId, $response['body']['$id']); $this->assertEquals('Product Vector', $response['body']['metadata']['name']); // ====== Cleanup all destinations ====== $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); // ====== Cleanup sources ====== $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $sourceProject['$id'], 'x-appwrite-key' => $sourceProject['apiKey'], ]); } public function testCreateJSONImport(): void { // Make a database $response = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'databaseId' => ID::unique(), 'name' => 'Test Database' ]); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals('Test Database', $response['body']['name']); $databaseId = $response['body']['$id']; // make a table $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'name' => 'Test table', 'tableId' => ID::unique(), ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals($response['body']['name'], 'Test table'); $tableId = $response['body']['$id']; // make columns $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'name', 'size' => 256, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $this->assertEquals($response['body']['key'], 'name'); $this->assertEquals($response['body']['type'], 'string'); $this->assertEquals($response['body']['size'], 256); $this->assertEquals($response['body']['required'], true); $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ 'key' => 'age', 'min' => 18, 'max' => 65, 'required' => true, ]); $this->assertEquals(202, $response['headers']['status-code']); $this->assertEquals($response['body']['key'], 'age'); $this->assertEquals($response['body']['type'], 'integer'); $this->assertEquals($response['body']['min'], 18); $this->assertEquals($response['body']['max'], 65); $this->assertEquals($response['body']['required'], true); // make a bucket, upload a file to it! $bucketOne = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'bucketId' => ID::unique(), 'name' => 'Test Bucket', 'maximumFileSize' => 2000000, //2MB 'allowedFileExtensions' => ['json'], 'compression' => 'gzip', 'encryption' => true ]); $this->assertEquals(201, $bucketOne['headers']['status-code']); $this->assertNotEmpty($bucketOne['body']['$id']); $bucketOneId = $bucketOne['body']['$id']; $bucketIds = [ 'default' => $bucketOneId, 'missing-column' => $bucketOneId, 'irrelevant-column' => $bucketOneId, 'documents-internals' => $bucketOneId, ]; $fileIds = []; foreach ($bucketIds as $label => $bucketId) { $jsonFileName = match ($label) { 'missing-column', 'irrelevant-column', 'documents-internals' => "$label.json", default => 'documents.json', }; $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/'.$jsonFileName), 'application/json', $jsonFileName), ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals($jsonFileName, $response['body']['name']); $this->assertEquals('application/json', $response['body']['mimeType']); $fileIds[$label] = $response['body']['$id']; } // missing column, fail in worker. $missingColumn = $this->performJsonMigration( [ 'fileId' => $fileIds['missing-column'], 'bucketId' => $bucketIds['missing-column'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($missingColumn) { $migrationId = $missingColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('failed', $migration['body']['status']); $this->assertEquals('JSON', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); /* fails in batch create documents unlike csv which checks headers first! */ $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertGreaterThan(0, $migration['body']['statusCounters'][Resource::TYPE_ROW]['error']); $this->assertThat( implode("\n", $migration['body']['errors']), $this->stringContains('Missing required attribute') ); $this->assertThat( implode("\n", $migration['body']['errors']), $this->stringContains('age') ); }, 60_000, 500); // irrelevant column - email, success. $irrelevantColumn = $this->performJsonMigration( [ 'fileId' => $fileIds['irrelevant-column'], 'bucketId' => $bucketIds['irrelevant-column'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($irrelevantColumn) { $migrationId = $irrelevantColumn['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('JSON', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // all data exists, pass. $migration = $this->performJsonMigration( [ 'endpoint' => $this->endpoint, 'fileId' => $fileIds['default'], 'bucketId' => $bucketIds['default'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('JSON', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); // get rows count $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/rows', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'queries' => [ Query::limit(250)->toString() ] ]); $this->assertEquals(200, $rows['headers']['status-code']); $this->assertIsArray($rows['body']['rows']); $this->assertIsNumeric($rows['body']['total']); $this->assertEquals(200, $rows['body']['total']); // all data exists and includes internals, pass. $migration = $this->performJsonMigration( [ 'endpoint' => $this->endpoint, 'fileId' => $fileIds['documents-internals'], 'bucketId' => $bucketIds['documents-internals'], 'resourceId' => $databaseId . ':' . $tableId, ] ); $this->assertEventually(function () use ($migration) { $migrationId = $migration['body']['$id']; $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('JSON', $migration['body']['source']); $this->assertEquals('Appwrite', $migration['body']['destination']); $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); $this->assertEquals(25, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); } private function performJsonMigration(array $body): array { return $this->client->call(Client::METHOD_POST, '/migrations/json/imports', [ 'content-type' => 'application/json', 'x-appwrite-key' => $this->getProject()['apiKey'], 'x-appwrite-project' => $this->getProject()['$id'], ], $body); } /** * Test JSON export with email notification */ public function testCreateJSONExport(): void { // Create a database $database = $this->client->call(Client::METHOD_POST, '/databases', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'databaseId' => ID::unique(), 'name' => 'Test Export Database' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create a collection $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'collectionId' => ID::unique(), 'name' => 'Test Export Collection', 'permissions' => [] ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Create a simple attribute like the basic test $name = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'name', 'size' => 255, 'required' => true, ]); $this->assertEquals(202, $name['headers']['status-code']); // Create a simple attribute like the basic test $email = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'key' => 'email', 'size' => 255, 'required' => false, ]); $this->assertEquals(202, $email['headers']['status-code']); \sleep(3); // Create sample documents for ($i = 1; $i <= 10; $i++) { $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ], [ 'documentId' => ID::unique(), 'data' => [ 'name' => 'Test User ' . $i, 'email' => 'user' . $i . '@appwrite.io' ] ]); $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); } // Verify documents were created $docs = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]); $this->assertEquals(200, $docs['headers']['status-code']); $this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']); // Perform JSON export with notification enabled (uses internal bucket) $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders()), [ 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => 'test-json-export', 'columns' => [], 'queries' => [], 'notify' => true ]); $this->assertEquals(202, $migration['headers']['status-code']); $this->assertNotEmpty($migration['body']['$id']); $migrationId = $migration['body']['$id']; $this->assertEventually(function () use ($migrationId) { $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('finished', $response['body']['stage']); $this->assertEquals('completed', $response['body']['status']); $this->assertEquals('Appwrite', $response['body']['source']); $this->assertEquals('JSON', $response['body']['destination']); return true; }, 30_000, 500); // Check that email was sent with download link $lastEmail = $this->getLastEmail(probe: function ($email) { $this->assertEquals('Your JSON export is ready', $email['subject']); }); $this->assertNotEmpty($lastEmail); $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); // Extract download URL from email HTML \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches); $this->assertNotEmpty($matches[1], 'Download URL not found in email'); $downloadUrl = html_entity_decode($matches[1]); // Parse the URL to extract components $components = \parse_url($downloadUrl); $this->assertNotEmpty($components); \parse_str($components['query'] ?? '', $queryParams); $this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL'); $this->assertNotEmpty($queryParams['jwt']); $this->assertArrayHasKey('project', $queryParams, 'Project not found in download URL'); $this->assertStringContainsString('/storage/buckets/default/files/', $downloadUrl); // Test download with JWT $path = \str_replace('/v1', '', $components['path']); $downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); $this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT'); // Verify the downloaded content is valid JSON $jsonData = $downloadWithJwt['body']; $this->assertNotEmpty($jsonData, 'JSON export should not be empty'); $decoded = json_decode($jsonData, true); $this->assertIsArray($decoded, 'JSON should be valid and decodable'); $this->assertCount(10, $decoded, 'JSON should contain 10 documents'); $this->assertArrayHasKey('name', $decoded[0], 'JSON documents should contain name field'); $this->assertArrayHasKey('email', $decoded[0], 'JSON documents should contain email field'); $this->assertStringContainsString('Test User', $decoded[0]['name'], 'JSON should contain test data'); // Cleanup $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]); } public function testCreateVectorsDBJSONExport(): void { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // Create vectorsdb database $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ 'databaseId' => ID::unique(), 'name' => 'VectorsDB Export Test' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create collection with dimension 16 $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ 'collectionId' => ID::unique(), 'name' => 'VecExportCol', 'dimension' => 16, ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Seed 5 documents for ($i = 1; $i <= 5; $i++) { $embeddings = array_map(fn () => round((mt_rand() / mt_getrandmax()) * 2 - 1, 6), range(1, 16)); $doc = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ 'documentId' => ID::unique(), 'data' => [ 'embeddings' => $embeddings, 'metadata' => ['title' => 'Doc ' . $i, 'score' => round($i * 0.2, 1)] ] ]); $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create vector document ' . $i); } // Trigger JSON export $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => 'vectorsdb-export-test', 'columns' => [], 'queries' => [], 'notify' => false, ]); $this->assertEquals(202, $migration['headers']['status-code']); $migrationId = $migration['body']['$id']; // Poll until completed $this->assertEventually(function () use ($migrationId, $headers) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('Appwrite', $migration['body']['source']); $this->assertEquals('JSON', $migration['body']['destination']); }, 30_000, 500); // Cleanup $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); } public function testCreateVectorsDBJSONImport(): void { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // Create vectorsdb database $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ 'databaseId' => ID::unique(), 'name' => 'VectorsDB Import Test' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create collection with dimension 16 $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ 'collectionId' => ID::unique(), 'name' => 'VecImportCol', 'dimension' => 16, ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Create bucket and upload test file $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ 'bucketId' => ID::unique(), 'name' => 'VectorsDB Import Bucket', 'maximumFileSize' => 2000000, 'allowedFileExtensions' => ['json'], ]); $this->assertEquals(201, $bucket['headers']['status-code']); $bucketId = $bucket['body']['$id']; $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/vectorsdb-documents.json'), 'application/json', 'vectorsdb-documents.json'), ]); $this->assertEquals(201, $file['headers']['status-code']); $fileId = $file['body']['$id']; // Trigger import $migration = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $collectionId, ]); $this->assertEquals(202, $migration['headers']['status-code']); // Poll until completed $this->assertEventually(function () use ($migration, $headers) { $migrationId = $migration['body']['$id']; $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); $this->assertEquals(200, $result['headers']['status-code']); $this->assertEquals('finished', $result['body']['stage']); $this->assertEquals('completed', $result['body']['status']); $this->assertEquals('JSON', $result['body']['source']); $this->assertEquals('Appwrite', $result['body']['destination']); }, 30_000, 500); // Verify documents were imported $docs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); $this->assertEquals(200, $docs['headers']['status-code']); $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 vectorsdb documents'); // Verify first document structure $firstDoc = $docs['body']['documents'][0]; $this->assertArrayHasKey('embeddings', $firstDoc); $this->assertCount(16, $firstDoc['embeddings'], 'Imported embeddings should have 16 dimensions'); $this->assertArrayHasKey('metadata', $firstDoc); // Cleanup $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); } public function testCreateDocumentsDBJSONExport(): void { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // Create documentsdb database $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ 'databaseId' => ID::unique(), 'name' => 'DocumentsDB Export Test' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create collection (schemaless — no attributes needed) $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ 'collectionId' => ID::unique(), 'name' => 'DocExportCol', ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Seed 5 documents for ($i = 1; $i <= 5; $i++) { $doc = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ 'documentId' => ID::unique(), 'data' => [ 'name' => 'User ' . $i, 'email' => 'user' . $i . '@test.com', 'age' => 20 + $i, 'address' => ['city' => 'City ' . $i, 'zip' => '1000' . $i] ] ]); $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); } // Trigger JSON export $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ 'resourceId' => $databaseId . ':' . $collectionId, 'filename' => 'documentsdb-export-test', 'columns' => [], 'queries' => [], 'notify' => false, ]); $this->assertEquals(202, $migration['headers']['status-code']); $migrationId = $migration['body']['$id']; // Poll until completed $this->assertEventually(function () use ($migrationId, $headers) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); $this->assertEquals(200, $migration['headers']['status-code']); $this->assertEquals('finished', $migration['body']['stage']); $this->assertEquals('completed', $migration['body']['status']); $this->assertEquals('Appwrite', $migration['body']['source']); $this->assertEquals('JSON', $migration['body']['destination']); }, 30_000, 500); // Cleanup $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); } public function testCreateDocumentsDBJSONImport(): void { $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]; // Create documentsdb database $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ 'databaseId' => ID::unique(), 'name' => 'DocumentsDB Import Test' ]); $this->assertEquals(201, $database['headers']['status-code']); $databaseId = $database['body']['$id']; // Create collection (schemaless) $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ 'collectionId' => ID::unique(), 'name' => 'DocImportCol', ]); $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; // Create bucket and upload test file $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ 'bucketId' => ID::unique(), 'name' => 'DocumentsDB Import Bucket', 'maximumFileSize' => 2000000, 'allowedFileExtensions' => ['json'], ]); $this->assertEquals(201, $bucket['headers']['status-code']); $bucketId = $bucket['body']['$id']; $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ 'content-type' => 'multipart/form-data', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'fileId' => ID::unique(), 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/documentsdb-documents.json'), 'application/json', 'documentsdb-documents.json'), ]); $this->assertEquals(201, $file['headers']['status-code']); $fileId = $file['body']['$id']; // Trigger import $migration = $this->performJsonMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $collectionId, ]); $this->assertEquals(202, $migration['headers']['status-code']); // Poll until completed $this->assertEventually(function () use ($migration, $headers) { $migrationId = $migration['body']['$id']; $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); $this->assertEquals(200, $result['headers']['status-code']); $this->assertEquals('finished', $result['body']['stage']); $this->assertEquals('completed', $result['body']['status']); $this->assertEquals('JSON', $result['body']['source']); $this->assertEquals('Appwrite', $result['body']['destination']); }, 30_000, 500); // Verify documents were imported $docs = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); $this->assertEquals(200, $docs['headers']['status-code']); $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 documentsdb documents'); // Verify first document has nested data $firstDoc = $docs['body']['documents'][0]; $this->assertArrayHasKey('name', $firstDoc); $this->assertArrayHasKey('address', $firstDoc); $this->assertIsArray($firstDoc['address']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); } }