seed(RoleSeeder::class); $this->organisation = Organisation::factory()->create(); $this->otherOrganisation = Organisation::factory()->create(); $this->orgAdmin = User::factory()->create(); $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); $this->outsider = User::factory()->create(); $this->otherOrganisation->users()->attach($this->outsider, ['role' => 'org_admin']); $this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]); $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->organisation->id, ]); $this->identityService = app(PersonIdentityService::class); } // ────────────────────────────────────────────────────── // Detection tests // ────────────────────────────────────────────────────── public function test_creating_person_with_existing_user_email_creates_pending_match(): void { $matchUser = User::factory()->create(['email' => 'jan@example.nl']); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ 'crowd_type_id' => $this->crowdType->id, 'first_name' => 'Jan', 'last_name' => 'de Vries', 'email' => 'jan@example.nl', ]); $response->assertCreated(); $person = Person::where('email', 'jan@example.nl') ->where('event_id', $this->event->id) ->first(); $this->assertNull($person->user_id, 'Person should NOT be auto-linked'); $this->assertDatabaseHas('person_identity_matches', [ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'matched_on' => IdentityMatchMethod::EMAIL->value, 'confidence' => IdentityMatchConfidence::EXACT->value, 'status' => IdentityMatchStatus::PENDING->value, ]); } public function test_creating_person_with_unknown_email_creates_no_match(): void { Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/persons", [ 'crowd_type_id' => $this->crowdType->id, 'first_name' => 'Piet', 'last_name' => 'Jansen', 'email' => 'unknown@example.nl', ]); $response->assertCreated(); $this->assertDatabaseCount('person_identity_matches', 0); } public function test_creating_person_with_user_id_set_creates_no_match(): void { $linkedUser = User::factory()->create(['email' => 'linked@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'linked@example.nl', 'user_id' => $linkedUser->id, ]); $result = $this->identityService->detectMatchForPerson($person); $this->assertNull($result); $this->assertDatabaseCount('person_identity_matches', 0); } public function test_creating_user_detects_existing_unlinked_persons(): void { $event2 = Event::factory()->create(['organisation_id' => $this->organisation->id]); Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'maria@example.nl', 'user_id' => null, ]); Person::factory()->create([ 'event_id' => $event2->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'maria@example.nl', 'user_id' => null, ]); $user = User::factory()->create(['email' => 'maria@example.nl']); $count = $this->identityService->detectMatchesForUser($user); $this->assertEquals(2, $count); $this->assertDatabaseCount('person_identity_matches', 2); } public function test_no_duplicate_match_records(): void { $matchUser = User::factory()->create(['email' => 'dedup@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'dedup@example.nl', 'user_id' => null, ]); $first = $this->identityService->detectMatchForPerson($person); $second = $this->identityService->detectMatchForPerson($person); $this->assertNotNull($first); $this->assertNotNull($second); $this->assertEquals($first->id, $second->id); $this->assertDatabaseCount('person_identity_matches', 1); } public function test_no_match_when_user_already_linked_in_same_event(): void { $existingUser = User::factory()->create(['email' => 'already@example.nl']); // User already has a person in this event (linked) Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'other@example.nl', 'user_id' => $existingUser->id, ]); // Unlinked person with same email as user $unlinkedPerson = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'already@example.nl', 'user_id' => null, ]); $result = $this->identityService->detectMatchForPerson($unlinkedPerson); $this->assertNull($result); $this->assertDatabaseCount('person_identity_matches', 0); } public function test_matching_is_case_insensitive(): void { User::factory()->create(['email' => 'Jan@Example.com']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'jan@example.com', 'user_id' => null, ]); $result = $this->identityService->detectMatchForPerson($person); $this->assertNotNull($result); $this->assertDatabaseCount('person_identity_matches', 1); } public function test_soft_deleted_person_is_not_matched(): void { User::factory()->create(['email' => 'deleted@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'deleted@example.nl', 'user_id' => null, ]); $person->delete(); $result = $this->identityService->detectMatchForPerson($person); $this->assertNull($result); $this->assertDatabaseCount('person_identity_matches', 0); } // ────────────────────────────────────────────────────── // Resolution tests // ────────────────────────────────────────────────────── public function test_confirm_match_links_person_to_user(): void { $matchUser = User::factory()->create(['email' => 'confirm@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'confirm@example.nl', 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" ); $response->assertOk(); $person->refresh(); $match->refresh(); $this->assertEquals($matchUser->id, $person->user_id); $this->assertEquals(IdentityMatchStatus::CONFIRMED, $match->status); $this->assertEquals($this->orgAdmin->id, $match->resolved_by_user_id); $this->assertNotNull($match->resolved_at); } public function test_dismiss_match_keeps_person_unlinked(): void { $matchUser = User::factory()->create(['email' => 'dismiss@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'dismiss@example.nl', 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/dismiss" ); $response->assertOk(); $person->refresh(); $match->refresh(); $this->assertNull($person->user_id); $this->assertEquals(IdentityMatchStatus::DISMISSED, $match->status); $this->assertEquals($this->orgAdmin->id, $match->resolved_by_user_id); $this->assertNotNull($match->resolved_at); } public function test_dismissed_match_not_shown_in_index(): void { $matchUser = User::factory()->create(); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); PersonIdentityMatch::factory()->dismissed()->create([ 'person_id' => Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ])->id, 'matched_user_id' => User::factory()->create()->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches" ); $response->assertOk(); $this->assertCount(1, $response->json('data')); } public function test_confirm_already_confirmed_match_returns_error(): void { $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->confirmed()->create([ 'person_id' => $person->id, 'matched_user_id' => User::factory()->create()->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" ); $response->assertStatus(422); } public function test_dismiss_already_dismissed_match_returns_error(): void { $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->dismissed()->create([ 'person_id' => $person->id, 'matched_user_id' => User::factory()->create()->id, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/dismiss" ); $response->assertStatus(422); } public function test_bulk_confirm_multiple_matches(): void { $matches = []; for ($i = 0; $i < 3; $i++) { $matchUser = User::factory()->create(); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => "bulk{$i}@example.nl", 'user_id' => null, ]); $matches[] = PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); } Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/bulk-confirm", ['match_ids' => collect($matches)->pluck('id')->toArray()] ); $response->assertOk(); $this->assertEquals(3, $response->json('confirmed')); $this->assertEmpty($response->json('errors')); foreach ($matches as $match) { $match->refresh(); $this->assertEquals(IdentityMatchStatus::CONFIRMED, $match->status); } } public function test_bulk_confirm_skips_conflicts(): void { // Match 1: normal, should succeed $matchUser1 = User::factory()->create(); $person1 = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); $match1 = PersonIdentityMatch::factory()->create([ 'person_id' => $person1->id, 'matched_user_id' => $matchUser1->id, 'status' => IdentityMatchStatus::PENDING, ]); // Match 2: user already linked in same event → should error $matchUser2 = User::factory()->create(); Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => $matchUser2->id, ]); $person2 = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); $match2 = PersonIdentityMatch::factory()->create([ 'person_id' => $person2->id, 'matched_user_id' => $matchUser2->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/bulk-confirm", ['match_ids' => [$match1->id, $match2->id]] ); $response->assertOk(); $this->assertEquals(1, $response->json('confirmed')); $this->assertCount(1, $response->json('errors')); $this->assertEquals($match2->id, $response->json('errors.0.match_id')); } // ────────────────────────────────────────────────────── // Authorization tests // ────────────────────────────────────────────────────── public function test_cross_org_cannot_resolve_match(): void { $matchUser = User::factory()->create(); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->outsider); $response = $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" ); $response->assertForbidden(); } public function test_unauthenticated_cannot_access_matches(): void { $response = $this->getJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches" ); $response->assertUnauthorized(); } // ────────────────────────────────────────────────────── // Index/filtering tests // ────────────────────────────────────────────────────── public function test_index_returns_only_pending_matches_for_organisation(): void { $matchUser = User::factory()->create(); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); // Match from other organisation — should not appear $otherEvent = Event::factory()->create(['organisation_id' => $this->otherOrganisation->id]); $otherCrowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ 'organisation_id' => $this->otherOrganisation->id, ]); $otherPerson = Person::factory()->create([ 'event_id' => $otherEvent->id, 'crowd_type_id' => $otherCrowdType->id, 'user_id' => null, ]); PersonIdentityMatch::factory()->create([ 'person_id' => $otherPerson->id, 'matched_user_id' => User::factory()->create()->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches" ); $response->assertOk(); $this->assertCount(1, $response->json('data')); } public function test_index_paginates_results(): void { for ($i = 0; $i < 30; $i++) { $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'user_id' => null, ]); PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => User::factory()->create()->id, 'status' => IdentityMatchStatus::PENDING, ]); } Sanctum::actingAs($this->orgAdmin); $response = $this->getJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches" ); $response->assertOk(); $this->assertCount(25, $response->json('data')); $this->assertEquals(30, $response->json('meta.total')); } public function test_person_resource_includes_pending_match(): void { $matchUser = User::factory()->create(['email' => 'inline@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'inline@example.nl', 'user_id' => null, ]); PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); $response->assertOk(); $personData = collect($response->json('data')) ->firstWhere('email', 'inline@example.nl'); $this->assertNotNull($personData); $this->assertArrayHasKey('pending_identity_match', $personData); $this->assertEquals($matchUser->id, $personData['pending_identity_match']['matched_user']['id']); } public function test_person_resource_excludes_match_when_none_pending(): void { Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'nopending@example.nl', 'user_id' => null, ]); Sanctum::actingAs($this->orgAdmin); $response = $this->getJson("/api/v1/events/{$this->event->id}/persons"); $response->assertOk(); $personData = collect($response->json('data')) ->firstWhere('email', 'nopending@example.nl'); $this->assertNotNull($personData); $this->assertArrayNotHasKey('pending_identity_match', $personData); } // ────────────────────────────────────────────────────── // Activity log tests // ────────────────────────────────────────────────────── public function test_confirm_match_creates_activity_log(): void { $matchUser = User::factory()->create(['email' => 'audit@example.nl']); $person = Person::factory()->create([ 'event_id' => $this->event->id, 'crowd_type_id' => $this->crowdType->id, 'email' => 'audit@example.nl', 'user_id' => null, ]); $match = PersonIdentityMatch::factory()->create([ 'person_id' => $person->id, 'matched_user_id' => $matchUser->id, 'status' => IdentityMatchStatus::PENDING, ]); Sanctum::actingAs($this->orgAdmin); $this->postJson( "/api/v1/organisations/{$this->organisation->id}/identity-matches/{$match->id}/confirm" )->assertOk(); $this->assertDatabaseHas('activity_log', [ 'description' => 'person.identity.match_confirmed', 'causer_id' => $this->orgAdmin->id, 'subject_id' => $person->id, ]); } }