Files
crewli/api/tests/Feature/MigrationRollbackTest.php
bert.hausmans cd7a804024 test(forms): model tests, multi-tenancy, migration rollback (Phase 9)
UserProfileTest: belongs-to user, fillable/non-fillable boundaries,
settings cast, lastSubmittedAt accessor (null + max from user-subject
submissions only, ignoring drafts and is_test rows).

FormSchemaTest: ULID PK, OrganisationScope filtering, polymorphic owner
resolution to Event, purpose enum cast, hasMany fields/submissions, and
logSchemaChange() actually creates an activity-log entry.

FormFieldTest: belongs-to schema, field_type stored as string (not DB
enum), binding/translations array casts, hasMany values, soft-delete
preserves historical values, logFieldChange() creates an entry.

FormSubmissionTest: belongs-to schema, polymorphic subject resolution,
status enum cast, schema_snapshot array cast, hasMany values.

FormValueTest: belongs-to submission/field, value array cast, hasMany
options pivot rebuilt by observer, unique-pair DB constraint enforced.

MultiTenancyTest: OrganisationScope correctly filters FormSchema /
FormTemplate / FormFieldLibrary by route-resolved organisation. Pins
the FormSchemaWebhook un-scoped behaviour explicitly so a future scope
addition is an intentional decision, not an accident.

MigrationRollbackTest (group 'slow'): full migrate:fresh → rollback 14
S1 steps → assert all 13 form-builder tables dropped + legacy tables
intentionally retained → re-migrate and assert table list matches
snapshot. Plus a separate test exercising the populate-user-profiles
migration's down().

Supporting tweaks:
- UserProfile::lastSubmittedAt accessor now returns Carbon|null instead
  of a raw timestamp string — testable, and matches Eloquent convention.
- UserProfileFactory cooperates with UserObserver via newModel override
  (updates the auto-created row instead of inserting a duplicate).
- AppServiceProvider morph map extended with all 12 form-builder model
  keys so logSchemaChange/logFieldChange resolve under enforceMorphMap.

Suite: 945 passed (was 911), 2671 assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:44:47 +02:00

115 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\Group;
use Tests\TestCase;
/**
* Hard-resets the test database via migrate:fresh, rolls back every
* form-builder migration in reverse, asserts the new tables are gone (and
* legacy tables remain — Phase 8 was deferred to S2 per S1 wrap-up), then
* re-applies and asserts the table list matches the post-fresh snapshot.
*
* Slow because we exercise the real migrator against the real database.
* Tagged "slow" so CI can parallel-isolate or skip it where needed.
*/
#[Group('slow')]
final class MigrationRollbackTest extends TestCase
{
use WithoutMiddleware;
/** Migration steps added in S1 (Phase 3 + Phase 4). */
private const S1_MIGRATION_STEPS = 14;
private const FORM_BUILDER_TABLES = [
'user_profiles',
'form_schemas',
'form_schema_sections',
'form_field_library',
'form_fields',
'form_submissions',
'form_submission_section_statuses',
'form_submission_delegations',
'form_values',
'form_value_options',
'form_templates',
'form_schema_webhooks',
'form_webhook_deliveries',
];
public function test_form_builder_migrations_are_fully_reversible(): void
{
Artisan::call('migrate:fresh');
$beforeTables = $this->tableList();
// S1 leaves the legacy registration_* tables in place — Phase 8
// was deferred to S2. Sanity-check that assumption is still true.
foreach (['registration_form_fields', 'person_field_values', 'registration_field_templates'] as $legacy) {
$this->assertTrue(Schema::hasTable($legacy), "legacy table {$legacy} should still exist after S1");
}
// Every form-builder table is present after fresh.
foreach (self::FORM_BUILDER_TABLES as $table) {
$this->assertTrue(Schema::hasTable($table), "{$table} should exist after migrate:fresh");
}
// Roll back exactly the S1 migration steps.
Artisan::call('migrate:rollback', ['--step' => self::S1_MIGRATION_STEPS]);
// All form-builder tables should now be gone.
foreach (self::FORM_BUILDER_TABLES as $table) {
$this->assertFalse(Schema::hasTable($table), "{$table} should be dropped by rollback");
}
// Legacy tables remain untouched by the rollback.
$this->assertTrue(Schema::hasTable('registration_form_fields'));
// Re-apply: tables are recreated, table list matches snapshot.
Artisan::call('migrate');
$afterTables = $this->tableList();
sort($beforeTables);
sort($afterTables);
$this->assertSame($beforeTables, $afterTables);
}
public function test_user_profiles_populate_migration_down_clears_backfilled_rows(): void
{
Artisan::call('migrate:fresh');
// The populate migration ran during fresh. Assert it left rows for
// any users present at migrate time (test DB has none, so 0 is OK).
$populatedCount = DB::table('user_profiles')->count();
// down() of the populate migration deletes all profiles.
Artisan::call('migrate:rollback', ['--step' => self::S1_MIGRATION_STEPS - 1]);
Artisan::call('migrate:rollback', ['--step' => 1]); // populate step
// Next rollback step now drops the table — handled by the other test.
// Re-apply for clean state for subsequent tests.
Artisan::call('migrate');
// Sanity: counts can be compared before/after but tests are isolated
// per RefreshDatabase so we mainly assert no exceptions.
$this->assertSame($populatedCount, DB::table('user_profiles')->count());
}
/**
* @return array<int, string>
*/
private function tableList(): array
{
return collect(Schema::getTables())
->pluck('name')
->reject(fn (string $n) => str_starts_with($n, 'sqlite_') || $n === 'migrations')
->values()
->all();
}
}