test(form-builder): WS-6 v1.3-delta D2 tests

~30 new tests + 6 modified covering D2 deliverables.

NEW test files:
- FormSubmissionSubmittedListenerOrderTest: rewritten — flips
  identity-match assertion from sync to ShouldQueue + adds AST-level
  structural guard that every queued listener has the
  apply_status=COMPLETED gate as an early statement
  (form-builder.queued-listener.skipped_apply_failed log line + ApplyStatus::COMPLETED check).
- TriggerPersonIdentityMatchOnFormSubmitTest: rewritten — drops
  failsafe-pad assertions; adds gate-skip tests (null/PENDING/PARTIAL/FAILED);
  invariant-violation throw test; broadcast-dispatch test.
- ApplyBindingsOnFormSubmitTest: extended — initial
  identity_match_status='pending' write, apply_completed_at on both
  paths, classifier-derived failure_response_code per exception subclass,
  unknown_error fallback, deadline wrapper invocation captured by
  test double, outer-transaction failure record.
- SyncTagPickerSelectionsOnSubmitGateTest (NEW): canonical skip-log
  assertion for null/PENDING/PARTIAL/FAILED apply_status; no-skip-log
  assertion for COMPLETED. Uses Log::spy because FormTagSyncService
  is final and can't be Mockery-mocked.
- FormBindingApplicatorDeadlineTest (NEW): withDeadline returns clone;
  no-deadline path; generous-deadline path; timeout exception thrown
  with correct submissionId + reasonCode (temporary_error inherited
  via FormBindingInfraException). Uses incident_report purpose for
  anonymous-allowed branch to avoid PersonProvisioner constraints.
- RetryServiceFailureClassifierTest (NEW): per-subclass
  failure_response_code mapping in recordFailure; apply_completed_at
  symmetry-fix coverage.
- SubmissionChannelAuthTest (NEW): submitter authorised, other user
  denied, missing submission denied, org admin currently denied
  (locks v1 contract per BACKLOG TECH-CHANNEL-AUTH-ORG-ADMIN).
- FormSubmissionResourceIdentityMatchTest: extended — DataProvider
  iterates over all six non-person purposes asserting
  identity_match=null per RFC §Q2 v1.3 contract.

MODIFIED to fit v1.3 layout:
- IdentityMatchOnSubmitTest: rewritten — directly invokes the listener
  with apply_status=COMPLETED pre-set, mirroring ApplyBindings'
  happy-path output (the test fixtures lack an identity-key binding
  so going through full event dispatch fails at PersonProvisioner).
  Drops the failsafe-pad assertion in test_public_submission_marked_pending;
  replaces with v1.3 contract: subject_type=null leaves
  identity_match_status untouched.
- TagPickerSyncListenerTest: same fix — sets apply_status=COMPLETED
  on the submission and invokes the listener directly.

