Files
InvoiceShelf/tests/Feature/Setup/BaseSchemaConsolidationTest.php
T
Darko Gjorgjijoski 04af1e5c73 feat(schema): consolidate the 2.x migration chain into one guarded base migration
A single consolidation migration replaces the 150 removed historical files.
It decides — from reads alone — whether to build the boundary schema fresh,
skip on a fully-migrated 2.x database, or refuse a partial or inconsistent
history untouched. Fresh installs run it first and the live v3 chain after;
pre-2.x installs are directed through the latest 2.x release. The same
verdict backs a self-updater preflight so an unsafe upgrade is refused
before any file is copied. Schema verified identical to the boundary
fixtures on SQLite, MariaDB and PostgreSQL.
2026-08-20 19:17:28 +02:00

504 lines
17 KiB
PHP

<?php
// The base-schema consolidation: what it builds, what it leaves alone, and what
// it refuses. Spec: squash-guard-spec.md ("Acceptance tests").
//
// Every test here works on its own throwaway SQLite file rather than on the
// suite's database, because the point is to watch the migration meet databases
// in states the suite's own database can never be in — half-migrated, restored
// without its history, or completely empty.
use App\Platform\Operations\Database\SchemaConsolidationGuard;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The one migration under test, as `migrate --path` wants it.
*/
function consolidationPath(): string
{
return 'database/migrations/'.SchemaConsolidationGuard::CONSOLIDATION_MIGRATION.'.php';
}
/**
* Run the consolidation, and nothing else, against the throwaway database.
*/
function runConsolidation(): void
{
Artisan::call('migrate', [
'--database' => 'squash',
'--path' => consolidationPath(),
'--force' => true,
]);
}
/**
* Give the throwaway database a migration repository holding these names.
*/
function recordHistory(array $names): void
{
Artisan::call('migrate:install', ['--database' => 'squash']);
DB::connection('squash')->table('migrations')->insert(
array_map(fn (string $name): array => ['migration' => $name, 'batch' => 1], $names)
);
}
/**
* The names a real 2.4.x database carries beyond the replaced set.
*
* Module migrations share the repository table, and databases from the 2.4.x
* line recorded one migration this codebase has never shipped. Both must be
* ignored by the decision.
*/
function foreignHistory(): array
{
return [
'2022_06_01_120000_create_payments_module_tables',
'2026_04_07_000001_increase_tax_percent_precision_to_three_decimals',
];
}
/**
* Stand in for "this database already has a schema".
*/
function giveDatabaseASentinel(): void
{
Schema::connection('squash')->create('companies', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
});
}
/**
* Everything about the database that an ABORT must leave untouched.
*/
function databaseFootprint(): array
{
$builder = Schema::connection('squash');
$footprint = [];
foreach ($builder->getTables() as $table) {
$footprint[$table['name']] = [
'columns' => array_column($builder->getColumns($table['name']), 'name'),
'rows' => DB::connection('squash')->table($table['name'])->get()->toArray(),
];
}
ksort($footprint);
return $footprint;
}
/**
* The four introspection calls the boundary fixture was captured with.
*/
function introspectConsolidatedSchema(): array
{
$builder = Schema::connection('squash');
$tables = [];
foreach ($builder->getTables() as $table) {
$name = $table['name'];
if ($name === 'migrations') {
continue;
}
$tables[$name] = [
'columns' => array_values($builder->getColumns($name)),
'indexes' => array_values($builder->getIndexes($name)),
'foreign_keys' => array_values($builder->getForeignKeys($name)),
];
}
return normaliseIntrospection($tables);
}
/**
* Put an introspection result in a comparable shape.
*
* Column order is compared as it stands — it is part of what the consolidation
* reproduces. Index and foreign-key order is not: the drivers report them in
* whatever order they please, so both are sorted. The schema each foreign key
* names is dropped, because it is the database's own name and the fixture was
* captured from a database with a different one.
*/
function normaliseIntrospection(array $tables): array
{
$normalised = [];
foreach ($tables as $name => $table) {
$indexes = array_values($table['indexes']);
usort($indexes, fn (array $a, array $b): int => [$a['name'], implode(',', $a['columns'])]
<=> [$b['name'], implode(',', $b['columns'])]);
$foreignKeys = array_map(function (array $key): array {
unset($key['foreign_schema']);
return $key;
}, array_values($table['foreign_keys']));
usort($foreignKeys, fn (array $a, array $b): int => [implode(',', $a['columns']), $a['foreign_table']]
<=> [implode(',', $b['columns']), $b['foreign_table']]);
$normalised[$name] = [
'columns' => array_values($table['columns']),
'indexes' => $indexes,
'foreign_keys' => $foreignKeys,
];
}
ksort($normalised);
return $normalised;
}
/**
* The captured end state of the replaced chain on SQLite.
*/
function boundaryFixture(): array
{
$fixture = json_decode(file_get_contents(__DIR__.'/fixtures/boundary.sqlite.json'), true);
return normaliseIntrospection($fixture['tables']);
}
beforeEach(function () {
$this->squashDatabase = tempnam(sys_get_temp_dir(), 'squash-').'.sqlite';
touch($this->squashDatabase);
config(['database.connections.squash' => [
'driver' => 'sqlite',
'database' => $this->squashDatabase,
'prefix' => '',
'foreign_key_constraints' => true,
]]);
DB::purge('squash');
});
afterEach(function () {
DB::purge('squash');
@unlink($this->squashDatabase);
});
// -- BUILD ------------------------------------------------------------------
it('builds the boundary schema on an empty database', function () {
runConsolidation();
$built = introspectConsolidatedSchema();
$expected = boundaryFixture();
expect(array_keys($built))->toBe(array_keys($expected))
->and(count($built))->toBe(43)
->and(array_sum(array_map(fn (array $t): int => count($t['columns']), $built)))->toBe(524);
foreach ($expected as $name => $table) {
expect($built[$name]['columns'])->toEqual($table['columns']);
expect($built[$name]['indexes'])->toEqual($table['indexes']);
expect($built[$name]['foreign_keys'])->toEqual($table['foreign_keys']);
}
});
it('seeds the two system file disks', function () {
runConsolidation();
$disks = DB::connection('squash')->table('file_disks')->orderBy('id')->get();
expect($disks)->toHaveCount(2)
->and($disks[0]->name)->toBe('public')
->and($disks[0]->type)->toBe('SYSTEM')
->and($disks[0]->driver)->toBe('local')
->and((bool) $disks[0]->set_as_default)->toBeFalse()
->and($disks[1]->name)->toBe('local_private')
->and($disks[1]->type)->toBe('SYSTEM')
->and($disks[1]->driver)->toBe('local')
->and((bool) $disks[1]->set_as_default)->toBeTrue();
// Credentials are a single JSON encoding, and describe this installation
// rather than the one the release was built on.
expect(json_decode($disks[0]->credentials, true))->toBe([
'driver' => 'local',
'root' => config('filesystems.disks.public.root'),
'url' => config('app.url').'/storage',
'visibility' => 'public',
]);
expect(json_decode($disks[1]->credentials, true))->toBe([
'root' => config('filesystems.disks.local.root'),
'driver' => 'local',
]);
});
it('seeds the three late currencies', function () {
runConsolidation();
$currencies = DB::connection('squash')->table('currencies')->orderBy('code')->get()
->keyBy('code');
expect($currencies->keys()->all())->toBe(['DZD', 'PYG', 'QAR'])
->and($currencies['DZD']->name)->toBe('Algerian Dinar')
->and($currencies['DZD']->symbol)->toBe('DA')
->and($currencies['DZD']->precision)->toBe(2)
->and($currencies['DZD']->thousand_separator)->toBe(',')
->and($currencies['DZD']->decimal_separator)->toBe('.')
->and($currencies['PYG']->name)->toBe('Paraguayan Guaraní')
->and($currencies['PYG']->symbol)->toBe('₲')
->and($currencies['PYG']->precision)->toBe(0)
->and($currencies['PYG']->thousand_separator)->toBe('.')
->and($currencies['PYG']->decimal_separator)->toBe(',')
->and($currencies['QAR']->name)->toBe('Qatari Riyal')
->and($currencies['QAR']->symbol)->toBe('QR')
->and($currencies['QAR']->precision)->toBe(2);
});
it('stamps the boundary version', function () {
runConsolidation();
expect(DB::connection('squash')->table('settings')->where('option', 'version')->value('value'))
->toBe('1.3.0');
});
/**
* The collision the seed step exists to survive: something else got a currency
* into the table first. The row is planted the moment the currencies table
* appears, so the seed step meets it exactly as it would in the wild.
*/
it('inserts only the currencies that are missing at seed time', function () {
DB::connection('squash')->listen(function ($query) {
static $planted = false;
if ($planted || preg_match('/^create table "currencies"/i', $query->sql) !== 1) {
return;
}
$planted = true;
DB::connection('squash')->table('currencies')->insert([
'name' => 'Guarani From Somewhere Else',
'code' => 'PYG',
'symbol' => 'G',
'precision' => 0,
'thousand_separator' => '.',
'decimal_separator' => ',',
]);
});
runConsolidation();
$currencies = DB::connection('squash')->table('currencies')->orderBy('code')->get();
expect($currencies)->toHaveCount(3)
->and($currencies->pluck('code')->all())->toBe(['DZD', 'PYG', 'QAR'])
->and($currencies->firstWhere('code', 'PYG')->name)->toBe('Guarani From Somewhere Else');
});
// -- SKIP -------------------------------------------------------------------
it('leaves a fully migrated 2.4.x database alone', function () {
recordHistory([...SchemaConsolidationGuard::REPLACED_MIGRATIONS, ...foreignHistory()]);
giveDatabaseASentinel();
DB::connection('squash')->table('companies')->insert(['name' => 'Acme']);
$before = databaseFootprint();
runConsolidation();
$after = databaseFootprint();
// The only change is the consolidation's own row in the repository.
expect(array_keys($after))->toBe(array_keys($before))
->and($after['companies'])->toEqual($before['companies'])
->and(count($after['migrations']['rows']))->toBe(count($before['migrations']['rows']) + 1)
->and(DB::connection('squash')->table('migrations')
->where('migration', SchemaConsolidationGuard::CONSOLIDATION_MIGRATION)->exists())->toBeTrue();
// No schema was built: the sentinel is still the stub, and none of the
// boundary tables appeared beside it.
expect(Schema::connection('squash')->hasTable('invoices'))->toBeFalse()
->and($after['companies']['columns'])->toBe(['id', 'name']);
});
it('ignores recorded names that are not part of the replaced set', function () {
recordHistory(foreignHistory());
// Nothing from the replaced set is present, and there is no schema, so this
// is still a fresh database however many other names it carries.
expect(SchemaConsolidationGuard::inspect('squash')->mode())->toBe(SchemaConsolidationGuard::BUILD);
runConsolidation();
expect(Schema::connection('squash')->hasTable('invoices'))->toBeTrue();
});
// -- ABORT: floor -----------------------------------------------------------
it('refuses a database that stopped one migration short of the 2.x boundary', function () {
$history = SchemaConsolidationGuard::REPLACED_MIGRATIONS;
array_pop($history);
recordHistory([...$history, ...foreignHistory()]);
giveDatabaseASentinel();
$before = databaseFootprint();
expect(fn () => runConsolidation())->toThrow(
RuntimeException::class,
'Upgrading to this version requires the database to be fully migrated on the 2.x line first. '
.'This database has run 149 of the 150 historical migrations this version consolidates. '
.'Install the latest InvoiceShelf 2.x release, run its migrations to completion, then upgrade '
.'to this version again. No changes have been made.'
);
expect(databaseFootprint())->toEqual($before);
});
it('refuses a database that only ever ran the first migration', function () {
recordHistory([SchemaConsolidationGuard::REPLACED_MIGRATIONS[0]]);
$before = databaseFootprint();
expect(fn () => runConsolidation())->toThrow(
RuntimeException::class,
'This database has run 1 of the 150 historical migrations this version consolidates.'
);
expect(databaseFootprint())->toEqual($before);
});
/**
* One replaced name genuinely contains a space. Matching is byte-exact, so a
* database recording the tidied-up spelling has not run that migration.
*/
it('matches replaced names byte for byte', function () {
$spaced = '2018_11_02_133825_create_ expense_categories_table';
expect(SchemaConsolidationGuard::REPLACED_MIGRATIONS)->toContain($spaced);
$history = array_map(
fn (string $name): string => $name === $spaced ? str_replace(' ', '', $name) : $name,
SchemaConsolidationGuard::REPLACED_MIGRATIONS
);
recordHistory($history);
giveDatabaseASentinel();
// 149 of 150 matched: the de-spaced name counts for nothing.
$verdict = SchemaConsolidationGuard::inspect('squash');
expect($verdict->mode())->toBe(SchemaConsolidationGuard::ABORT_FLOOR)
->and($verdict->recordedCount())->toBe(149);
});
// -- ABORT: inconsistent ----------------------------------------------------
it('refuses a schema that arrived without its history', function () {
recordHistory([]);
giveDatabaseASentinel();
$before = databaseFootprint();
expect(fn () => runConsolidation())->toThrow(
RuntimeException::class,
'The database schema and the migration history do not match (the recorded history and the '
.'`companies` table disagree). This usually means a partial restore. Restore a consistent '
.'backup — schema and migration history together — and run the upgrade again. No changes '
.'have been made.'
);
expect(databaseFootprint())->toEqual($before);
});
it('refuses a history that arrived without its schema', function () {
recordHistory([...SchemaConsolidationGuard::REPLACED_MIGRATIONS, ...foreignHistory()]);
$before = databaseFootprint();
expect(fn () => runConsolidation())->toThrow(
RuntimeException::class,
'The database schema and the migration history do not match'
);
expect(databaseFootprint())->toEqual($before);
});
// -- Re-running and rolling back --------------------------------------------
it('does nothing when migrate runs a second time', function () {
runConsolidation();
$before = databaseFootprint();
runConsolidation();
expect(databaseFootprint())->toEqual($before);
});
it('refuses to be rolled back', function () {
runConsolidation();
$before = databaseFootprint();
expect(fn () => Artisan::call('migrate:rollback', [
'--database' => 'squash',
'--path' => consolidationPath(),
'--force' => true,
]))->toThrow(RuntimeException::class, 'irreversible');
expect(databaseFootprint())->toEqual($before);
});
// -- The decision table, read directly --------------------------------------
it('decides every row of the decision table from reads alone', function (
bool $withHistory,
bool $partial,
bool $withSentinel,
string $expected,
) {
$history = [];
if ($withHistory) {
$history = SchemaConsolidationGuard::REPLACED_MIGRATIONS;
if ($partial) {
array_pop($history);
}
}
recordHistory($history);
if ($withSentinel) {
giveDatabaseASentinel();
}
expect(SchemaConsolidationGuard::inspect('squash')->mode())->toBe($expected);
})->with([
'empty history, no schema' => [false, false, false, SchemaConsolidationGuard::BUILD],
'empty history, schema present' => [false, false, true, SchemaConsolidationGuard::ABORT_INCONSISTENT],
'partial history, schema present' => [true, true, true, SchemaConsolidationGuard::ABORT_FLOOR],
'partial history, no schema' => [true, true, false, SchemaConsolidationGuard::ABORT_FLOOR],
'complete history, schema present' => [true, false, true, SchemaConsolidationGuard::SKIP],
'complete history, no schema' => [true, false, false, SchemaConsolidationGuard::ABORT_INCONSISTENT],
]);
it('embeds the replaced set exactly once, at full length', function () {
$replaced = SchemaConsolidationGuard::REPLACED_MIGRATIONS;
expect($replaced)->toHaveCount(150)
->and(array_unique($replaced))->toHaveCount(150)
->and($replaced[0])->toBe('2014_10_11_071840_create_companies_table')
->and(end($replaced))->toBe('2025_09_02_add_expense_number_to_expenses_table');
// None of them is still on disk: this migration is what replaced them.
foreach ($replaced as $name) {
expect(file_exists(base_path('database/migrations/'.$name.'.php')))->toBeFalse();
}
expect(glob(base_path('database/migrations/*.php')))
->each(fn ($file) => expect(basename($file->value))->toStartWith('2026_'));
});