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 private history context', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true, 'mandatory' => ['capacity']]]); $record = $service->transition($this->owner, $record->id, $record->version, 'submit', 'SYNTHETIC submission'); $this->record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC review'); $this->interest = $service->interest($this->candidate, $record->id, ['client_key' => (string) Str::uuid(), 'proposal' => 'SYNTHETIC original proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC original capacity']]); $this->actingAs($this->candidate); } private function url(?int $id = null): string { return '/parcerias/candidaturas/'.($id ?? $this->interest->id).'/historico'; } private function assess(int $version, string $evidence = 'SYNTHETIC preserved evidence'): object { return app(PartnershipExchange::class)->decideInterest($this->owner, $this->interest->id, $version, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => $evidence]]); } private function organization(User $person, string $role = 'officer'): string { $id = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC history organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $person->id, 'organization_id' => $id, 'role' => $role]); return $id; } public function test_history_recovers_real_versions_and_frozen_matching_without_recalculation(): void { $this->assess(1); app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 2, 'proposal' => 'SYNTHETIC revised proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC revised capacity']]); config(['partnerships.weights.capacity' => 99, 'partnerships.criteria.capacity' => 'SYNTHETIC renamed current label']); $response = $this->getJson($this->url())->assertOk()->assertJsonCount(3, 'entries')->assertJsonPath('entries.0.version', 3)->assertJsonPath('entries.0.after.fields.proposal', 'SYNTHETIC revised proposal')->assertJsonPath('entries.0.previous.fields.capacity', 'SYNTHETIC original capacity')->assertJsonPath('entries.1.after.fields.proposal', 'SYNTHETIC original proposal')->assertJsonPath('entries.1.after.assessment.0.evidence', 'SYNTHETIC preserved evidence')->assertJsonPath('entries.2.previous.availability', 'not_recorded'); $criteria = collect($response->json('entries.1.matching.criteria'))->keyBy('code'); $this->assertSame(20, $criteria['capacity']['weight']); $this->assertSame('Capacidade técnica/produtiva', $criteria['capacity']['label']); $this->assertTrue($criteria['capacity']['mandatory']); $this->assertStringNotContainsString('client_key', $response->getContent()); $this->assertStringNotContainsString('command_hash', $response->getContent()); } public function test_anonymous_competitors_and_moderation_do_not_receive_another_candidacy_history(): void { $other = User::factory()->create(); $otherInterest = app(PartnershipExchange::class)->interest($other, $this->record->id, ['proposal' => 'SYNTHETIC other candidature', 'payload' => ['consent' => true]]); $this->getJson($this->url($otherInterest->id))->assertForbidden(); $this->actingAs($this->moderator)->getJson($this->url())->assertForbidden(); $this->actingAs($other)->getJson($this->url())->assertForbidden(); $this->getJson($this->url($otherInterest->id))->assertOk()->assertJsonCount(1, 'entries'); $this->actingAs($this->owner)->getJson($this->url())->assertOk(); auth()->forgetGuards(); $this->app['auth']->forgetGuards(); $this->getJson($this->url())->assertUnauthorized(); } public function test_read_only_organization_members_can_read_and_revoked_authors_cannot(): 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 version', 'payload' => ['consent' => true]]); $viewer = User::factory()->create(); DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $org, 'role' => 'viewer']); $this->actingAs($viewer)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.availability', 'available')->assertJsonPath('entries.0.previous.availability', 'restricted')->assertJsonPath('entries.1.restricted', true); DB::table('memberships')->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]); $this->actingAs($this->candidate)->getJson($this->url())->assertForbidden(); $this->actingAs($viewer)->getJson($this->url())->assertOk(); } public function test_new_organization_does_not_gain_old_organization_snapshots_or_assessment(): void { $orgA = $this->organization($this->candidate); $orgB = $this->organization($this->candidate); app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $orgA, 'proposal' => 'SYNTHETIC A-only proposal', 'payload' => ['consent' => true]]); $this->assess(2, 'SYNTHETIC A-only evidence'); app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 3, 'organization_id' => $orgB, 'proposal' => 'SYNTHETIC B submission', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC B capacity']]); $viewer = User::factory()->create(); DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $orgB, 'role' => 'viewer']); $response = $this->actingAs($viewer)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.fields.proposal', 'SYNTHETIC B submission')->assertJsonPath('entries.0.previous.availability', 'restricted')->assertJsonPath('entries.1.restricted', true)->assertJsonPath('entries.1.actor_name', null)->assertJsonPath('entries.1.matching', null); $this->assertStringNotContainsString('SYNTHETIC A-only', $response->getContent()); $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.fields.proposal', 'SYNTHETIC A-only proposal'); DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]); $this->actingAs($this->candidate)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.availability', 'restricted'); } public function test_legacy_partial_events_explain_missing_snapshots_without_copying_current_declarations(): void { DB::table('partnership_interests')->where('id', $this->interest->id)->update(['proposal' => 'SYNTHETIC current-only declaration', 'version' => 2, 'status' => 'clarification']); app(PartnershipExchange::class)->event($this->record, $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id, 'interest_version' => 2, 'from' => 'submitted', 'to' => 'clarification', 'reason' => 'SYNTHETIC legacy reason', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC legacy evidence']]], 'SYNTHETIC legacy fixture'); $response = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.action', null)->assertJsonPath('entries.0.previous.availability', 'not_recorded')->assertJsonPath('entries.0.after.complete', false)->assertJsonPath('entries.0.after.status', 'clarification')->assertJsonPath('entries.0.matching', null); $this->assertArrayNotHasKey('proposal', $response->json('entries.0.after.fields')); $this->assertStringNotContainsString('SYNTHETIC current-only', $response->getContent()); $this->actingAs($this->candidate)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.restricted', true)->assertJsonPath('entries.0.after.availability', 'scope_unknown'); } public function test_pagination_is_stable_when_new_decisions_arrive_and_never_mixes_candidacies(): void { for ($version = 1; $version <= 21; $version++) { $this->assess($version); } $other = app(PartnershipExchange::class)->interest(User::factory()->create(), $this->record->id, ['proposal' => 'SYNTHETIC unrelated timeline', 'payload' => ['consent' => true]]); $first = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonCount(10, 'entries'); $versions = array_column($first->json('entries'), 'version'); $this->assertSame(range(22, 13), $versions); $this->assess(22); $second = $this->getJson($first->json('next_url'))->assertOk()->assertJsonCount(10, 'entries'); $third = $this->getJson($second->json('next_url'))->assertOk()->assertJsonCount(2, 'entries')->assertJsonPath('next_url', null); $this->assertSame(range(22, 1), [...$versions, ...array_column($second->json('entries'), 'version'), ...array_column($third->json('entries'), 'version')]); $this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.version', 23); $this->getJson($this->url($other->id))->assertOk()->assertJsonCount(1, 'entries'); } public function test_cursors_are_bound_to_actor_and_candidacy_and_checked_after_current_access(): void { for ($version = 1; $version <= 10; $version++) { $this->assess($version); } $cursor = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->json('next_url'); $this->actingAs($this->candidate)->getJson($cursor)->assertUnprocessable(); $other = app(PartnershipExchange::class)->interest(User::factory()->create(), $this->record->id, ['proposal' => 'SYNTHETIC other', 'payload' => ['consent' => true]]); $query = parse_url($cursor, PHP_URL_QUERY); $this->actingAs($this->owner)->getJson($this->url($other->id).'?'.$query)->assertUnprocessable(); $this->getJson($this->url().'?before=invalid-token')->assertUnprocessable(); $this->getJson($cursor)->assertOk(); } public function test_private_history_remains_readable_after_archiving_without_exposing_a_new_draft(): void { DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'archived', 'title' => 'SYNTHETIC undisclosed revised title', 'offer' => 'SYNTHETIC private new draft']); $response = $this->getJson($this->url())->assertOk()->assertJsonPath('partnership_href', null)->assertJsonPath('dashboard_href', '/parcerias/painel'); $this->assertStringNotContainsString('SYNTHETIC undisclosed', $response->getContent()); $this->assertStringNotContainsString('SYNTHETIC private new draft', $response->getContent()); $this->getJson('/parcerias/'.$this->record->id)->assertNotFound(); $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('partnership_href', '/parcerias/'.$this->record->id); } public function test_consultation_is_read_only_private_and_uses_the_native_history_page(): void { $this->assess(1); $row = DB::table('partnership_interests')->where('id', $this->interest->id)->first(); $events = DB::table('partnership_events')->count(); $audits = DB::table('audit_events')->count(); $notifications = DB::table('partnership_notifications')->count(); $version = app(HandleInertiaRequests::class)->version(Request::create($this->url())); $response = $this->get($this->url(), ['X-Inertia' => 'true', 'X-Inertia-Version' => $version ?? ''])->assertOk()->assertJsonPath('component', 'partnerships/interest-history')->assertJsonPath('encryptHistory', true); $this->assertTrue($response->headers->hasCacheControlDirective('private')); $this->assertTrue($response->headers->hasCacheControlDirective('no-store')); $this->assertEquals($row, DB::table('partnership_interests')->where('id', $this->interest->id)->first()); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($audits, DB::table('audit_events')->count()); $this->assertSame($notifications, DB::table('partnership_notifications')->count()); } public function test_missing_or_malformed_matching_parameters_stay_unknown_and_never_become_zero(): void { $this->assess(1); $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.changed.v1')->first(); $data = json_decode($event->payload, true); $data['payload']['matching_context']['score_min'] = ['invalid']; $data['payload']['matching_context']['score_max'] = null; $data['payload']['matching_context']['criteria'][0]['weight'] = ['invalid']; DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($data)]); $this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.matching.score_min', null)->assertJsonPath('entries.0.matching.score_max', null)->assertJsonPath('entries.0.matching.criteria.0.weight', null); } }