Files
crewli/api/tests/Feature/FormBuilder/FormSubmissionDuplicateDetectorTest.php
bert.hausmans b6a3a17b0a feat(form-builder): detect duplicate submissions by email on same form schema
Informational hint on the confirmation page when the same email has
already submitted the form. Not a block — the submission proceeds
normally. Privacy-safe: only shown to the submitter themselves.

Scope: same form_schema_id only. Cross-form/cross-event detection
would leak info about other forms.

- New FormSubmissionDuplicateDetector service queries by
  form_submissions.public_submitter_email (trim + case-insensitive)
  scoped to the schema, status=submitted, excluding the current
  submission. Errors are swallowed + logged so a detector failure
  never blocks the submit response.
- PublicFormSubmissionController enriches the submit response by
  setting a transient duplicate_submission_data attribute on the
  submission before resource serialisation.
- PublicFormSubmissionResource serialises a duplicate_submission
  block with count, first_submitted_at, plus backend-authored
  Dutch title + body (plural-agreement + IntlDateFormatter for
  "23 april 2026"-style long-form dates). Null when no priors,
  no email, or detector error.
- DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above
  IdentityMatchBanner on FormConfirmation. Prefers backend copy
  with Intl-based Dutch date fallback for safety.
- 16 new backend assertions across the detector and the full
  submit-response flow; 5 new Vitest assertions for the hint.

Note on scope: spec suggested extracting email from values via
schema binding; the codebase's public flow captures submitter
email in a guaranteed column (public_submitter_email) populated
by the stepper's Contactgegevens step. Using that directly is
both simpler and more correct for the duplicate-by-submitter
semantic. When FORM-05's binding-based extractor lands, this
detector can migrate without changing its public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:26:58 +02:00

198 lines
6.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Models\Event;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Services\FormBuilder\FormSubmissionDuplicateDetector;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class FormSubmissionDuplicateDetectorTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
$this->org = Organisation::factory()->create();
$this->event = Event::factory()->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,
]);
}
/** @param array<string, mixed> $overrides */
private function submission(array $overrides = []): FormSubmission
{
return FormSubmission::create(array_merge([
'form_schema_id' => $this->schema->id,
'subject_type' => null,
'subject_id' => null,
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now(),
'is_test' => false,
'public_submitter_email' => 'default@example.test',
], $overrides));
}
public function test_returns_empty_when_no_other_submissions_exist(): void
{
$current = $this->submission(['public_submitter_email' => 'only@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertTrue($priors->isEmpty());
}
public function test_returns_one_prior_for_same_email_same_schema_submitted(): void
{
$this->submission([
'public_submitter_email' => 'dup@example.test',
'submitted_at' => now()->subDay(),
]);
$current = $this->submission(['public_submitter_email' => 'dup@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertSame(1, $priors->count());
}
public function test_returns_priors_ordered_by_submitted_at_ascending(): void
{
$first = $this->submission([
'public_submitter_email' => 'dup@example.test',
'submitted_at' => now()->subDays(3),
]);
$second = $this->submission([
'public_submitter_email' => 'dup@example.test',
'submitted_at' => now()->subDays(1),
]);
$current = $this->submission(['public_submitter_email' => 'dup@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertSame(2, $priors->count());
$this->assertSame($first->id, $priors[0]->id);
$this->assertSame($second->id, $priors[1]->id);
}
public function test_excludes_current_submission(): void
{
$current = $this->submission(['public_submitter_email' => 'solo@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertFalse($priors->contains('id', $current->id));
}
public function test_excludes_drafts(): void
{
$this->submission([
'public_submitter_email' => 'dup@example.test',
'status' => FormSubmissionStatus::DRAFT->value,
'submitted_at' => null,
]);
$current = $this->submission(['public_submitter_email' => 'dup@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertTrue($priors->isEmpty());
}
public function test_excludes_other_schemas_with_same_email(): void
{
$otherSchema = FormSchema::factory()->create([
'organisation_id' => $this->org->id,
'purpose' => FormPurpose::EVENT_REGISTRATION,
'owner_type' => 'event',
'owner_id' => $this->event->id,
]);
FormSubmission::create([
'form_schema_id' => $otherSchema->id,
'subject_type' => null,
'subject_id' => null,
'status' => FormSubmissionStatus::SUBMITTED->value,
'submitted_at' => now()->subDay(),
'is_test' => false,
'public_submitter_email' => 'scope@example.test',
]);
$current = $this->submission(['public_submitter_email' => 'scope@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertTrue($priors->isEmpty());
}
public function test_returns_empty_when_current_submission_has_no_email(): void
{
$current = $this->submission(['public_submitter_email' => null]);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertTrue($priors->isEmpty());
}
public function test_email_match_is_case_insensitive_and_trimmed(): void
{
$this->submission([
'public_submitter_email' => ' Dup@Example.TEST ',
'submitted_at' => now()->subDay(),
]);
$current = $this->submission(['public_submitter_email' => 'dup@example.test']);
$priors = app(FormSubmissionDuplicateDetector::class)->findPriorSubmissions($current);
$this->assertSame(1, $priors->count());
}
public function test_format_for_response_shapes_count_and_first_date(): void
{
$first = $this->submission([
'public_submitter_email' => 'dup@example.test',
'submitted_at' => now()->subDays(3),
]);
$this->submission([
'public_submitter_email' => 'dup@example.test',
'submitted_at' => now()->subDay(),
]);
$current = $this->submission(['public_submitter_email' => 'dup@example.test']);
$payload = app(FormSubmissionDuplicateDetector::class)->formatForResponse($current);
$this->assertIsArray($payload);
$this->assertSame(2, $payload['count']);
$this->assertSame(
$first->submitted_at->toIso8601String(),
$payload['first_submitted_at'],
);
}
public function test_format_for_response_returns_null_when_no_priors(): void
{
$current = $this->submission(['public_submitter_email' => 'only@example.test']);
$payload = app(FormSubmissionDuplicateDetector::class)->formatForResponse($current);
$this->assertNull($payload);
}
}