From c1ef2ee97a78d88c6f51691285cbf0e09128d201 Mon Sep 17 00:00:00 2001 From: Cline Date: Mon, 12 Oct 2026 13:18:02 -0500 Subject: [PATCH] test: validate M3 imports and locking on PostgreSQL Add isolated PostgreSQL fixtures, two-session locking coverage, and a disposable remote Docker test runner. Correct the canonical-root fixture and document verified SQLite and PostgreSQL results. --- bin/test-postgres.sh | 41 +++++ docker/php/Dockerfile.test | 11 ++ docker/php/Dockerfile.test.dockerignore | 26 +++ docs/postgres-tests.md | 47 +++++ .../2026-10-11-m3-import-indexing-engine.md | 27 ++- tests/Integration/ImportPipelineTest.php | 36 ++-- tests/Integration/PostgresRepositoryTest.php | 162 ++++++++++++++++++ tests/Integration/RepositoryDatabase.php | 95 ++++++++-- tests/Integration/RepositoryTest.php | 12 +- 9 files changed, 420 insertions(+), 37 deletions(-) create mode 100644 bin/test-postgres.sh create mode 100644 docker/php/Dockerfile.test create mode 100644 docker/php/Dockerfile.test.dockerignore create mode 100644 docs/postgres-tests.md create mode 100644 tests/Integration/PostgresRepositoryTest.php diff --git a/bin/test-postgres.sh b/bin/test-postgres.sh new file mode 100644 index 0000000..3f7490d --- /dev/null +++ b/bin/test-postgres.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Run from an isolated checkout on any Docker host. No existing database is touched. +set -eu +cd "$(dirname "$0")/.." +suffix="$$-$(date +%s)" +network="meshvault-test-$suffix" +database="meshvault-test-db-$suffix" +image="meshvault-test:$suffix" +runner="meshvault-test-runner-$suffix" +cleanup() { + docker rm -f "$runner" >/dev/null 2>&1 || true + docker rm -f "$database" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + docker image rm "$image" >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +docker build -f docker/php/Dockerfile.test -t "$image" . +docker network create "$network" >/dev/null +# Password is disposable, on a dedicated network, with no published port or persistent volume. +docker run -d --rm --name "$database" --network "$network" \ + -e POSTGRES_USER=meshvault_test -e POSTGRES_DB=meshvault_test \ + -e POSTGRES_PASSWORD=disposable-test-only postgres:17-bookworm >/dev/null +attempt=0 +until docker exec "$database" pg_isready -h 127.0.0.1 -U meshvault_test -d meshvault_test >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 60 ]; then + echo 'PostgreSQL did not become ready' >&2 + exit 1 + fi + sleep 1 +done +docker run --rm --name "$runner" --network "$network" \ + -e MESHVAULT_TEST_PG_DSN="pgsql:host=$database;dbname=meshvault_test" \ + -e MESHVAULT_TEST_PG_USER=meshvault_test \ + -e MESHVAULT_TEST_PG_PASSWORD=disposable-test-only \ + "$image" composer test +docker run --rm --name "$runner" "$image" composer lint +echo 'PostgreSQL tests and lint completed successfully.' diff --git a/docker/php/Dockerfile.test b/docker/php/Dockerfile.test new file mode 100644 index 0000000..695e751 --- /dev/null +++ b/docker/php/Dockerfile.test @@ -0,0 +1,11 @@ +FROM php:8.4-cli-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev libsqlite3-dev unzip \ + && docker-php-ext-install mbstring dom pdo_pgsql pdo_sqlite xmlwriter \ + && rm -rf /var/lib/apt/lists/* +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +WORKDIR /workspace +COPY composer.json composer.lock ./ +RUN composer install --no-interaction --prefer-dist --no-progress +COPY . . +CMD ["composer", "test"] diff --git a/docker/php/Dockerfile.test.dockerignore b/docker/php/Dockerfile.test.dockerignore new file mode 100644 index 0000000..d10823a --- /dev/null +++ b/docker/php/Dockerfile.test.dockerignore @@ -0,0 +1,26 @@ +** +!composer.json +!composer.lock +!src/** +!tests/** +!db/** +!config/** +!templates/** +!public/index.php +!bin/** +!dev.sh +!dev.ps1 +!phpunit.xml.dist +!phpcs.xml.dist +!phinx.php +!.editorconfig +!docker/** +!docker-compose.yml +!README.md +!CONTRIBUTING.md +!LICENSE +!assets/** +!package.json +!package-lock.json +!.dockerignore +!.gitignore diff --git a/docs/postgres-tests.md b/docs/postgres-tests.md new file mode 100644 index 0000000..b4914fb --- /dev/null +++ b/docs/postgres-tests.md @@ -0,0 +1,47 @@ +# PostgreSQL integration tests + +From a checkout on a Linux Docker host, run: + +```sh +sh bin/test-postgres.sh +``` + +This builds `docker/php/Dockerfile.test` from the current checkout and lockfile, +starts a disposable PostgreSQL 17 container on a unique network, runs the full +suite with PostgreSQL enabled, then runs lint. It publishes no ports, mounts no +existing database volumes, and removes its database container, network and test +image on exit. Build layers remain cached. PHP 8.4 is used for the PHPUnit 12 test +runtime; the production image is unchanged. Docker and network access for the +initial image/dependency build are required. + +For remote validation, copy the current source to a **separate** remote directory +(do not overwrite an existing dirty checkout), then execute the command there. +Do not copy `.env`, host `vendor`, collection directories or other secrets. +Capture the command exit status along with its output. + +## Existing dedicated test database + +Alternatively, export these variables before invoking `composer test`: + +```sh +export MESHVAULT_TEST_PG_DSN='pgsql:host=127.0.0.1;port=5432;dbname=meshvault_test' +export MESHVAULT_TEST_PG_USER=meshvault_test +# Supply MESHVAULT_TEST_PG_PASSWORD securely through your environment. +composer test +``` + +Use a dedicated test database and a role permitted to create schemas. Each test +migrates production Phinx migrations into a randomly named schema, sets its +connections' search path to that schema only, and drops that schema on teardown. +Configured connection or migration errors fail the suite rather than skip it. +An abruptly killed process may leave `meshvault_test_*` schemas; only remove +schemas known to belong to a finished test run. + +Without `MESHVAULT_TEST_PG_DSN`, repository and pipeline tests continue to use +SQLite and the five PostgreSQL-only tests skip. Those tests cover schema isolation, +overlapping claims using `SKIP LOCKED`, rollback/reclaim, and item-lock timeout and +retry using two independent PDO sessions. They are not a multiprocess load test. + +The older `MigrationTest` suite uses the separate application `MESHVAULT_DB_*` +variables and remains skipped in this isolated runner. Production migrations are +nevertheless exercised by the repository/pipeline fixtures on PostgreSQL. diff --git a/docs/superpowers/plans/2026-10-11-m3-import-indexing-engine.md b/docs/superpowers/plans/2026-10-11-m3-import-indexing-engine.md index 980bd61..acef7e3 100644 --- a/docs/superpowers/plans/2026-10-11-m3-import-indexing-engine.md +++ b/docs/superpowers/plans/2026-10-11-m3-import-indexing-engine.md @@ -185,14 +185,33 @@ - Live CLI exit-code probe: `help`=0, `bogus`=1, no args=1, `import` (no path)=1, `worker --sleep=x`=1, `worker-once extra`=1, `import C:\nope` (unconfigured env)=2. +### PostgreSQL follow-up validation (2026-10-12) + +- Windows SQLite/default run: **291 tests, 1801 assertions, 22 skips**, test exit 0; + lint exit 0. +- Remote isolated workspace `/tmp/meshvault-pg-tests-20261011` on `192.168.2.23`: + PHP 8.4.26, PostgreSQL 17, **291 tests, 1863 assertions, 7 skips**, runner exit 0 + after both tests and lint. Log: `/tmp/meshvault-pg-run.log`. +- Existing `/tmp/MeshVault` checkout and dev database were not modified. Disposable + database/test containers and network were removed after the run. +- Linux execution uncovered an invalid canonical-root fixture: it supplied a symlink root, + intentionally rejected by PathSafety. It now uses a safe noncanonical trailing separator + and runs on Windows too. No production safety policy was weakened. +- New test tooling and follow-up changes are included in the PostgreSQL validation follow-up + commit; the original M3 work is in `9d277d9`. + ### Limitations -- **PostgreSQL was not exercised.** Integration tests run against SQLite built from the Phinx - migrations; `FOR UPDATE SKIP LOCKED` is driver-detected but unverified on a live server. - `tests/Integration/MigrationTest.php` still skips without DB credentials. +- **PostgreSQL exercised on 2026-10-12.** The isolated remote Docker runner uses PostgreSQL 17 + and production Phinx migrations for the repository and import pipeline suites. Two-session + tests verify overlapping `SKIP LOCKED` claims, rollback/reclaim, item lock timeout/retry, + explicit `FOR UPDATE` read locking and schema isolation. This is not a multiprocess load test. + The older `MigrationTest` still skips without application DB credentials; the runner does + not point it at the existing dev database. See `docs/postgres-tests.md`. - **Signals are POSIX-only.** `bin/meshvault` installs `SIGTERM`/`SIGINT` handlers when `pcntl` is available; on Windows the worker stops only at `--max-iterations`. - Graceful stop is verified between items, not mid-copy. - Symlink unit tests skip on hosts that cannot create links (11 skips here). -- Nothing is committed, merged or pushed; all work is uncommitted in the working tree. +- Original M3 work was committed as `9d277d9`; PostgreSQL validation is recorded in the + subsequent follow-up commit. diff --git a/tests/Integration/ImportPipelineTest.php b/tests/Integration/ImportPipelineTest.php index 30af94e..4478903 100644 --- a/tests/Integration/ImportPipelineTest.php +++ b/tests/Integration/ImportPipelineTest.php @@ -42,9 +42,13 @@ final class ImportPipelineTest extends TestCase protected function tearDown(): void { unset($this->pdo, $this->jobs, $this->catalog); - @unlink($this->database); - RepositoryDatabase::remove($this->library); - RepositoryDatabase::remove($this->source); + RepositoryDatabase::cleanup($this->database); + if (isset($this->library)) { + RepositoryDatabase::remove($this->library); + } + if (isset($this->source)) { + RepositoryDatabase::remove($this->source); + } } private function service(?SpaceChecker $space = null): ImportService @@ -374,22 +378,15 @@ final class ImportPipelineTest extends TestCase public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void { - // Test that the worker uses canonical (realpath) library root for - // computing relative paths, not the raw string which might have - // symlinks or case differences. - // - // Create library with a symlink in the path - $realLibrary = $this->library; - $linkLibrary = $realLibrary . '-link'; - if (!@symlink($realLibrary, $linkLibrary)) { - self::markTestSkipped('Host cannot create symlinks.'); - } + // A trailing separator is safe but differs from the canonical root. + // Symlink roots are deliberately rejected by PathSafety. + $rawLibrary = $this->library . DIRECTORY_SEPARATOR; $this->writeStl('planes/wing.stl'); $job = $this->service()->import($this->source); - // Create worker with symlinked library root - $safety = new PathSafety($linkLibrary); + $safety = new PathSafety($rawLibrary); + self::assertNotSame($rawLibrary, $safety->libraryRoot()); $sourceSafety = new PathSafety($this->source); $worker = new ImportWorker( $this->jobs, @@ -399,20 +396,17 @@ final class ImportPipelineTest extends TestCase new StlValidator($sourceSafety), $safety, new SpaceChecker(), - $linkLibrary, // Symlinked path + $rawLibrary, new NullLogger(), ); $worker->runOnce(); $item = $this->jobs->items($job)[0]; - // The managed_path should be relative to the CANONICAL root, not the symlink - self::assertStringNotContainsString('link', $item['managed_path']); + self::assertSame('Completed', $item['status']); + self::assertStringStartsWith('planes/', $item['managed_path']); $published = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']); self::assertFileExists($published); - - // Cleanup - unlink($linkLibrary); } } diff --git a/tests/Integration/PostgresRepositoryTest.php b/tests/Integration/PostgresRepositoryTest.php new file mode 100644 index 0000000..04a2f78 --- /dev/null +++ b/tests/Integration/PostgresRepositoryTest.php @@ -0,0 +1,162 @@ +database = tempnam(sys_get_temp_dir(), 'meshvault-pg-'); + $this->pdo = RepositoryDatabase::migrate($this->database); + $this->other = RepositoryDatabase::connect($this->database); + foreach ([$this->pdo, $this->other] as $pdo) { + $pdo->exec("SET lock_timeout = '250ms'"); + $pdo->exec("SET statement_timeout = '5s'"); + } + } + + protected function tearDown(): void + { + foreach (['pdo', 'other'] as $property) { + if (isset($this->$property) && $this->$property->inTransaction()) { + $this->$property->rollBack(); + } + unset($this->$property); + } + if (isset($this->database)) { + RepositoryDatabase::cleanup($this->database); + } + } + + public function testMigrationsAndCleanupAreIsolatedToTheGeneratedSchema(): void + { + $path = tempnam(sys_get_temp_dir(), 'meshvault-pg-isolated-'); + try { + $isolated = RepositoryDatabase::migrate($path); + $schema = $isolated->query('SELECT current_schema()')->fetchColumn(); + $original = $this->pdo->query('SELECT current_schema()')->fetchColumn(); + self::assertMatchesRegularExpression('/^meshvault_test_[a-f0-9]{32}$/', $schema); + self::assertNotSame($original, $schema); + self::assertSame($original, $this->other->query('SELECT current_schema()')->fetchColumn()); + self::assertNotSame( + $this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(), + $this->other->query('SELECT pg_backend_pid()')->fetchColumn(), + ); + $jobs = new PdoImportJobRepository($this->pdo); + $job = $jobs->create('/survives-cleanup', []); + self::assertSame(0, (int) $isolated->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn()); + unset($isolated); + RepositoryDatabase::cleanup($path); + $exists = $this->pdo->prepare('SELECT COUNT(*) FROM pg_namespace WHERE nspname = ?'); + $exists->execute([$schema]); + self::assertSame(0, (int) $exists->fetchColumn()); + self::assertSame('/survives-cleanup', $jobs->get($job)['source_path']); + // An unregistered path is never interpreted as a schema identifier. + RepositoryDatabase::cleanup($schema); + } finally { + unset($isolated); + RepositoryDatabase::cleanup($path); + } + } + + public function testOverlappingClaimsSkipLockedJobsAndReturnNullWhenAllAreLocked(): void + { + $jobs = new PdoImportJobRepository($this->pdo); + $other = new PdoImportJobRepository($this->other); + $first = $jobs->create('/first', []); + $second = $jobs->create('/second', []); + + // The outer transaction must retain claimNext's row lock after it returns. + $this->pdo->beginTransaction(); + self::assertSame($first, (int) $jobs->claimNext()['id']); + self::assertTrue($this->pdo->inTransaction()); + self::assertSame('Pending', $other->get($first)['status']); + self::assertSame($second, (int) $other->claimNext()['id']); + self::assertNull($other->claimNext()); + self::assertTrue($this->pdo->inTransaction()); + $this->pdo->commit(); + self::assertSame('Copying', $other->get($first)['status']); + self::assertSame('Copying', $other->get($second)['status']); + } + + public function testRolledBackClaimCanBeReclaimedByTheOtherConnection(): void + { + $jobs = new PdoImportJobRepository($this->pdo); + $other = new PdoImportJobRepository($this->other); + $job = $jobs->create('/rollback', []); + $this->pdo->beginTransaction(); + self::assertSame($job, (int) $jobs->claimNext()['id']); + self::assertNull($other->claimNext()); + $this->pdo->rollBack(); + self::assertSame('Pending', $other->get($job)['status']); + self::assertNull($other->get($job)['started_at']); + self::assertSame($job, (int) $other->claimNext()['id']); + self::assertNull($jobs->claimNext()); + } + + public function testTerminalNoOpStillAcquiresExplicitItemReadLock(): void + { + $jobs = new PdoImportJobRepository($this->pdo); + $other = new PdoImportJobRepository($this->other); + $job = $jobs->create('/terminal', [['source_path' => '/terminal/a', 'size' => 7]]); + $item = (int) $jobs->items($job)[0]['id']; + $jobs->finishItem($item, 'Completed'); + $this->pdo->beginTransaction(); + $statement = $this->pdo->prepare('SELECT id FROM import_items WHERE id = ? FOR SHARE'); + $statement->execute([$item]); + $statement->closeCursor(); + try { + // Already terminal: no UPDATE occurs, so only SELECT FOR UPDATE can block. + $other->finishItem($item, 'Completed'); + self::fail('The explicit item read lock must conflict with FOR SHARE.'); + } catch (PDOException $error) { + self::assertSame('55P03', $error->getCode()); + } + self::assertFalse($this->other->inTransaction()); + $this->pdo->commit(); + $other->finishItem($item, 'Completed'); + self::assertSame(1, (int) $jobs->get($job)['processed_files']); + } + + public function testItemLockTimesOutWithoutPublishingProgressAndCanBeRetried(): void + { + $jobs = new PdoImportJobRepository($this->pdo); + $other = new PdoImportJobRepository($this->other); + $job = $jobs->create('/item', [['source_path' => '/item/a', 'size' => 7]]); + $jobs->claimNext(); + $item = (int) $jobs->items($job)[0]['id']; + $this->pdo->beginTransaction(); + $jobs->planItem($item, 'a.stl', str_repeat('a', 64), 7); + self::assertTrue($this->pdo->inTransaction()); + try { + $other->finishItem($item, 'Completed'); + self::fail('A concurrent item transition must wait for the item lock.'); + } catch (PDOException $error) { + self::assertSame('55P03', $error->getCode(), 'Expected PostgreSQL lock_not_available.'); + } + self::assertFalse($this->other->inTransaction()); + self::assertSame('Pending', $other->items($job)[0]['status']); + self::assertSame(0, (int) $other->get($job)['processed_files']); + $this->pdo->commit(); + $other->finishItem($item, 'Completed'); + self::assertSame('Completed', $jobs->items($job)[0]['status']); + self::assertSame(1, (int) $jobs->get($job)['processed_files']); + self::assertSame(7, (int) $jobs->get($job)['copied_bytes']); + } +} diff --git a/tests/Integration/RepositoryDatabase.php b/tests/Integration/RepositoryDatabase.php index c8843b7..3dfecfa 100644 --- a/tests/Integration/RepositoryDatabase.php +++ b/tests/Integration/RepositoryDatabase.php @@ -10,27 +10,102 @@ use Phinx\Migration\Manager; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; -/** Runs the production migrations against SQLite and manages scratch directories. */ +/** Runs production migrations against SQLite, or an explicitly configured PostgreSQL database. */ final class RepositoryDatabase { + /** @var array */ + private static array $schemas = []; + + public static function postgresConfigured(): bool + { + $dsn = getenv('MESHVAULT_TEST_PG_DSN'); + return $dsn !== false && $dsn !== ''; + } + /** Never a hand-maintained test schema: drift must fail the suite. */ public static function migrate(string $path): PDO { + if (isset(self::$schemas[$path])) { + throw new \LogicException('Clean up the previous database before migrating this path again.'); + } + $environment = ['adapter' => 'sqlite']; + if (self::postgresConfigured()) { + $pdo = self::postgresConnection(); + $schema = 'meshvault_test_' . bin2hex(random_bytes(16)); + $pdo->exec('CREATE SCHEMA "' . $schema . '"'); + self::$schemas[$path] = ['schema' => $schema, 'connection' => $pdo]; + $pdo->exec('SET search_path TO "' . $schema . '"'); + $environment = ['adapter' => 'pgsql', 'schema' => $schema]; + } else { + $pdo = self::connect($path); + } + try { + $config = new Config([ + 'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'], + 'environments' => [ + 'default_migration_table' => 'phinxlog', + 'default_database' => 'test', + 'test' => $environment + ['connection' => $pdo], + ], + ]); + (new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test'); + if (isset(self::$schemas[$path])) { + // Phinx may add public to search_path; repository queries must remain isolated. + $pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"'); + } + return $pdo; + } catch (\Throwable $error) { + self::cleanup($path); + throw $error; + } + } + + public static function connect(string $path): PDO + { + if (isset(self::$schemas[$path])) { + $pdo = self::postgresConnection(); + $pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"'); + return $pdo; + } + if (self::postgresConfigured()) { + throw new \LogicException('Migrate this path before connecting to PostgreSQL.'); + } $pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); - $config = new Config([ - 'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'], - 'environments' => [ - 'default_migration_table' => 'phinxlog', - 'default_database' => 'test', - 'test' => ['adapter' => 'sqlite', 'connection' => $pdo], - ], - ]); - (new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test'); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA busy_timeout = 5000'); return $pdo; } + public static function cleanup(string $path): void + { + if (isset(self::$schemas[$path])) { + ['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path]; + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + // Only identifiers generated and registered by migrate() can reach this statement. + $pdo->exec('DROP SCHEMA "' . $schema . '" CASCADE'); + unset(self::$schemas[$path]); + } + if (is_file($path)) { + unlink($path); + } + } + + private static function postgresConnection(): PDO + { + $dsn = (string) getenv('MESHVAULT_TEST_PG_DSN'); + if (!str_starts_with($dsn, 'pgsql:')) { + throw new \InvalidArgumentException('MESHVAULT_TEST_PG_DSN must be a pgsql: DSN.'); + } + return new PDO( + $dsn, + ($user = getenv('MESHVAULT_TEST_PG_USER')) === false ? null : $user, + ($password = getenv('MESHVAULT_TEST_PG_PASSWORD')) === false ? null : $password, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + public static function directory(string $prefix): string { $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6)); diff --git a/tests/Integration/RepositoryTest.php b/tests/Integration/RepositoryTest.php index 29518d2..2d6dfda 100644 --- a/tests/Integration/RepositoryTest.php +++ b/tests/Integration/RepositoryTest.php @@ -27,7 +27,15 @@ final class RepositoryTest extends TestCase protected function tearDown(): void { unset($this->pdo); - unlink($this->database); + RepositoryDatabase::cleanup($this->database); + } + + public function testSecondConnectionUsesTheMigratedDatabase(): void + { + $jobs = new PdoImportJobRepository($this->pdo); + $job = $jobs->create('/shared', []); + $other = new PdoImportJobRepository(RepositoryDatabase::connect($this->database)); + self::assertSame('/shared', $other->get($job)['source_path']); } public function testCatalogGroupingAndFileLifecycle(): void @@ -126,7 +134,7 @@ final class RepositoryTest extends TestCase $catalog = new PdoCatalogRepository($this->pdo); $job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]); $second = $jobs->create('/', []); - $other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database)); + $other = new PdoImportJobRepository(RepositoryDatabase::connect($this->database)); self::assertSame($job, (int) $jobs->claimNext()['id']); self::assertSame($second, (int) $other->claimNext()['id']); self::assertNull($other->claimNext());