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>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user