withoutVite(); Http::fake(); Mail::fake(); Queue::fake(); } private function organization(string $name): string { $id = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC '.$name, 'source_system' => 'unit-synthetic', 'organization_type' => 'private']); return $id; } private function member(User $user, string $organization, string $role = 'officer'): void { DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $organization, 'role' => $role]); } private function room(?string $proponentOrganization = null, ?string $candidateOrganization = null): array { $owner = User::factory()->create(['name' => 'SYNTHETIC original proponent']); $candidate = User::factory()->create(['name' => 'SYNTHETIC original candidate']); if ($proponentOrganization) { $this->member($owner, $proponentOrganization); } if ($candidateOrganization) { $this->member($candidate, $candidateOrganization); } $service = app(PartnershipExchange::class); $record = $service->save($owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC partnership', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'organization_id' => $proponentOrganization, 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true]]); $service->transition($owner, $record->id, $record->version, 'submit', 'SYNTHETIC consent to review'); $moderator = User::factory()->create(); config(['portal.managers' => [$moderator->email]]); $record = $service->find($record->id); $service->transition($moderator, $record->id, $record->version, 'publish', 'SYNTHETIC reviewed'); $interest = $service->interest($candidate, $record->id, ['organization_id' => $candidateOrganization, 'proposal' => 'SYNTHETIC proposal', 'payload' => ['consent' => true]]); $interest = $service->decideInterest($owner, $interest->id, $interest->version, 'shortlist', 'SYNTHETIC consideration'); $service->decideInterest($owner, $interest->id, $interest->version, 'connect', 'SYNTHETIC connection'); $room = DB::table('partnership_rooms')->where('interest_id', $interest->id)->first(); $this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 1, 'action' => 'record', 'title' => 'SYNTHETIC plan', 'terms' => 'SYNTHETIC terms', 'reason' => 'SYNTHETIC record'])->assertOk(); return [$room->id, $owner, $candidate]; } public function test_authorized_representatives_are_selectable_and_accepted_without_exposing_account_secrets(): void { $proponentOrganization = $this->organization('proponent'); $candidateOrganization = $this->organization('candidate'); [$room, $owner, $candidate] = $this->room($proponentOrganization, $candidateOrganization); $officer = User::factory()->create(['name' => 'SYNTHETIC current proponent officer']); $manager = User::factory()->create(['name' => 'SYNTHETIC current candidate manager']); $viewer = User::factory()->create(); $outsider = User::factory()->create(); $this->member($officer, $proponentOrganization); $this->member($manager, $candidateOrganization, 'manager'); $this->member($viewer, $proponentOrganization, 'viewer'); $otherOrganization = $this->organization('unrelated'); $this->member($outsider, $otherOrganization); $response = $this->actingAs($officer)->getJson('/parcerias/salas/'.$room)->assertOk(); $choices = collect($response->json('participants'))->keyBy('id'); $this->assertEqualsCanonicalizing([$owner->id, $candidate->id, $officer->id, $manager->id], $choices->keys()->all()); $this->assertSame(['proponent'], $choices[$officer->id]['parties']); $this->assertSame(['candidate'], $choices[$manager->id]['parties']); foreach ($choices as $choice) { $this->assertEqualsCanonicalizing(['id', 'name', 'parties'], array_keys($choice)); } $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC representative deliverable', 'responsible_id' => $manager->id, 'status' => 'pending'])->assertOk(); $this->assertDatabaseHas('partnership_milestones', ['room_id' => $room, 'responsible_id' => $manager->id]); $this->getJson('/parcerias/salas/'.$room)->assertJsonPath('milestones.0.responsible_name', $manager->name)->assertJsonPath('milestones.0.responsible_is_participant', true); } public function test_revoked_author_is_historical_but_not_silently_reassigned_or_selectable(): void { $organization = $this->organization('revocation'); [$room, $owner] = $this->room($organization); $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC assigned before revocation', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertOk(); $milestone = DB::table('partnership_milestones')->where('room_id', $room)->first(); $officer = User::factory()->create(['name' => 'SYNTHETIC replacement representative']); $this->member($officer, $organization); DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $organization)->update(['deleted_at' => now()]); $events = DB::table('partnership_events')->count(); $response = $this->actingAs($officer)->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('milestones.0.responsible_name', $owner->name)->assertJsonPath('milestones.0.responsible_is_participant', false); $this->assertNotContains($owner->id, array_column($response->json('participants'), 'id')); $this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $owner->id, 'version' => 1]); $this->assertSame($events, DB::table('partnership_events')->count()); $input = ['version' => 1, 'title' => $milestone->title, 'responsible_id' => $owner->id, 'status' => 'pending']; $this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, $input)->assertUnprocessable(); $this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, [...$input, 'responsible_id' => $officer->id])->assertOk(); $this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $officer->id, 'version' => 2]); $event = DB::table('partnership_events')->where('event_type', 'partnership_milestone.changed.v1')->orderByDesc('aggregate_version')->first(); $payload = json_decode($event->payload, true)['payload']; $this->assertSame($owner->id, $payload['previous_responsible_id']); $this->assertSame($officer->id, $payload['responsible_id']); $this->actingAs($owner)->getJson('/parcerias/salas/'.$room)->assertForbidden(); } public function test_personal_parties_are_not_expanded_by_unrelated_memberships_and_dual_representation_is_deduplicated(): void { [$room, $owner, $candidate] = $this->room(); $organization = $this->organization('unrelated personal membership'); $this->member($owner, $organization); $other = User::factory()->create(); $this->member($other, $organization); $response = $this->getJson('/parcerias/salas/'.$room)->assertOk(); $this->assertEqualsCanonicalizing([$owner->id, $candidate->id], array_column($response->json('participants'), 'id')); $this->actingAs($other)->getJson('/parcerias/salas/'.$room)->assertForbidden(); $a = $this->organization('dual a'); $b = $this->organization('dual b'); [$secondRoom] = $this->room($a, $b); $dual = User::factory()->create(); $this->member($dual, $a); $this->member($dual, $b); $choices = $this->actingAs($dual)->getJson('/parcerias/salas/'.$secondRoom)->assertOk()->json('participants'); $matching = array_values(array_filter($choices, fn ($choice) => $choice['id'] === $dual->id)); $this->assertCount(1, $matching); $this->assertSame(['proponent', 'candidate'], $matching[0]['parties']); } public function test_read_only_and_closed_rooms_do_not_offer_assignment_choices_and_unrelated_users_are_denied(): void { $organization = $this->organization('read only'); [$room, $owner] = $this->room($organization); $viewer = User::factory()->create(); $this->member($viewer, $organization, 'viewer'); $this->actingAs($viewer)->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('canWrite', false)->assertJsonPath('participants', []); $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC refused', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertForbidden(); $this->actingAs(User::factory()->create())->getJson('/parcerias/salas/'.$room)->assertForbidden(); $this->actingAs($owner)->postJson('/parcerias/salas/'.$room.'/acordo', ['version' => 2, 'action' => 'cancel', 'reason' => 'SYNTHETIC cancelled'])->assertOk(); $this->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('participants', []); $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC refused', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertConflict(); } public function test_cached_choices_do_not_authorize_assignment_after_downgrade_and_unrelated_ids_are_refused(): void { $organization = $this->organization('cached selection'); [$room, $owner, $candidate] = $this->room($organization); $officer = User::factory()->create(); $this->member($officer, $organization); $this->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonFragment(['id' => $officer->id, 'name' => $officer->name, 'parties' => ['proponent']]); DB::table('memberships')->where('user_id', $officer->id)->where('organization_id', $organization)->update(['role' => 'viewer']); $input = ['title' => 'SYNTHETIC rejected cached assignment', 'responsible_id' => $officer->id, 'status' => 'pending']; $events = DB::table('partnership_events')->count(); $this->postJson('/parcerias/salas/'.$room.'/marcos', $input)->assertUnprocessable(); $this->postJson('/parcerias/salas/'.$room.'/marcos', [...$input, 'responsible_id' => User::factory()->create()->id])->assertUnprocessable(); $this->assertDatabaseCount('partnership_milestones', 0); $this->assertSame($events, DB::table('partnership_events')->count()); $this->postJson('/parcerias/salas/'.$room.'/marcos', [...$input, 'responsible_id' => $candidate->id])->assertOk(); $milestone = DB::table('partnership_milestones')->where('room_id', $room)->first(); $this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, [...$input, 'responsible_id' => $owner->id, 'version' => 99])->assertConflict(); $this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $candidate->id, 'version' => 1]); } public function test_deleted_organizations_are_not_assignment_sources_and_personal_participant_remains_available(): void { $organization = $this->organization('deleted'); [$room, $owner, $candidate] = $this->room($organization); DB::table('organizations')->where('id', $organization)->update(['deleted_at' => now()]); $response = $this->actingAs($candidate)->getJson('/parcerias/salas/'.$room)->assertOk(); $this->assertSame([['id' => $candidate->id, 'name' => $candidate->name, 'parties' => ['candidate']]], $response->json('participants')); $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC unavailable organization', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertUnprocessable(); $this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC personal participant', 'responsible_id' => $candidate->id, 'status' => 'pending'])->assertOk(); $this->actingAs($owner)->getJson('/parcerias/salas/'.$room)->assertForbidden(); } }