Full suite: 1621 passing (4281 assertions). Larastan: 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 03:20:27 +02:00
parent 94205164ed
commit 1afe11609a
10 changed files with 1096 additions and 130 deletions

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Tests\Feature\Api\V1\Public\FormBuilder;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
@@ -23,6 +25,12 @@ use Tests\TestCase;
* ARCH §31.1 TriggerPersonIdentityMatchOnFormSubmit contract.
* Verifies: (a) only fires for event_registration, (b) matched/pending/
* none states written correctly, (c) listener failures never rethrow.
*
* D2 update: post-RFC-WS-6 v1.3, the listener is queued + gated on
* apply_status=COMPLETED. These tests bypass the full submit pipeline
* (no real bindings configured) and directly invoke the listener with
* apply_status=COMPLETED pre-set on the submission, mirroring the
* gate-passing happy-path output of ApplyBindingsOnFormSubmit.
*/
final class IdentityMatchOnSubmitTest extends TestCase
{
@@ -59,6 +67,31 @@ final class IdentityMatchOnSubmitTest extends TestCase
]);
}
/**
* Build a submission with apply_status=COMPLETED pre-set so the v1.3
* gate passes. Then invoke the listener directly bypasses
* ApplyBindings (which would fail without a properly-configured
* schema with identity-key binding + default_crowd_type_id).
*
* @param array<string, mixed> $overrides
*/
private function submitAndDispatch(array $overrides): FormSubmission
{
$submission = FormSubmission::factory()->create(array_merge([
'form_schema_id' => $this->schema->id,
'status' => 'submitted',
'submitted_at' => now(),
], $overrides));
$submission->apply_status = ApplyStatus::COMPLETED;
$submission->save();
$listener = $this->app->make(TriggerPersonIdentityMatchOnFormSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission->fresh()));
return $submission->fresh();
}
public function test_event_registration_triggers_matched_when_person_has_user(): void
{
$user = User::factory()->create();
@@ -68,17 +101,12 @@ final class IdentityMatchOnSubmitTest extends TestCase
'user_id' => $user->id,
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $this->schema->id,
$submission = $this->submitAndDispatch([
'subject_type' => 'person',
'subject_id' => $person->id,
'status' => 'submitted',
'submitted_at' => now(),
]);
FormSubmissionSubmitted::dispatch($submission->fresh());
$this->assertSame('matched', $submission->fresh()->identity_match_status);
$this->assertSame('matched', $submission->identity_match_status);
}
public function test_event_registration_triggers_none_when_person_unlinked_no_match(): void
@@ -92,17 +120,12 @@ final class IdentityMatchOnSubmitTest extends TestCase
'last_name' => 'NoMatch',
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $this->schema->id,
$submission = $this->submitAndDispatch([
'subject_type' => 'person',
'subject_id' => $person->id,
'status' => 'submitted',
'submitted_at' => now(),
]);
FormSubmissionSubmitted::dispatch($submission->fresh());
$this->assertSame('none', $submission->fresh()->identity_match_status);
$this->assertSame('none', $submission->identity_match_status);
}
public function test_event_registration_triggers_pending_when_matcher_finds_candidate(): void
@@ -122,17 +145,12 @@ final class IdentityMatchOnSubmitTest extends TestCase
'last_name' => 'Match',
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $this->schema->id,
$submission = $this->submitAndDispatch([
'subject_type' => 'person',
'subject_id' => $person->id,
'status' => 'submitted',
'submitted_at' => now(),
]);
FormSubmissionSubmitted::dispatch($submission->fresh());
$this->assertSame('pending', $submission->fresh()->identity_match_status);
$this->assertSame('pending', $submission->identity_match_status);
}
public function test_non_event_registration_purpose_does_not_trigger(): void
@@ -141,6 +159,7 @@ final class IdentityMatchOnSubmitTest extends TestCase
'organisation_id' => $this->org->id,
'purpose' => FormPurpose::INCIDENT_REPORT,
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $otherSchema->id,
'subject_type' => null,
@@ -148,13 +167,24 @@ final class IdentityMatchOnSubmitTest extends TestCase
'status' => 'submitted',
'submitted_at' => now(),
]);
$submission->apply_status = ApplyStatus::COMPLETED;
$submission->save();
FormSubmissionSubmitted::dispatch($submission->fresh());
$listener = $this->app->make(TriggerPersonIdentityMatchOnFormSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission->fresh()));
$this->assertNull($submission->fresh()->identity_match_status);
}
public function test_public_submission_marked_pending(): void
/**
* v1.3 contract change: the failsafe-pad ("subject_id null → write
* 'pending' + log warning") is gone. With apply_status=COMPLETED but
* subject_type='person' AND subject_id=null, the listener throws
* IdentityMatchInvariantViolation per RFC §Q2. With subject_type=null
* (this test's scenario), the listener simply returns at the
* subject-type check without writing anything.
*/
public function test_public_submission_with_null_subject_does_not_write_status(): void
{
$submission = FormSubmission::factory()->create([
'form_schema_id' => $this->schema->id,
@@ -163,9 +193,12 @@ final class IdentityMatchOnSubmitTest extends TestCase
'status' => 'submitted',
'submitted_at' => now(),
]);
$submission->apply_status = ApplyStatus::COMPLETED;
$submission->save();
FormSubmissionSubmitted::dispatch($submission->fresh());
$listener = $this->app->make(TriggerPersonIdentityMatchOnFormSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission->fresh()));
$this->assertSame('pending', $submission->fresh()->identity_match_status);
$this->assertNull($submission->fresh()->identity_match_status);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Bindings;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
use App\FormBuilder\Bindings\BindingPassResult;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Per RFC-WS-6 §Q1 v1.3 addition 4 + ARCH-BINDINGS §5.3.
*
* The deadline wrapper is a soft post-call microtime check. It cannot
* interrupt mid-query; it catches the long tail of slow applies before
* they hang the public flow.
*
* Uses incident_report purpose with a null submitter so the applicator
* takes the anonymous-allowed branch apply() returns an empty
* BindingPassResult without provisioning a Person, and the deadline
* check still fires at the end of apply(). Avoids the persons-table
* NOT NULL constraints that an event_registration fixture would
* otherwise need full first/last_name bindings to satisfy.
*/
final class FormBindingApplicatorDeadlineTest extends TestCase
{
use RefreshDatabase;
public function test_with_deadline_returns_a_clone(): void
{
$applicator = $this->app->make(FormBindingApplicator::class);
$clone = $applicator->withDeadline(5);
$this->assertInstanceOf(FormBindingApplicator::class, $clone);
$this->assertNotSame($applicator, $clone, 'withDeadline must return a clone, not mutate the receiver');
}
public function test_apply_without_deadline_does_not_throw_for_fast_runs(): void
{
$submission = $this->makeAnonymousSubmission();
$applicator = $this->app->make(FormBindingApplicator::class);
DB::transaction(function () use ($applicator, $submission): void {
$result = $applicator->apply($submission);
$this->assertInstanceOf(BindingPassResult::class, $result);
});
}
public function test_apply_with_generous_deadline_does_not_throw(): void
{
$submission = $this->makeAnonymousSubmission();
$applicator = $this->app->make(FormBindingApplicator::class);
DB::transaction(function () use ($applicator, $submission): void {
$result = $applicator->withDeadline(60)->apply($submission);
$this->assertInstanceOf(BindingPassResult::class, $result);
});
}
public function test_apply_throws_timeout_when_elapsed_exceeds_deadline(): void
{
$submission = $this->makeAnonymousSubmission();
$applicator = new SlowApplicator(
$this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class),
$this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class),
);
$applicator->sleepMicroseconds = 50_000; // 50ms
$this->expectException(FormBindingApplicatorTimeoutException::class);
$this->expectExceptionMessageMatches('/exceeded deadline of 0s/');
DB::transaction(function () use ($applicator, $submission): void {
// 0 seconds — any wall-clock elapsed should exceed it.
$applicator->withDeadline(0)->apply($submission);
});
}
public function test_timeout_exception_carries_submission_id_and_reason_code(): void
{
$submission = $this->makeAnonymousSubmission();
$applicator = new SlowApplicator(
$this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class),
$this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class),
);
$applicator->sleepMicroseconds = 50_000;
$caught = null;
try {
DB::transaction(function () use ($applicator, $submission): void {
$applicator->withDeadline(0)->apply($submission);
});
} catch (FormBindingApplicatorTimeoutException $e) {
$caught = $e;
}
$this->assertNotNull($caught);
$this->assertSame((string) $submission->id, $caught->submissionId);
// Inherited via FormBindingInfraException — Timeout extends Infra.
$this->assertSame('temporary_error', $caught->reasonCode());
}
/**
* Anonymous incident-report submission: the applicator's
* IncidentReportSubjectResolver returns null when submitted_by_user_id
* is null, and the applicator's anonymous-allowed branch returns an
* empty BindingPassResult. The deadline check still fires at the end.
*/
private function makeAnonymousSubmission(): FormSubmission
{
$schema = FormSchema::factory()->create([
'purpose' => FormPurpose::INCIDENT_REPORT->value,
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $schema->id,
'subject_type' => null,
'subject_id' => null,
'submitted_by_user_id' => null,
]);
return $submission->fresh();
}
}
/**
* Test double sleeps inside apply() to force the deadline check at
* the end of apply() to fail for any reasonable deadline value (incl. 0).
*/
final class SlowApplicator extends FormBindingApplicator
{
public int $sleepMicroseconds = 0;
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
usleep($this->sleepMicroseconds);
return parent::apply($submission, $sectionId);
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Bindings;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
use App\Exceptions\FormBuilder\FormBindingDataIntegrityException;
use App\Exceptions\FormBuilder\FormBindingInfraException;
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
use App\FormBuilder\Bindings\BindingPassResult;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFailureRetryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
/**
* Per ARCH-BINDINGS §7.1 v1.2 + RFC-WS-6 §Q3 v1.3 addition 2.
*
* D2 wires FormBindingExceptionClassifier into
* FormFailureRetryService::recordFailure so the retry-failure path
* mirrors ApplyBindingsOnFormSubmit's outer-transaction catch block:
* - failure_response_code on the parent submission per the exception
* subclass classification.
* - apply_completed_at = now() (asymmetry fix per the v1.2 note).
*/
final class RetryServiceFailureClassifierTest extends TestCase
{
use RefreshDatabase;
private function makeFailure(): FormSubmissionActionFailure
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create([
'organisation_id' => $org->id,
'purpose' => FormPurpose::EVENT_REGISTRATION->value,
]);
$submission = FormSubmission::factory()->create([
'form_schema_id' => $schema->id,
'organisation_id' => $org->id,
]);
return FormSubmissionActionFailure::factory()
->for($submission, 'submission')
->create([
'exception_class' => 'OriginalException',
'exception_message' => 'first failure message',
]);
}
/**
* @param callable(FormSubmission, ?string): BindingPassResult $apply
*/
private function bindApplicator(callable $apply): void
{
$stub = new class($apply) extends FormBindingApplicator
{
/** @var callable(FormSubmission, ?string): BindingPassResult */
private $apply;
/** @param callable(FormSubmission, ?string): BindingPassResult $apply */
public function __construct(callable $apply)
{
$this->apply = $apply;
}
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
return ($this->apply)($submission, $sectionId);
}
};
$this->app->instance(FormBindingApplicator::class, $stub);
}
private function retryWithThrow(callable $throwFn): FormSubmissionActionFailure
{
$failure = $this->makeFailure();
$this->bindApplicator(fn () => $throwFn());
$service = $this->app->make(FormFailureRetryService::class);
$result = $service->retry($failure);
$this->assertSame('failed', $result['outcome']);
return $failure->fresh();
}
public function test_record_failure_writes_failure_response_code_for_schema_config(): void
{
$failure = $this->retryWithThrow(
fn () => throw new FormBindingSchemaConfigException(submissionId: 'x', message: 'cfg'),
);
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertSame('schema_config_error', $submission->failure_response_code);
$this->assertSame(ApplyStatus::FAILED, $submission->apply_status);
}
public function test_record_failure_writes_failure_response_code_for_infra(): void
{
$failure = $this->retryWithThrow(
fn () => throw new FormBindingInfraException(submissionId: 'x', message: 'infra'),
);
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertSame('temporary_error', $submission->failure_response_code);
}
public function test_record_failure_writes_failure_response_code_for_timeout(): void
{
// Timeout extends Infra → temporary_error inherited.
$failure = $this->retryWithThrow(
fn () => throw new FormBindingApplicatorTimeoutException(submissionId: 'x', message: 'deadline'),
);
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertSame('temporary_error', $submission->failure_response_code);
}
public function test_record_failure_writes_failure_response_code_for_data_integrity(): void
{
$failure = $this->retryWithThrow(
fn () => throw new FormBindingDataIntegrityException(submissionId: 'x', message: 'fk'),
);
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertSame('data_integrity_error', $submission->failure_response_code);
}
public function test_record_failure_writes_unknown_error_for_arbitrary_throwable(): void
{
$failure = $this->retryWithThrow(fn () => throw new RuntimeException('arbitrary'));
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertSame('unknown_error', $submission->failure_response_code);
}
public function test_record_failure_writes_apply_completed_at_symmetry_fix(): void
{
// Per ARCH-BINDINGS §7.1 v1.2 retry-service asymmetry note —
// pre-D2, recordFailure did NOT write apply_completed_at; only
// recordSuccess did. D2 closes the asymmetry.
$failure = $this->retryWithThrow(
fn () => throw new FormBindingInfraException(submissionId: 'x', message: 'transient'),
);
$submission = FormSubmission::query()->withoutGlobalScopes()->find($failure->form_submission_id);
$this->assertNotNull(
$submission->apply_completed_at,
'apply_completed_at must be set on the failure path; pre-D2 only the success path wrote it.',
);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Channels;
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\Broadcast;
use Tests\TestCase;
/**
* Per RFC-WS-6 §Q1 v1.3 addition 2.
*
* The submission.{submissionId} private channel authorises only the
* submitter (user whose id matches submissions.submitted_by_user_id).
* Org-admin access is deferred see BACKLOG entry
* TECH-CHANNEL-AUTH-ORG-ADMIN.
*
* Broadcast::auth() drives the same callback path that Laravel's
* broadcasting auth middleware uses on a websocket subscription
* attempt. Tests pose as authenticated users and assert the boolean
* outcome.
*/
final class SubmissionChannelAuthTest extends TestCase
{
use RefreshDatabase;
private function authoriseSubscription(?User $user, FormSubmission $submission): bool
{
// Resolve the channel callback registered in routes/channels.php.
// Broadcast::channel() returns the manager; Broadcast::driver()
// exposes the channel callbacks. The cleanest contract test is
// to call the callback directly via the registered closure
// returned by getChannels().
$channels = Broadcast::getChannels();
$name = "submission.{$submission->id}";
foreach ($channels as $pattern => $callback) {
// Pattern is 'submission.{submissionId}'; convert to a regex
// so we can match the concrete channel name.
$regex = '/^'.str_replace(['{submissionId}', '.'], ['([^.]+)', '\\.'], $pattern).'$/';
if (! preg_match($regex, $name, $matches)) {
continue;
}
$result = $callback($user, $matches[1]);
return (bool) $result;
}
$this->fail("No channel callback registered for {$name}");
}
private function makeSubmission(?User $submitter): FormSubmission
{
$org = Organisation::factory()->create();
$schema = FormSchema::factory()->create(['organisation_id' => $org->id]);
return FormSubmission::factory()->create([
'form_schema_id' => $schema->id,
'organisation_id' => $org->id,
'submitted_by_user_id' => $submitter?->id,
]);
}
public function test_submitter_is_authorised(): void
{
$submitter = User::factory()->create();
$submission = $this->makeSubmission($submitter);
$this->assertTrue($this->authoriseSubscription($submitter, $submission));
}
public function test_other_authenticated_user_is_denied(): void
{
$submitter = User::factory()->create();
$other = User::factory()->create();
$submission = $this->makeSubmission($submitter);
$this->assertFalse($this->authoriseSubscription($other, $submission));
}
public function test_subscription_is_denied_when_submission_does_not_exist(): void
{
$user = User::factory()->create();
// Build a submission, then delete it so the FK lookup returns null.
$submission = $this->makeSubmission($user);
$submissionId = (string) $submission->id;
$submission->forceDelete();
// Reuse the submitter's User and a fake submission shell.
$shell = new FormSubmission;
$shell->id = $submissionId;
$this->assertFalse($this->authoriseSubscription($user, $shell));
}
public function test_org_admin_is_currently_denied_per_backlog_entry(): void
{
// Locks the v1 contract: even an org admin of the submission's
// organisation is denied because the canonical Spatie helper
// hasn't been audited yet. See BACKLOG entry
// TECH-CHANNEL-AUTH-ORG-ADMIN. When that lands, this test should
// flip to expectTrue() in the same PR as the auth-callback
// extension.
$orgAdmin = User::factory()->create();
$submitter = User::factory()->create();
$submission = $this->makeSubmission($submitter);
$this->assertFalse(
$this->authoriseSubscription($orgAdmin, $submission),
'V1 contract: org admins are NOT authorised. Flip with TECH-CHANNEL-AUTH-ORG-ADMIN.',
);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Http\Resources\FormBuilder\FormSubmissionResource;
use App\Models\FormBuilder\FormSchema;
@@ -13,6 +14,7 @@ use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
@@ -97,4 +99,53 @@ final class FormSubmissionResourceIdentityMatchTest extends TestCase
$response->assertOk()
->assertJsonPath('data.identity_match.status', 'matched');
}
/**
* Per RFC-WS-6 §Q2 v1.3 for non-person purposes the identity_match
* block is null. ApplyBindings only writes identity_match_status='pending'
* when the subject is a person; non-person purposes leave the column
* NULL and the resource emits null. This test pins the contract per
* non-person purpose so a future refactor can't silently drift.
*
* `event_registration` is intentionally excluded that purpose IS
* person-typed and identity_match is non-null after ApplyBindings runs.
*
* @return array<string, array{0: FormPurpose}>
*/
public static function nonPersonPurposes(): array
{
return [
'signature_contract' => [FormPurpose::SIGNATURE_CONTRACT],
'user_profile' => [FormPurpose::USER_PROFILE],
'incident_report' => [FormPurpose::INCIDENT_REPORT],
'post_event_evaluation' => [FormPurpose::POST_EVENT_EVALUATION],
'supplier_intake' => [FormPurpose::SUPPLIER_INTAKE],
'artist_advance' => [FormPurpose::ARTIST_ADVANCE],
];
}
#[DataProvider('nonPersonPurposes')]
public function test_identity_match_is_null_for_non_person_purpose(FormPurpose $purpose): void
{
$schema = FormSchema::factory()->create([
'organisation_id' => $this->org->id,
'purpose' => $purpose->value,
]);
$submission = FormSubmission::create([
'form_schema_id' => $schema->id,
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now(),
'is_test' => false,
// identity_match_status intentionally not set; for non-person
// purposes the column stays NULL.
]);
$array = $this->toArray($submission);
$this->assertNull(
$array['identity_match'],
"FormSubmissionResource.identity_match must be null for non-person purpose '{$purpose->value}' per RFC-WS-6 §Q2 v1.3.",
);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Integration;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionStatus;
@@ -11,6 +12,7 @@ use App\Enums\IdentityMatchConfidence;
use App\Enums\IdentityMatchMethod;
use App\Enums\IdentityMatchStatus;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
@@ -237,9 +239,17 @@ final class TagPickerSyncListenerTest extends TestCase
$value->value = $tagIds;
$value->save();
// Fire the event manually (we bypass the service during this test
// to isolate the listener contract).
FormSubmissionSubmitted::dispatch($submission->fresh());
// Per ARCH-BINDINGS §5.6 — SyncTagPickerSelectionsOnSubmit is gated
// on apply_status === COMPLETED. These integration tests bypass the
// applicator (the schema has no bindings configured), so we set the
// gate-passing state explicitly and invoke the listener under test
// directly. Mirrors the original "bypass the service to isolate the
// listener contract" intent.
$submission->apply_status = ApplyStatus::COMPLETED;
$submission->save();
$listener = $this->app->make(SyncTagPickerSelectionsOnSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission->fresh()));
return $submission;
}

View File

@@ -9,6 +9,11 @@ use App\Enums\FormBuilder\FormFieldBindingMergeStrategy;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\FormBindingApplicatorTimeoutException;
use App\Exceptions\FormBuilder\FormBindingDataIntegrityException;
use App\Exceptions\FormBuilder\FormBindingInfraException;
use App\Exceptions\FormBuilder\FormBindingSchemaConfigException;
use App\FormBuilder\Bindings\BindingPassResult;
use App\FormBuilder\Bindings\FormBindingApplicator;
use App\Listeners\FormBuilder\ApplyBindingsOnFormSubmit;
use App\Models\CrowdType;
@@ -19,8 +24,8 @@ use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionActionFailure;
use App\Models\FormBuilder\FormValue;
use App\FormBuilder\Bindings\BindingPassResult;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;
@@ -40,11 +45,129 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase
$this->assertNotNull($reloaded->apply_completed_at);
}
public function test_writes_initial_pending_to_identity_match_status_for_person_subject(): void
{
// Per RFC-WS-6 §Q1 v1.3 addition 1 — ApplyBindings writes the
// initial 'pending' identity_match_status inside its inner
// transaction so the HTTP response carries the right state for
// the IdentityMatchBanner first-paint copy.
$submission = $this->makeSubmission();
$listener = $this->app->make(ApplyBindingsOnFormSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission));
$reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id);
$this->assertSame('pending', $reloaded->identity_match_status);
}
public function test_writes_apply_completed_at_on_failure(): void
{
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator(\RuntimeException::class, 'boom');
Log::shouldReceive('error')->once();
$listener = new ApplyBindingsOnFormSubmit($applicator);
$listener->handle(new FormSubmissionSubmitted($submission));
$reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id);
$this->assertSame(ApplyStatus::FAILED, $reloaded->apply_status);
$this->assertNotNull($reloaded->apply_completed_at);
}
public function test_writes_failure_response_code_for_schema_config_exception(): void
{
$this->assertFailureResponseCode(
FormBindingSchemaConfigException::class,
'schema_config_error',
);
}
public function test_writes_failure_response_code_for_infra_exception(): void
{
$this->assertFailureResponseCode(
FormBindingInfraException::class,
'temporary_error',
);
}
public function test_writes_failure_response_code_for_data_integrity_exception(): void
{
$this->assertFailureResponseCode(
FormBindingDataIntegrityException::class,
'data_integrity_error',
);
}
public function test_writes_unknown_error_for_arbitrary_throwable(): void
{
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator(\RuntimeException::class, 'boom');
Log::shouldReceive('error')->once();
$listener = new ApplyBindingsOnFormSubmit($applicator);
$listener->handle(new FormSubmissionSubmitted($submission));
$reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id);
$this->assertSame('unknown_error', $reloaded->failure_response_code);
}
public function test_writes_temporary_error_when_deadline_exceeds(): void
{
// Per RFC-WS-6 §Q1 v1.3 addition 4 — wrapper throws Timeout, which
// extends FormBindingInfraException, so failure_response_code
// resolves to 'temporary_error' via the classifier.
$this->assertFailureResponseCode(
FormBindingApplicatorTimeoutException::class,
'temporary_error',
);
}
public function test_deadline_wrapper_is_invoked_with_config_value(): void
{
// Drive the applicator clone via a test double that asserts the
// deadline value reached withDeadline().
Config::set('form_builder.apply_deadline_seconds', 3);
$submission = $this->makeSubmission();
$applicator = new DeadlineRecordingApplicator(
$this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class),
$this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class),
);
$listener = new ApplyBindingsOnFormSubmit($applicator);
$listener->handle(new FormSubmissionSubmitted($submission));
$this->assertSame(3, DeadlineRecordingApplicator::$lastDeadline);
}
public function test_outer_transaction_writes_failure_record_on_inner_rollback(): void
{
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator(FormBindingSchemaConfigException::class, 'no schema');
Log::shouldReceive('error')->once();
$listener = new ApplyBindingsOnFormSubmit($applicator);
$listener->handle(new FormSubmissionSubmitted($submission));
$failure = FormSubmissionActionFailure::query()
->where('form_submission_id', $submission->id)
->first();
$this->assertNotNull($failure, 'Outer transaction must write the failure record even after the inner rollback');
$this->assertSame(FormBindingSchemaConfigException::class, $failure->exception_class);
}
public function test_exception_path_records_failure_and_marks_failed(): void
{
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator();
$applicator = $this->throwingApplicator(\RuntimeException::class, 'boom');
Log::shouldReceive('error')->once();
@@ -66,7 +189,7 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase
{
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator();
$applicator = $this->throwingApplicator(\RuntimeException::class, 'boom');
Log::shouldReceive('error')->once();
@@ -81,14 +204,33 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase
$this->assertFalse($threw, 'Listener must swallow throws so siblings keep running');
}
private function throwingApplicator(): ThrowingApplicator
private function assertFailureResponseCode(string $exceptionClass, string $expectedCode): void
{
return new ThrowingApplicator(
$submission = $this->makeSubmission();
$applicator = $this->throwingApplicator($exceptionClass, 'test');
Log::shouldReceive('error')->once();
$listener = new ApplyBindingsOnFormSubmit($applicator);
$listener->handle(new FormSubmissionSubmitted($submission));
$reloaded = FormSubmission::query()->withoutGlobalScopes()->find($submission->id);
$this->assertSame(ApplyStatus::FAILED, $reloaded->apply_status);
$this->assertSame($expectedCode, $reloaded->failure_response_code);
}
private function throwingApplicator(string $exceptionClass, string $message): ThrowingApplicator
{
$applicator = new ThrowingApplicator(
$this->app->make(\App\FormBuilder\Purposes\PurposeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingConflictResolver::class),
$this->app->make(\App\FormBuilder\Bindings\BindingTypeRegistry::class),
$this->app->make(\App\FormBuilder\Bindings\BindingActivityLogger::class),
);
$applicator->exceptionClass = $exceptionClass;
$applicator->message = $message;
return $applicator;
}
private function makeSubmission(): FormSubmission
@@ -156,7 +298,7 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase
private function writeValue(string $submissionId, string $fieldId, mixed $value): void
{
$row = new FormValue();
$row = new FormValue;
$row->form_submission_id = $submissionId;
$row->form_field_id = $fieldId;
$row->setAttribute('value', $value);
@@ -192,10 +334,53 @@ final class ApplyBindingsOnFormSubmitTest extends TestCase
}
}
/**
* Test double apply() throws an instance of the configured exception
* class. Subclass of FormBindingApplicator so withDeadline() (which
* returns clone $this) preserves the override.
*/
final class ThrowingApplicator extends FormBindingApplicator
{
public string $exceptionClass = \RuntimeException::class;
public string $message = 'boom';
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
throw new \RuntimeException('boom');
$class = $this->exceptionClass;
// FormBindingApplicatorException subclasses use named-arg constructor;
// generic Throwables use the standard message ctor.
if (is_subclass_of($class, \App\Exceptions\FormBuilder\FormBindingApplicatorException::class)) {
throw new $class(submissionId: (string) $submission->id, message: $this->message);
}
throw new $class($this->message);
}
}
/**
* Test double captures the deadline value passed via withDeadline().
* Used by test_deadline_wrapper_is_invoked_with_config_value.
*/
final class DeadlineRecordingApplicator extends FormBindingApplicator
{
public static ?int $lastDeadline = null;
public function withDeadline(int $seconds): self
{
self::$lastDeadline = $seconds;
return $this;
}
public function apply(FormSubmission $submission, ?string $sectionId = null): BindingPassResult
{
return new BindingPassResult(
formSubmissionId: (string) $submission->id,
provisionedSubjectType: null,
provisionedSubjectId: null,
applications: [],
);
}
}

