Files
crewli/api/tests/Feature/FormBuilder/PublicFormSubmissionDuplicateResponseTest.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

161 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* End-to-end: duplicate_submission shows up in the submit response
* body when (and only when) a prior submitted submission exists on
* the same schema with the same email.
*/
final class PublicFormSubmissionDuplicateResponseTest extends TestCase
{
use RefreshDatabase;
private Organisation $org;
private Event $event;
private FormSchema $schema;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
Config::set('form_builder.captcha.required_for_purposes', []);
$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,
'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,
]);
}
private function submitAs(string $email, string $idempotencyKey, ?string $schemaOverride = null): array
{
$token = $schemaOverride ?? $this->schema->public_token;
$create = $this->postJson(
"/api/v1/public/forms/{$token}/submissions",
[
'idempotency_key' => $idempotencyKey,
'public_submitter_name' => 'Bart',
'public_submitter_email' => $email,
],
);
$create->assertCreated();
$submissionId = $create->json('data.id');
$submit = $this->postJson(
"/api/v1/public/forms/{$token}/submissions/{$submissionId}/submit",
['values' => ['motivatie' => 'x']],
);
$submit->assertCreated();
return $submit->json('data');
}
public function test_first_submit_has_null_duplicate_submission(): void
{
$data = $this->submitAs('test@example.test', 'dup-regression-001');
$this->assertNull($data['duplicate_submission']);
}
public function test_second_submit_same_email_same_schema_exposes_count_one(): void
{
$this->submitAs('test@example.test', 'dup-regression-010');
$data = $this->submitAs('test@example.test', 'dup-regression-011');
$this->assertIsArray($data['duplicate_submission']);
$this->assertSame(1, $data['duplicate_submission']['count']);
$this->assertNotEmpty($data['duplicate_submission']['first_submitted_at']);
}
public function test_third_submit_exposes_count_two_and_first_date_points_to_first(): void
{
$first = $this->submitAs('test@example.test', 'dup-regression-020');
$this->submitAs('test@example.test', 'dup-regression-021');
$third = $this->submitAs('test@example.test', 'dup-regression-022');
$this->assertSame(2, $third['duplicate_submission']['count']);
$this->assertSame(
$first['submitted_at'],
$third['duplicate_submission']['first_submitted_at'],
'first_submitted_at must continue to point at the oldest submission',
);
}
public function test_different_email_same_schema_sees_no_duplicate(): void
{
$this->submitAs('first@example.test', 'dup-regression-030');
$data = $this->submitAs('second@example.test', 'dup-regression-031');
$this->assertNull($data['duplicate_submission']);
}
public function test_same_email_different_schema_sees_no_duplicate(): void
{
$this->submitAs('test@example.test', 'dup-regression-040');
$otherSchema = FormSchema::factory()->create([
'organisation_id' => $this->org->id,
'purpose' => FormPurpose::EVENT_REGISTRATION,
'owner_type' => 'event',
'owner_id' => $this->event->id,
'is_published' => true,
'public_token' => (string) Str::ulid(),
]);
FormField::factory()->create([
'form_schema_id' => $otherSchema->id,
'field_type' => FormFieldType::TEXT->value,
'slug' => 'motivatie',
'label' => 'Motivatie',
'is_portal_visible' => true,
'is_admin_only' => false,
]);
$data = $this->submitAs('test@example.test', 'dup-regression-041', $otherSchema->public_token);
$this->assertNull($data['duplicate_submission']);
}
public function test_duplicate_submission_block_includes_dutch_title_and_body(): void
{
$this->submitAs('test@example.test', 'dup-regression-050');
$data = $this->submitAs('test@example.test', 'dup-regression-051');
$this->assertSame('Je hebt je eerder al aangemeld', $data['duplicate_submission']['title']);
$this->assertStringContainsString(
'De organisator ziet beide aanmeldingen',
$data['duplicate_submission']['body'],
);
}
}