assertTrue(Schema::hasColumn('form_submissions', 'apply_status')); $this->assertTrue(Schema::hasColumn('form_submissions', 'apply_completed_at')); $indexes = $this->indexNamesFor('form_submissions'); $this->assertContains('fs_schema_apply_status_idx', $indexes); $this->assertContains('fs_org_apply_status_idx', $indexes); } public function test_apply_status_has_no_database_default_so_legacy_rows_remain_null(): void { $organisation = Organisation::factory()->create(); $schema = FormSchema::factory()->for($organisation)->create(); // Direct DB insert simulating a legacy row written before the // applicator existed: nothing writes apply_status. $id = (string) \Illuminate\Support\Str::ulid(); DB::table('form_submissions')->insert([ 'id' => $id, 'form_schema_id' => $schema->id, 'organisation_id' => $organisation->id, 'status' => 'submitted', 'is_test' => false, 'auto_save_count' => 0, 'created_at' => now(), 'updated_at' => now(), ]); $row = DB::table('form_submissions')->where('id', $id)->first(); $this->assertNotNull($row); $this->assertNull($row->apply_status); $this->assertNull($row->apply_completed_at); } public function test_form_submission_action_failures_table_has_expected_columns_and_indexes(): void { $this->assertTrue(Schema::hasTable('form_submission_action_failures')); $expected = [ 'id', 'form_submission_id', 'listener_class', 'binding_id', 'failed_at', 'exception_class', 'exception_message', 'context', 'retry_count', 'resolved_at', 'resolved_by_user_id', 'resolved_note', 'dismissed_at', 'dismissed_by_user_id', 'dismissed_reason_type', 'dismissed_reason_note', 'created_at', 'updated_at', ]; foreach ($expected as $column) { $this->assertTrue( Schema::hasColumn('form_submission_action_failures', $column), "Missing column: {$column}", ); } // RFC V3 — table intentionally has NO denormalized organisation_id. $this->assertFalse( Schema::hasColumn('form_submission_action_failures', 'organisation_id'), 'form_submission_action_failures must not carry organisation_id (FK-chain tenancy per RFC V3)', ); $indexes = $this->indexNamesFor('form_submission_action_failures'); foreach ([ 'fsaf_submission_idx', 'fsaf_listener_failed_idx', 'fsaf_resolved_idx', 'fsaf_dismissed_idx', 'fsaf_binding_idx', 'fsaf_reason_type_idx', ] as $idx) { $this->assertContains($idx, $indexes, "Missing index: {$idx}"); } } public function test_down_methods_clean_up_columns_and_table(): void { // The two WS-6 foundation migrations sit chronologically between // 2026_04_25_100000 (WS-5a) and 2026_04_26_100000 (WS-5c) — they // are NOT the last batch, so `migrate:rollback --step=N` would // target unrelated migrations. Invoke the down() methods directly. $createFailures = require database_path( 'migrations/2026_04_25_140100_create_form_submission_action_failures.php', ); $applyStatus = require database_path( 'migrations/2026_04_25_140000_extend_form_submissions_with_apply_status.php', ); $createFailures->down(); $applyStatus->down(); try { $this->assertFalse(Schema::hasColumn('form_submissions', 'apply_status')); $this->assertFalse(Schema::hasColumn('form_submissions', 'apply_completed_at')); $this->assertFalse(Schema::hasTable('form_submission_action_failures')); $indexes = $this->indexNamesFor('form_submissions'); $this->assertNotContains('fs_schema_apply_status_idx', $indexes); $this->assertNotContains('fs_org_apply_status_idx', $indexes); } finally { // Restore state for any subsequent tests in this class. $applyStatus->up(); $createFailures->up(); } } /** * @return list */ private function indexNamesFor(string $table): array { $driver = DB::connection()->getDriverName(); if ($driver === 'mysql' || $driver === 'mariadb') { return collect(DB::select("SHOW INDEX FROM {$table}")) ->pluck('Key_name') ->unique() ->values() ->all(); } if ($driver === 'sqlite') { return collect(DB::select("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = ?", [$table])) ->pluck('name') ->all(); } return Schema::getIndexes($table) ? collect(Schema::getIndexes($table))->pluck('name')->all() : []; } }