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.
This commit is contained in:
Cline committed 2026-10-12 13:19:10 -05:00
1 parent 9d277d9e72
commit c1ef2ee97a
9 files changed
+411 -28

No files matched your search

+41
View File
@@ -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.'
+11
View File
@@ -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"]
+26
View File
@@ -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
+47
View File
@@ -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.
@@ -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.
+13 -19
View File
@@ -42,10 +42,14 @@ final class ImportPipelineTest extends TestCase
protected function tearDown(): void
{
unset($this->pdo, $this->jobs, $this->catalog);
@unlink($this->database);
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);
}
}
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\TestCase;
/** Opt in with MESHVAULT_TEST_PG_DSN (and optional PG_USER / PG_PASSWORD with the same prefix). */
final class PostgresRepositoryTest extends TestCase
{
private string $database;
private PDO $pdo;
private PDO $other;
protected function setUp(): void
{
if (!RepositoryDatabase::postgresConfigured()) {
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL concurrency tests.');
}
// Configured connection/migration failures must be errors, never skips.
$this->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']);
}
}
+78 -3
View File
@@ -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<string, array{schema: string, connection: PDO}> */
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
{
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
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' => ['adapter' => 'sqlite', 'connection' => $pdo],
'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]);
$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));
+10 -2
View File
@@ -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());