View File

@@ -10,12 +10,23 @@ use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Event;
use ReflectionClass;
use Tests\TestCase;
/**
* RFC-WS-6 §3 (Q1) listener order on FormSubmissionSubmitted is
* load-bearing: ApplyBindings sync (1st) IdentityMatch sync (2nd)
* queued siblings parallel.
* RFC-WS-6 v1.3 §Q1 listener layout on FormSubmissionSubmitted is
* load-bearing.
*
* v1.3 layout: ApplyBindingsOnFormSubmit is the only sync listener;
* every other listener implements ShouldQueue and is gated on
* apply_status=COMPLETED per ARCH-BINDINGS §5.6.
*
* v1.0/v1.2 layout had two sync listeners (ApplyBindings then
* TriggerPersonIdentityMatch); the v1.3 review (2026-05-07) reduced the
* sync chain to one. The assertions below lock the v1.3 layout so
* a future contributor cannot silently revert TriggerPersonIdentityMatch
* to sync, or queue ApplyBindings, or omit the gating-invariant on a
* new queued listener.
*/
final class FormSubmissionSubmittedListenerOrderTest extends TestCase
{
@@ -41,28 +52,70 @@ final class FormSubmissionSubmittedListenerOrderTest extends TestCase
public function test_apply_bindings_listener_is_synchronous(): void
{
$reflection = new \ReflectionClass(ApplyBindingsOnFormSubmit::class);
$reflection = new ReflectionClass(ApplyBindingsOnFormSubmit::class);
$this->assertFalse(
$reflection->implementsInterface(ShouldQueue::class),
'ApplyBindingsOnFormSubmit must be sync (no ShouldQueue)',
'ApplyBindingsOnFormSubmit must be sync (no ShouldQueue) — subject_id must land in the HTTP response.',
);
}
public function test_identity_match_listener_is_synchronous(): void
public function test_identity_match_listener_is_queued(): void
{
$reflection = new \ReflectionClass(TriggerPersonIdentityMatchOnFormSubmit::class);
$this->assertFalse(
// v1.3 flip — was sync in v1.0/v1.2. RFC-WS-6 §Q1 v1.3.
$reflection = new ReflectionClass(TriggerPersonIdentityMatchOnFormSubmit::class);
$this->assertTrue(
$reflection->implementsInterface(ShouldQueue::class),
'TriggerPersonIdentityMatchOnFormSubmit must be sync (no ShouldQueue)',
'TriggerPersonIdentityMatchOnFormSubmit must be queued (ShouldQueue) per RFC-WS-6 v1.3 §Q1.',
);
}
public function test_tag_picker_sync_listener_is_queued(): void
{
$reflection = new \ReflectionClass(SyncTagPickerSelectionsOnSubmit::class);
$reflection = new ReflectionClass(SyncTagPickerSelectionsOnSubmit::class);
$this->assertTrue(
$reflection->implementsInterface(ShouldQueue::class),
'SyncTagPickerSelectionsOnSubmit must be queued (ShouldQueue)',
);
}
/**
* Structural guard: every queued listener attached to
* FormSubmissionSubmitted must include the apply_status=COMPLETED
* gate as an early statement of handle().
*
* Implementation: file-content scan rather than full AST parsing
* good enough as a regression guard, deliberately fragile if someone
* tries to be clever (e.g. comparing via a helper method). Keep the
* guard simple and let it fail loudly when the canonical pattern
* drifts; rewrite both the listener and this test together if you
* deliberately change the pattern.
*/
public function test_every_queued_listener_has_apply_status_completed_gate(): void
{
$listeners = Event::getRawListeners()[FormSubmissionSubmitted::class] ?? [];
$listenerClasses = array_values(array_filter(array_map(
static fn ($listener): ?string => is_string($listener) ? $listener : null,
$listeners,
)));
foreach ($listenerClasses as $class) {
$reflection = new ReflectionClass($class);
if (! $reflection->implementsInterface(ShouldQueue::class)) {
continue;
}
$source = file_get_contents($reflection->getFileName());
$this->assertStringContainsString(
'apply_status !== ApplyStatus::COMPLETED',
(string) $source,
"Queued listener {$class} must include the apply_status=COMPLETED gating-invariant per ARCH-BINDINGS §5.6.",
);
$this->assertStringContainsString(
'form-builder.queued-listener.skipped_apply_failed',
(string) $source,
"Queued listener {$class} must log the canonical skip event for triage visibility.",
);
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Listeners;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Models\Person;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Mockery;
use Tests\TestCase;
/**
* Per ARCH-BINDINGS §5.6 v1.2 the gating-invariant first statement on
* every queued listener attached to FormSubmissionSubmitted skips when
* apply_status !== COMPLETED. PARTIAL and FAILED both fall through.
*
* Verified by asserting the canonical skip-log line
* (form-builder.queued-listener.skipped_apply_failed) IS emitted on
* apply_status of NULL/PENDING/PARTIAL/FAILED, and is NOT emitted when
* apply_status is COMPLETED. FormTagSyncService is `final` so we can't
* spy on it directly; the log line is the canonical observable signal.
*/
final class SyncTagPickerSelectionsOnSubmitGateTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private CrowdType $crowdType;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->create(['organisation_id' => $this->org->id]);
$this->crowdType = CrowdType::factory()->systemType('CREW')->create([
'organisation_id' => $this->org->id,
]);
$this->schema = FormSchema::factory()->create([
'organisation_id' => $this->org->id,
'purpose' => FormPurpose::EVENT_REGISTRATION,
'owner_type' => 'event',
'owner_id' => $this->event->id,
]);
}
private function makeSubmission(?ApplyStatus $applyStatus): FormSubmission
{
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
]);
$submission = FormSubmission::create([
'form_schema_id' => $this->schema->id,
'subject_type' => 'person',
'subject_id' => $person->id,
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now(),
'is_test' => false,
]);
if ($applyStatus !== null) {
$submission->apply_status = $applyStatus;
$submission->save();
}
return $submission->fresh();
}
private function dispatchListener(FormSubmission $submission): void
{
$listener = $this->app->make(SyncTagPickerSelectionsOnSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission));
}
private function assertSkipLogged(?ApplyStatus $applyStatus): void
{
// Spy on Log so we can pick out the canonical skip-log line
// without strict-mocking other Log calls (siblings may log too).
$logSpy = Log::spy();
$submission = $this->makeSubmission($applyStatus);
$this->dispatchListener($submission);
$logSpy->shouldHaveReceived(
'info',
[
'form-builder.queued-listener.skipped_apply_failed',
Mockery::on(static fn (array $context): bool => ($context['listener'] ?? null) === SyncTagPickerSelectionsOnSubmit::class),
],
);
}
public function test_skips_when_apply_status_is_null(): void
{
$this->assertSkipLogged(null);
}
public function test_skips_when_apply_status_is_pending(): void
{
$this->assertSkipLogged(ApplyStatus::PENDING);
}
public function test_skips_when_apply_status_is_partial(): void
{
// PARTIAL is treated identically to FAILED per ARCH-BINDINGS §5.6.
$this->assertSkipLogged(ApplyStatus::PARTIAL);
}
public function test_skips_when_apply_status_is_failed(): void
{
$this->assertSkipLogged(ApplyStatus::FAILED);
}
public function test_does_not_log_skip_when_apply_status_is_completed(): void
{
// When the gate passes, the canonical skip-log line must NOT
// fire. Other Log calls (e.g. tag-sync.deferred when person.user_id
// is null) may still fire and are out of scope here.
$logSpy = Log::spy();
$submission = $this->makeSubmission(ApplyStatus::COMPLETED);
$this->dispatchListener($submission);
$logSpy->shouldNotHaveReceived('info', [
'form-builder.queued-listener.skipped_apply_failed',
Mockery::any(),
]);
}
}

