Adds direct tenant + event columns to form_submissions so rapportage-hot
aggregate queries (dashboards, CSV-exports, counts over thousands of rows
per org or per event) skip the form_schemas join. This is the single
denormalization exception per addendum Q2; every other form-builder child
table continues to resolve tenancy via FK-chain through its parent
(implemented in Commit 3).
Schema:
- form_submissions.organisation_id ULID FK → organisations, cascade delete, NOT NULL
- form_submissions.event_id ULID FK → events, null on delete, nullable
- Indexes: (organisation_id, status), (event_id, status)
Observer: App\Observers\FormBuilder\FormSubmissionObserver::creating
resolves both columns when the caller has not set them.
- organisation_id <- form_schema.organisation_id (always present —
form_schemas carries OrganisationScope's column directly)
- event_id <- schema.owner_id when owner_type === 'event'; else the
active route's {event} parameter; else null (user_profile /
signature_contract purposes)
The observer docblock spells out both resolution paths and is covered
by the observer test below.
Model: FormSubmission gains organisation_id + event_id in $fillable, a
belongsTo organisation() and belongsTo event() relation.
Factory: FormSubmissionFactory gains forOrganisation($org) and
forEvent($event) states for tests that need to override the observer's
automatic resolution (e.g. cross-org leakage scenarios in Commit 3).
Normal factory usage does not need the states — the observer populates
both fields on save.
Docs:
- SCHEMA.md §3.5.12 form_submissions table — organisation_id and event_id
inserted between form_schema_id and subject_type; indexes added;
addendum Q2 rationale paragraph at the bottom explaining why this is
the only denormalized form-builder child.
- ARCH-FORM-BUILDER.md §4.3 — mirror changes + rationale inline on the
columns and in the indexes list.
Tests: tests/Feature/FormBuilder/FormSubmissionObserverTest.php — 7 tests
covering organisation resolution from schema, event resolution from
event-owned schema, null event_id for non-event-owned schemas without
route context, route-based event resolution, organisation_id populated
on every create path (factory / new() / Model::create), index presence,
and belongsTo relations. 13 new assertions. Full suite: 984 passed
(2675 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.7 KiB
PHP
167 lines
5.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\FormBuilder;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\FormBuilder\FormSchema;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\Organisation;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* WS-4 Commit 2 coverage — addendum Q2 denormalization observer.
|
|
*
|
|
* Guarantees that every form_submissions row carries correct
|
|
* organisation_id (always) and event_id (when resolvable from schema
|
|
* owner or active route).
|
|
*/
|
|
final class FormSubmissionObserverTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_organisation_id_resolves_from_schema(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create();
|
|
|
|
$submission = FormSubmission::factory()->for($schema, 'schema')->create();
|
|
|
|
$this->assertSame(
|
|
$organisation->id,
|
|
$submission->organisation_id,
|
|
'Observer must denormalize organisation_id from schema parent'
|
|
);
|
|
}
|
|
|
|
public function test_event_id_resolves_from_event_owned_schema(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$event = Event::factory()->for($organisation)->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create([
|
|
'owner_type' => 'event',
|
|
'owner_id' => $event->id,
|
|
]);
|
|
|
|
$submission = FormSubmission::factory()->for($schema, 'schema')->create();
|
|
|
|
$this->assertSame(
|
|
$event->id,
|
|
$submission->event_id,
|
|
'Observer must resolve event_id from schema.owner_id when owner_type = event'
|
|
);
|
|
}
|
|
|
|
public function test_event_id_is_null_when_schema_has_no_event_owner_and_no_route(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create([
|
|
'owner_type' => 'organisation',
|
|
'owner_id' => $organisation->id,
|
|
]);
|
|
|
|
$submission = FormSubmission::factory()->for($schema, 'schema')->create();
|
|
|
|
$this->assertNull(
|
|
$submission->event_id,
|
|
'Non-event-owned schema outside an event route yields event_id = null'
|
|
);
|
|
$this->assertSame($organisation->id, $submission->organisation_id);
|
|
}
|
|
|
|
public function test_event_id_resolves_from_active_route_when_schema_has_no_event_owner(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$event = Event::factory()->for($organisation)->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create([
|
|
'owner_type' => 'organisation',
|
|
'owner_id' => $organisation->id,
|
|
]);
|
|
|
|
// Simulate a request bound to /events/{event}/... by firing the
|
|
// observer inside a controller invocation with the route
|
|
// parameter populated.
|
|
Route::get(
|
|
'/_test/events/{event}/submissions',
|
|
function (Event $event) use ($schema): array {
|
|
$submission = new FormSubmission();
|
|
$submission->form_schema_id = $schema->id;
|
|
$submission->status = 'draft';
|
|
$submission->is_test = false;
|
|
$submission->save();
|
|
|
|
return ['id' => $submission->id, 'event_id' => $submission->event_id];
|
|
}
|
|
);
|
|
|
|
$response = $this->getJson("/_test/events/{$event->id}/submissions");
|
|
$response->assertOk();
|
|
|
|
$this->assertSame(
|
|
$event->id,
|
|
$response->json('event_id'),
|
|
'Route {event} parameter resolves event_id when schema has no event owner'
|
|
);
|
|
}
|
|
|
|
public function test_organisation_id_is_populated_on_every_create_path(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create();
|
|
|
|
// Direct factory
|
|
$s1 = FormSubmission::factory()->for($schema, 'schema')->create();
|
|
// Direct new() + save()
|
|
$s2 = new FormSubmission();
|
|
$s2->form_schema_id = $schema->id;
|
|
$s2->status = 'draft';
|
|
$s2->is_test = false;
|
|
$s2->save();
|
|
// Model::create()
|
|
$s3 = FormSubmission::create([
|
|
'form_schema_id' => $schema->id,
|
|
'status' => 'draft',
|
|
'is_test' => false,
|
|
]);
|
|
|
|
foreach ([$s1, $s2, $s3] as $submission) {
|
|
$this->assertSame(
|
|
$organisation->id,
|
|
$submission->organisation_id,
|
|
'organisation_id must never be NULL — resolved on every create path'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function test_denormalized_indexes_exist(): void
|
|
{
|
|
// Indexes named in the migration — addendum Q2 rapportage-hot.
|
|
$indexNames = collect(DB::select(
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='form_submissions'"
|
|
))->pluck('name')->toArray();
|
|
|
|
$this->assertContains('fs_org_status_idx', $indexNames);
|
|
$this->assertContains('fs_event_status_idx', $indexNames);
|
|
}
|
|
|
|
public function test_organisation_and_event_belongs_to_relationships_resolve(): void
|
|
{
|
|
$organisation = Organisation::factory()->create();
|
|
$event = Event::factory()->for($organisation)->create();
|
|
$schema = FormSchema::factory()->for($organisation)->create([
|
|
'owner_type' => 'event',
|
|
'owner_id' => $event->id,
|
|
]);
|
|
|
|
$submission = FormSubmission::factory()->for($schema, 'schema')->create();
|
|
|
|
$this->assertTrue($submission->organisation->is($organisation));
|
|
$this->assertTrue($submission->event->is($event));
|
|
}
|
|
}
|