~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>
150 lines
5.7 KiB
PHP
150 lines
5.7 KiB
PHP
<?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);
|
|
}
|
|
}
|