Files
appwrite/tests/e2e/Services/Migrations/MigrationsBase.php

6406 lines
297 KiB
PHP

<?php
namespace Tests\E2E\Services\Migrations;
use Appwrite\Tests\Retry;
use CURLFile;
use PHPUnit\Framework\Attributes\Depends;
use Tests\E2E\Client;
use Tests\E2E\General\UsageTest;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Services\Functions\FunctionsBase;
use Utopia\Command;
use Utopia\Console;
use Utopia\Database\Database;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite;
trait MigrationsBase
{
use ProjectCustom;
use FunctionsBase;
/**
* @var array
*/
protected static array $destinationProject = [];
/**
* Cached database data for independent test execution
* @var array
*/
protected static array $cachedDatabaseData = [];
/**
* Cached table data for independent test execution
* @var array
*/
protected static array $cachedTableData = [];
/**
* @param bool $fresh
* @return array
*/
public function getDestinationProject(bool $fresh = false): array
{
if (!empty(self::$destinationProject) && !$fresh) {
return self::$destinationProject;
}
$projectBackup = self::$project;
self::$destinationProject = $this->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";
$packageSiteCommand = (new Command('tar'))
->option('--exclude', 'code.tar.gz')
->option('--exclude', 'node_modules')
->flag('-czf')
->argument($tarPath)
->option('-C', $folderPath)
->argument('.');
Console::execute($packageSiteCommand, '', $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);
}
}