withoutVite(); Http::fake(); Mail::fake(); Queue::fake(); $this->owner = User::factory()->create(); $this->candidate = User::factory()->create(); $this->moderator = User::factory()->create(); config(['portal.managers' => [$this->moderator->email]]); $service = app(PartnershipExchange::class); $record = $service->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC published title', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'payload' => ['consent' => true]]); $record = $service->transition($this->owner, $record->id, $record->version, 'submit', 'SYNTHETIC submit'); $this->record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish'); $this->interest = $service->interest($this->candidate, $record->id, ['client_key' => (string) Str::uuid(), 'proposal' => 'SYNTHETIC candidate', 'payload' => ['consent' => true]]); $this->actingAs($this->candidate); } private function panel(): TestResponse { $version = app(HandleInertiaRequests::class)->version(Request::create('/parcerias/painel')); return $this->get('/parcerias/painel', ['X-Inertia' => 'true', 'X-Inertia-Version' => $version ?? '']); } private function organization(User $person, string $role = 'officer'): string { $id = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC dashboard organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $person->id, 'organization_id' => $id, 'role' => $role]); return $id; } public function test_dashboard_does_not_disclose_current_title_after_unpublication(): void { DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'draft', 'title' => 'SYNTHETIC undisclosed private replacement']); $response = $this->panel()->assertOk()->assertJsonPath('props.interests.0.title', null)->assertJsonPath('props.interests.0.partnership_href', null); $this->assertStringNotContainsString('SYNTHETIC undisclosed private replacement', $response->getContent()); } public function test_deleted_organization_does_not_leave_its_drafts_in_the_panel(): void { $org = $this->organization($this->candidate); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org, 'status' => 'draft', 'title' => 'SYNTHETIC deleted organization draft']); DB::table('partnership_interests')->where('id', $this->interest->id)->update(['organization_id' => $org]); DB::table('organizations')->where('id', $org)->update(['deleted_at' => now()]); $response = $this->panel()->assertOk()->assertJsonCount(0, 'props.mine')->assertJsonCount(0, 'props.interests'); $this->assertStringNotContainsString('SYNTHETIC deleted organization draft', $response->getContent()); } public function test_revoked_candidate_does_not_read_or_acknowledge_old_notification(): void { $org = $this->organization($this->candidate); app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $org, 'proposal' => 'SYNTHETIC organizational candidate', 'payload' => ['consent' => true]]); app(PartnershipExchange::class)->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC clarification'); $notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->orderByDesc('id')->first(); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]); $response = $this->panel()->assertOk(); $ids = array_column($response->json('props.notifications'), 'id'); $this->assertNotContains($notice->id, $ids); $this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound(); $this->assertNull(DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at')); } public function test_current_parent_reader_or_moderator_keeps_the_authorized_proposal_link(): void { $this->panel()->assertOk()->assertJsonPath('props.interests.0.title', 'SYNTHETIC published title')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'draft']); $org = $this->organization($this->owner); DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'viewer']); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org]); $this->panel()->assertOk()->assertJsonPath('props.mine.0.title', 'SYNTHETIC published title')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id); config(['portal.managers' => [$this->candidate->email]]); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]); $this->panel()->assertOk()->assertJsonCount(0, 'props.mine')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id); } public function test_new_notices_follow_current_operational_participants_without_notifying_revoked_authors(): void { $proponentOrg = $this->organization($this->owner); $candidateOrg = $this->organization($this->candidate); $proponent = User::factory()->create(); $candidate = User::factory()->create(); $viewer = User::factory()->create(); DB::table('memberships')->insert([['user_id' => $proponent->id, 'organization_id' => $proponentOrg, 'role' => 'manager'], ['user_id' => $candidate->id, 'organization_id' => $candidateOrg, 'role' => 'officer'], ['user_id' => $viewer->id, 'organization_id' => $candidateOrg, 'role' => 'viewer']]); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $proponentOrg]); app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $candidateOrg, 'proposal' => 'SYNTHETIC organizational candidate', 'payload' => ['consent' => true]]); DB::table('memberships')->whereIn('user_id', [$this->owner->id, $this->candidate->id])->update(['deleted_at' => now()]); app(PartnershipExchange::class)->decideInterest($proponent, $this->interest->id, 2, 'clarify', 'SYNTHETIC current representatives'); $event = DB::table('partnership_events')->where('partnership_id', $this->record->id)->orderByDesc('aggregate_version')->first(); $recipients = DB::table('partnership_notifications')->where('event_id', $event->id)->pluck('user_id')->all(); sort($recipients); $expected = [$proponent->id, $candidate->id]; sort($expected); $this->assertSame($expected, $recipients); $payload = json_decode($event->payload, true)['payload']['_notification_context']; $this->assertSame($proponentOrg, $payload['partnership']['organization_id']); $this->assertSame($candidateOrg, $payload['interest']['organization_id']); $this->actingAs($candidate); $this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico'); $this->actingAs($viewer); $this->panel()->assertOk()->assertJsonCount(0, 'props.notifications'); } public function test_change_to_another_organization_does_not_transfer_old_notices_to_the_new_representation(): void { $orgA = $this->organization($this->candidate); $representative = User::factory()->create(); DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgA, 'role' => 'officer']); $service = app(PartnershipExchange::class); $service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $orgA, 'proposal' => 'SYNTHETIC organization A', 'payload' => ['consent' => true]]); $service->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC A clarification'); $old = DB::table('partnership_notifications')->where('user_id', $representative->id)->orderByDesc('id')->first(); $orgB = $this->organization($this->candidate); DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgB, 'role' => 'officer']); $service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 3, 'organization_id' => $orgB, 'proposal' => 'SYNTHETIC organization B', 'payload' => ['consent' => true]]); DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $representative->id)->update(['deleted_at' => now()]); $this->actingAs($representative); $response = $this->panel()->assertOk()->assertJsonCount(1, 'props.notifications'); $this->assertNotContains($old->id, array_column($response->json('props.notifications'), 'id')); $this->post('/parcerias/notificacoes/'.$old->id.'/ler')->assertNotFound(); } public function test_historical_scope_is_required_even_when_current_proponent_organization_is_authorized(): void { $orgA = $this->organization($this->owner); $representative = User::factory()->create(); DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgA, 'role' => 'officer']); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $orgA]); $service = app(PartnershipExchange::class); $service->event($service->find($this->record->id), $this->owner, 'partnership_opportunity.changed.v1', [], 'SYNTHETIC A private notice', [$this->owner->id]); $old = DB::table('partnership_notifications')->where('user_id', $representative->id)->orderByDesc('id')->first(); $orgB = $this->organization($representative); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $orgB]); DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $representative->id)->update(['deleted_at' => now()]); $this->actingAs($representative); $this->panel()->assertOk()->assertJsonCount(0, 'props.notifications'); $this->post('/parcerias/notificacoes/'.$old->id.'/ler')->assertNotFound(); } public function test_legacy_candidate_snapshot_is_reused_without_inventing_missing_proponent_scope(): void { $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first(); $payload = json_decode($event->payload, true); unset($payload['payload']['_notification_context']); DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]); $this->panel()->assertOk()->assertJsonCount(1, 'props.notifications')->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico'); $this->actingAs($this->owner); $response = $this->panel()->assertOk(); $candidateNotice = DB::table('partnership_notifications')->where('event_id', $event->id)->where('user_id', $this->owner->id)->first(); $this->assertNotContains($candidateNotice->id, array_column($response->json('props.notifications'), 'id')); unset($payload['payload']['submitted']['organization_id']); DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]); $this->actingAs($this->candidate); $this->panel()->assertOk()->assertJsonCount(0, 'props.notifications'); $this->assertSame(2, DB::table('partnership_notifications')->where('event_id', $event->id)->count()); } public function test_inaccessible_rows_do_not_consume_the_visible_limit_or_hide_an_older_authorized_notice(): void { $record = app(PartnershipExchange::class)->find($this->record->id); $sequence = DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version'); $events = []; $notices = []; for ($i = 1; $i <= 105; $i++) { $event = (string) Str::uuid(); $events[] = ['id' => $event, 'partnership_id' => $record->id, 'actor_id' => $this->owner->id, 'event_type' => 'partnership_opportunity.changed.v1', 'aggregate_version' => $sequence + $i, 'payload' => json_encode(['payload' => ['_notification_context' => ['version' => 1, 'partnership' => ['id' => $record->id, 'user_id' => $this->owner->id, 'organization_id' => null], 'interest' => null, 'room_id' => null]]])]; $notices[] = ['event_id' => $event, 'user_id' => $this->candidate->id, 'title' => 'SYNTHETIC inaccessible title', 'href' => 'javascript:synthetic()']; } DB::table('partnership_events')->insert($events); DB::table('partnership_notifications')->insert($notices); $response = $this->panel()->assertOk()->assertJsonCount(1, 'props.notifications'); $this->assertStringNotContainsString('SYNTHETIC inaccessible title', $response->getContent()); $this->assertStringNotContainsString('javascript:', $response->getContent()); } public function test_notification_link_is_rebuilt_and_marking_read_is_idempotent_without_business_events(): void { $notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->first(); DB::table('partnership_notifications')->where('id', $notice->id)->update(['href' => 'javascript:synthetic()']); $events = DB::table('partnership_events')->count(); $audits = DB::table('audit_events')->count(); $this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico'); $this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect(); $first = DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at'); $this->assertNotNull($first); $this->travel(5)->minutes(); $this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect(); $this->assertSame($first, DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at')); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($audits, DB::table('audit_events')->count()); $this->actingAs($this->owner)->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound(); auth()->forgetGuards(); $this->postJson('/parcerias/notificacoes/'.$notice->id.'/ler')->assertUnauthorized(); } public function test_room_notices_link_to_the_current_authorized_room_and_message_replay_does_not_duplicate_delivery(): void { $service = app(PartnershipExchange::class); $service->decideInterest($this->owner, $this->interest->id, 1, 'shortlist', 'SYNTHETIC shortlist'); $service->decideInterest($this->owner, $this->interest->id, 2, 'connect', 'SYNTHETIC connect'); $room = DB::table('partnership_rooms')->where('interest_id', $this->interest->id)->first(); $data = ['client_key' => (string) Str::uuid(), 'body' => 'SYNTHETIC message']; $this->postJson('/parcerias/salas/'.$room->id.'/mensagens', $data)->assertOk(); $count = DB::table('partnership_notifications')->count(); $this->postJson('/parcerias/salas/'.$room->id.'/mensagens', $data)->assertOk(); $this->assertSame($count, DB::table('partnership_notifications')->count()); $this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/salas/'.$room->id); } public function test_malformed_explicit_context_does_not_fall_back_to_a_legacy_snapshot(): void { $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first(); $payload = json_decode($event->payload, true); $notice = DB::table('partnership_notifications')->where('event_id', $event->id)->where('user_id', $this->candidate->id)->first(); foreach ([null, ['version' => 99], ['version' => 1, 'partnership' => ['id' => $this->record->id + 999, 'user_id' => $this->owner->id, 'organization_id' => null]]] as $invalid) { $payload['payload']['_notification_context'] = $invalid; DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]); $this->panel()->assertOk()->assertJsonCount(0, 'props.notifications'); $this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound(); } } public function test_a_read_only_representative_keeps_reading_an_existing_notice_without_receiving_new_operational_delivery(): void { $org = $this->organization($this->candidate); $service = app(PartnershipExchange::class); $service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $org, 'proposal' => 'SYNTHETIC organization', 'payload' => ['consent' => true]]); $notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->orderByDesc('id')->first(); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['role' => 'viewer']); $this->panel()->assertOk()->assertJsonPath('props.notifications.0.id', $notice->id); $this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect(); $service->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC later decision'); $this->assertSame(2, DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->count()); $this->postJson('/parcerias/candidaturas/'.$this->interest->id, ['version' => 3, 'action' => 'withdraw', 'reason' => 'SYNTHETIC unauthorized write'])->assertForbidden(); } public function test_visible_limit_keeps_the_latest_fifty_authorized_notifications_in_order(): void { $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first(); $payload = $event->payload; $sequence = DB::table('partnership_events')->where('partnership_id', $this->record->id)->max('aggregate_version'); $events = []; $notices = []; for ($i = 1; $i <= 55; $i++) { $id = (string) Str::uuid(); $events[] = ['id' => $id, 'partnership_id' => $this->record->id, 'actor_id' => $this->candidate->id, 'event_type' => 'partnership_interest.submitted.v1', 'aggregate_version' => $sequence + $i, 'payload' => $payload]; $notices[] = ['event_id' => $id, 'user_id' => $this->candidate->id, 'title' => 'SYNTHETIC notice '.$i, 'href' => '/unused']; } DB::table('partnership_events')->insert($events); DB::table('partnership_notifications')->insert($notices); $response = $this->panel()->assertOk()->assertJsonCount(50, 'props.notifications')->assertJsonPath('props.notifications.0.title', 'SYNTHETIC notice 55')->assertJsonPath('props.notifications.49.title', 'SYNTHETIC notice 6'); $ids = array_column($response->json('props.notifications'), 'id'); $sorted = $ids; rsort($sorted); $this->assertSame($sorted, $ids); $this->assertCount(50, array_unique($ids)); } public function test_an_inconsistent_event_context_rolls_back_without_cross_partnership_delivery(): void { $service = app(PartnershipExchange::class); $other = $service->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC other partnership', 'kind' => 'seeking']); $events = DB::table('partnership_events')->count(); $notices = DB::table('partnership_notifications')->count(); $audits = DB::table('audit_events')->count(); try { $service->event($other, $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id], 'SYNTHETIC inconsistent context', [$this->candidate->id]); $this->fail('A foreign candidacy must not produce a notification.'); } catch (\LogicException $error) { $this->assertSame('Notification belongs to another partnership.', $error->getMessage()); } $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($notices, DB::table('partnership_notifications')->count()); $this->assertSame($audits, DB::table('audit_events')->count()); } public function test_events_without_a_notification_intent_remain_without_delivery_and_shared_parties_are_deduplicated(): void { $service = app(PartnershipExchange::class); $before = DB::table('partnership_notifications')->count(); $service->event($service->find($this->record->id), $this->owner, 'partnership.document_accessed.v1', [], 'SYNTHETIC read only'); $this->assertSame($before, DB::table('partnership_notifications')->count()); $org = $this->organization($this->owner); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org]); DB::table('partnership_interests')->where('id', $this->interest->id)->update(['organization_id' => $org]); $service->event($service->find($this->record->id), $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id], 'SYNTHETIC same party', [$this->owner->id, $this->candidate->id]); $event = DB::table('partnership_events')->where('partnership_id', $this->record->id)->orderByDesc('aggregate_version')->first(); $this->assertSame([$this->owner->id], DB::table('partnership_notifications')->where('event_id',$event->id)->pluck('user_id')->all()); } }