Tighten A→A re-migration tests against utopia-php/migration schema tolerance

utopia-php/migration's DestinationAppwrite now handles schema tolerance on
re-migration (PR #171 on feat/skip-duplicates): it pre-checks destination
`_metadata` for each database / table / column / index and tolerates in
Skip/Upsert mode. Re-runs no longer produce schema-level errors, so the
E2E tests can drop the status-tolerant workaround and assert strict
'completed' outcomes.

Changes:

- composer.json: pin utopia-php/migration to dev-feat/skip-duplicates (aliased
  to 1.9.99 for stability resolution). Will be replaced with a fixed 1.10.0
  tag once the migration PR lands.

- testAppwriteMigrationRowsOnDuplicate: replace the tolerant
  runMigrationAssertingRowSuccess helper with performMigrationSync on the
  Skip and Upsert re-runs. Asserts 'completed' status on every run,
  destination row content matches the expected value per mode (Mutated
  preserved on Skip, Original restored on Upsert). Helper method removed.

- testAppwriteMigrationReRunIsIdempotent (new): seeds two rows on source,
  runs the migration three times back-to-back (fresh, Skip re-run, Upsert
  re-run) against unchanged source data, asserts strict 'completed' on every
  run and row content is stable across all three. Exercises the
  schema-tolerance path end-to-end: every database/table/column on
  destination already exists with a matching spec, so DestinationAppwrite's
  pre-check returns Tolerate for every resource.
This commit is contained in:
Prem Palanisamy
2026-04-22 16:38:32 +01:00
parent 19f02a5129
commit 715bd40b4a
3 changed files with 107 additions and 42 deletions
+1 -1
View File
@@ -73,7 +73,7 @@
"utopia-php/locale": "0.8.*",
"utopia-php/logger": "0.6.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.9.*",
"utopia-php/migration": "dev-feat/skip-duplicates as 1.9.99",
"utopia-php/platform": "0.13.*",
"utopia-php/pools": "1.*",
"utopia-php/span": "1.1.*",
Generated
+18 -9
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "756f545d9723dbad7f23fd10fddc64bc",
"content-hash": "b6c60200cc06ec4d8ef8e342f09c7c30",
"packages": [
{
"name": "adhocore/jwt",
@@ -4528,16 +4528,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.9.2",
"version": "dev-feat/skip-duplicates",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "97266905f35260137ba0b0e0c4f849f1ee422e43"
"reference": "001682168f7d87932c56635a0d0a45b927febc33"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/97266905f35260137ba0b0e0c4f849f1ee422e43",
"reference": "97266905f35260137ba0b0e0c4f849f1ee422e43",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/001682168f7d87932c56635a0d0a45b927febc33",
"reference": "001682168f7d87932c56635a0d0a45b927febc33",
"shasum": ""
},
"require": {
@@ -4577,9 +4577,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.9.2"
"source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates"
},
"time": "2026-04-20T11:55:43+00:00"
"time": "2026-04-22T13:15:22+00:00"
},
{
"name": "utopia-php/mongo",
@@ -8441,9 +8441,18 @@
"time": "2024-11-07T12:36:22+00:00"
}
],
"aliases": [],
"aliases": [
{
"package": "utopia-php/migration",
"version": "dev-feat/skip-duplicates",
"alias": "1.9.99",
"alias_normalized": "1.9.99.0"
}
],
"minimum-stability": "dev",
"stability-flags": {},
"stability-flags": {
"utopia-php/migration": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -764,10 +764,11 @@ trait MigrationsBase
/**
* Appwrite → Appwrite row re-migration honoring onDuplicate=skip and onDuplicate=upsert.
*
* onDuplicate only gates the row-write path in DestinationAppwrite. Re-running the
* migration with the full resource tree (database/table/column/row) always errors
* on schema creation because destination already has those. This test accepts those
* schema-level errors as expected noise and asserts row-level correctness directly.
* With utopia-php/migration's DestinationAppwrite handling schema tolerance
* (pre-check the destination `_metadata` for each database / table / column
* / index, tolerate existing in Skip/Upsert), re-migration completes
* cleanly — no more schema-level errors to tolerate. The test asserts
* strict 'completed' status via performMigrationSync on every run.
*/
public function testAppwriteMigrationRowsOnDuplicate(): void
{
@@ -816,31 +817,31 @@ trait MigrationsBase
$this->assertEquals(200, $mutate['headers']['status-code']);
$this->assertEquals('Mutated', $mutate['body']['name']);
// Re-migration with onDuplicate=skip. Overall status is expected to be 'failed'
// because schema re-create errors (database/table/column already exist) — those
// are orthogonal to onDuplicate which only affects row writes. Assert row-level
// success counter instead.
$this->runMigrationAssertingRowSuccess([
// 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=upsert. Same status-tolerant approach; assert
// destination row was restored to source value.
$this->runMigrationAssertingRowSuccess([
// Re-migration with onDuplicate=upsert — strict completion; destination
// row restored to source value.
$upsertResult = $this->performMigrationSync([
'resources' => $resources,
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
'onDuplicate' => 'upsert',
]);
$this->assertEquals('completed', $upsertResult['status']);
$rowAfterUpsert = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
$this->assertEquals(200, $rowAfterUpsert['headers']['status-code']);
@@ -854,33 +855,88 @@ trait MigrationsBase
}
/**
* Helper for Appwrite→Appwrite re-migrations where onDuplicate applies only to rows.
* Accepts migration stages of 'finished' regardless of overall status, then asserts
* the row-level counter has zero errors (and at least one success).
*
* @param array<string,mixed> $body
* Re-migrating unchanged source (Skip / Upsert) completes cleanly without
* touching destination rows. Proves the schema-tolerance path: every
* database / table / attribute on destination already exists with a
* matching spec, so DestinationAppwrite's pre-check returns Tolerate for
* every resource and no-ops row writes go through the DB-native conflict
* primitives (INSERT IGNORE / ON DUPLICATE KEY UPDATE).
*/
private function runMigrationAssertingRowSuccess(array $body): void
public function testAppwriteMigrationReRunIsIdempotent(): void
{
$migration = $this->client->call(Client::METHOD_POST, '/migrations/appwrite', [
$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'],
], $body);
$this->assertEquals(202, $migration['headers']['status-code']);
];
$this->assertEventually(function () use ($migration) {
$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'],
$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(200, $response['headers']['status-code']);
$this->assertEquals('finished', $response['body']['stage']);
$this->assertArrayHasKey(Resource::TYPE_ROW, $response['body']['statusCounters']);
$this->assertEquals(0, $response['body']['statusCounters'][Resource::TYPE_ROW]['error']);
$this->assertGreaterThanOrEqual(1, $response['body']['statusCounters'][Resource::TYPE_ROW]['success']);
}, 60_000, 500);
$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 Upsert: same unchanged source. Schema tolerance path
// fires for each resource; rows go through DB-native upsert.
$reRunUpsert = $this->performMigrationSync([
'resources' => $resources,
'endpoint' => $this->webEndpoint,
'projectId' => $this->getProject()['$id'],
'apiKey' => $this->getProject()['apiKey'],
'onDuplicate' => 'upsert',
]);
$this->assertEquals('completed', $reRunUpsert['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 = [];
}
/**