mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
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.
504 lines
17 KiB
PHP
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_'));
|
|
});
|