View File

@@ -4,13 +4,15 @@ declare(strict_types=1);
namespace Tests\Feature\FormBuilder\Listeners;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\ApplyStatus;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Events\FormBuilder\FormSubmissionIdentityMatchResolved;
use App\Events\FormBuilder\FormSubmissionSubmitted;
use App\Exceptions\FormBuilder\IdentityMatchInvariantViolation;
use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit;
use App\Models\CrowdType;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
@@ -18,20 +20,20 @@ use App\Models\Person;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Broadcast;
use Tests\TestCase;
/**
* Contract tests for TriggerPersonIdentityMatchOnFormSubmit. The listener
* acts as the FORM-05 stub + first-cut match detector per ARCH §31.1 and
* drives the form_submissions.identity_match_status column for the portal
* banner. These tests lock in the transition matrix so follow-up work
* extending PersonIdentityService can't silently break the contract.
* Contract tests for TriggerPersonIdentityMatchOnFormSubmit (v1.3 layout).
*
* NOTE: PersonIdentityService is final, so we drive state via real
* Person/User fixtures (deterministic outcomes of detectMatches) rather
* than mocking.
* v1.3 changes:
* - Listener is now queued (was sync in v1.0/v1.2).
* - Failsafe-pad ("subject_id null → write 'pending' + log warning")
* replaced with strict invariant: throws IdentityMatchInvariantViolation
* when subject_type='person' AND subject_id IS NULL AND apply_status=COMPLETED.
* - Gate as first statement: skip when apply_status !== COMPLETED.
* - Dispatches FormSubmissionIdentityMatchResolved on the
* submission.{id} private channel after writing the final status.
*/
final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
{
@@ -64,10 +66,24 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
}
/**
* Build a submission then directly invoke the listener so the test
* fixture controls apply_status without going through ApplyBindings.
*
* `apply_status` is intentionally not in $fillable on the model
* (production writes go through ApplyBindings, never mass-assign);
* tests pass it via $overrides and we route it through direct
* attribute assignment.
*
* @param array<string, mixed> $overrides
*/
private function submit(array $overrides = []): FormSubmission
private function makeSubmission(array $overrides = []): FormSubmission
{
$applyStatus = null;
if (array_key_exists('apply_status', $overrides)) {
$applyStatus = $overrides['apply_status'];
unset($overrides['apply_status']);
}
$submission = FormSubmission::create(array_merge([
'form_schema_id' => $this->schema->id,
'subject_type' => null,
@@ -77,30 +93,76 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
'is_test' => false,
], $overrides));
FormSubmissionSubmitted::dispatch($submission->fresh());
if ($applyStatus !== null) {
$submission->apply_status = $applyStatus;
$submission->save();
}
return $submission->fresh();
}
public function test_public_event_registration_submission_is_marked_pending(): void
private function dispatchListener(FormSubmission $submission): void
{
// RFC Q2: this path is now a logged-warning failsafe. The pending
// state is preserved for portal banner rendering, but a misconfigured
// schema or silently-failed ApplyBindings should surface in logs.
// Spy mode (not strict mock) so other listeners' Log calls don't
// interfere — ApplyBindingsOnFormSubmit may also log error here.
$logSpy = \Illuminate\Support\Facades\Log::spy();
$listener = $this->app->make(TriggerPersonIdentityMatchOnFormSubmit::class);
$listener->handle(new FormSubmissionSubmitted($submission));
}
$submission = $this->submit();
public function test_handle_skips_when_apply_status_is_not_completed(): void
{
// v1.3 §Q2 + ARCH-BINDINGS §5.6 — gate skips on null/PENDING/PARTIAL/FAILED.
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'user_id' => null,
'email' => 'gate-skip@example.test',
]);
$this->assertSame('pending', $submission->identity_match_status);
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
// apply_status is NULL — gate must skip.
]);
/** @var \Mockery\Expectation $expectation */
$expectation = $logSpy->shouldHaveReceived('warning');
$expectation->with(
'form-builder.identity-match.no_person_subject_post_apply',
\Mockery::type('array'),
);
$this->dispatchListener($submission);
// identity_match_status stays NULL because the listener returned at the gate.
$this->assertNull($submission->fresh()->identity_match_status);
}
public function test_handle_skips_when_apply_status_is_partial(): void
{
// PARTIAL is treated identically to FAILED by the gate per ARCH-BINDINGS §5.6.
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'user_id' => null,
]);
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
'apply_status' => ApplyStatus::PARTIAL->value,
]);
$this->dispatchListener($submission);
// PARTIAL → gate skips → identity_match_status untouched.
$this->assertNull($submission->fresh()->identity_match_status);
}
public function test_handle_throws_invariant_violation_when_subject_id_null_after_completed_with_person_subject_type(): void
{
// v1.3 §Q2 — strict invariant replaces the v1.2 failsafe-pad.
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => null, // invariant violation
'apply_status' => ApplyStatus::COMPLETED->value,
]);
$this->expectException(IdentityMatchInvariantViolation::class);
$this->expectExceptionMessageMatches('/subject_id=null after ApplyBindings COMPLETED/');
$this->dispatchListener($submission);
}
public function test_linked_person_is_marked_matched(): void
@@ -112,12 +174,15 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
'user_id' => $user->id,
]);
$submission = $this->submit([
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
$this->assertSame('matched', $submission->identity_match_status);
$this->dispatchListener($submission);
$this->assertSame('matched', $submission->fresh()->identity_match_status);
}
public function test_unlinked_person_with_no_matches_is_marked_none(): void
@@ -134,12 +199,15 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
'registration_source' => 'self',
]);
$submission = $this->submit([
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
$this->assertSame('none', $submission->identity_match_status);
$this->dispatchListener($submission);
$this->assertSame('none', $submission->fresh()->identity_match_status);
}
public function test_unlinked_person_with_pending_match_is_marked_pending(): void
@@ -156,12 +224,15 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
'email' => 'match@example.test',
]);
$submission = $this->submit([
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
$this->assertSame('pending', $submission->identity_match_status);
$this->dispatchListener($submission);
$this->assertSame('pending', $submission->fresh()->identity_match_status);
}
public function test_non_event_registration_submission_is_left_untouched(): void
@@ -180,75 +251,63 @@ final class TriggerPersonIdentityMatchOnFormSubmitTest extends TestCase
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now(),
'is_test' => false,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
FormSubmissionSubmitted::dispatch($submission->fresh());
$this->dispatchListener($submission->fresh());
$this->assertNull($submission->fresh()->identity_match_status);
}
public function test_it_writes_identity_match_status_before_http_response_returns(): void
{
// Regression guard: the listener must run synchronously so the
// public submit response already carries identity_match.status.
// If someone reinstates ShouldQueue, the column stays null in
// the response body (only written later by a queue worker) and
// this assertion fails.
Config::set('form_builder.captcha.required_for_purposes', []);
$this->schema->update([
'is_published' => true,
'public_token' => (string) Str::ulid(),
]);
FormField::factory()->create([
'form_schema_id' => $this->schema->id,
'field_type' => FormFieldType::TEXT->value,
'slug' => 'motivatie',
'label' => 'Motivatie',
'is_portal_visible' => true,
'is_admin_only' => false,
]);
$token = $this->schema->fresh()->public_token;
$create = $this->postJson(
"/api/v1/public/forms/{$token}/submissions",
[
'idempotency_key' => 'sync-regression-001',
'public_submitter_name' => 'Sync Tester',
'public_submitter_email' => 'sync-tester@example.test',
],
);
$create->assertCreated();
$submissionId = $create->json('data.id');
$this->postJson(
"/api/v1/public/forms/{$token}/submissions/{$submissionId}/submit",
['values' => ['motivatie' => 'test']],
)
->assertCreated()
->assertJsonPath('data.status', 'submitted')
->assertJsonPath('data.identity_match.status', 'pending');
}
public function test_submission_with_no_schema_is_left_untouched(): void
{
// Guard branch: if the schema relation can't resolve, the listener
// must early-return without touching the status and without throwing
// (so sibling listeners keep executing).
$submission = FormSubmission::create([
'form_schema_id' => $this->schema->id,
$submission = $this->makeSubmission([
'subject_type' => null,
'subject_id' => null,
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now(),
'is_test' => false,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
// Soft-delete the schema so fresh(['schema']) returns null for the relation.
FormSchema::query()->whereKey($this->schema->id)->delete();
FormSubmissionSubmitted::dispatch($submission->fresh());
$this->dispatchListener($submission);
$this->assertNull($submission->fresh()->identity_match_status);
}
public function test_dispatches_broadcast_after_successful_match(): void
{
// broadcast(...) helper returns a PendingBroadcast which on
// __destruct calls Event::dispatch($event). Faking the Event
// facade for our specific event class captures the dispatch
// without it leaving the test boundary.
\Illuminate\Support\Facades\Event::fake([FormSubmissionIdentityMatchResolved::class]);
$user = User::factory()->create();
$person = Person::factory()->create([
'event_id' => $this->event->id,
'crowd_type_id' => $this->crowdType->id,
'user_id' => $user->id,
]);
$submission = $this->makeSubmission([
'subject_type' => 'person',
'subject_id' => $person->id,
'apply_status' => ApplyStatus::COMPLETED->value,
]);
$this->dispatchListener($submission);
\Illuminate\Support\Facades\Event::assertDispatched(
FormSubmissionIdentityMatchResolved::class,
function (FormSubmissionIdentityMatchResolved $event) use ($submission): bool {
return $event->submissionId === (string) $submission->id
&& $event->status === 'matched'
&& $event->matchCount >= 0;
},
);
}
}