withoutVite(); Http::fake(); Mail::fake(); Queue::fake(); $this->owner = User::factory()->create(); $this->candidate = User::factory()->create(); $this->record = $this->published($this->owner); $this->actingAs($this->candidate); } private function published(User $owner): object { $service = app(PartnershipExchange::class); $record = $service->save($owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC candidature context', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true, 'mandatory' => ['capacity']]]); $record = $service->transition($owner, $record->id, $record->version, 'submit', 'SYNTHETIC submission'); $moderator = User::factory()->create(); config(['portal.managers' => [$moderator->email]]); return $service->transition($moderator, $record->id, $record->version, 'publish', 'SYNTHETIC independent review'); } private function input(array $changes = []): array { return ['proposal' => 'SYNTHETIC original proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC original capacity', 'availability' => 'SYNTHETIC original availability', 'contribution' => 'SYNTHETIC original contribution', 'conflicts' => 'SYNTHETIC declared conflicts'], ...$changes]; } private function url(): string { return '/parcerias/'.$this->record->id.'/interesses'; } private function interest(array $changes = [], ?User $actor = null): object { return app(PartnershipExchange::class)->interest($actor ?? $this->candidate, $this->record->id, $this->input($changes)); } private function organization(User $user, string $role = 'officer'): string { $id = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC candidacy organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $id, 'role' => $role]); return $id; } public function test_omitted_declarations_and_organization_survive_a_partial_resubmission(): void { $org = $this->organization($this->candidate); $interest = $this->interest(['organization_id' => $org]); $this->postJson($this->url(), ['interest_id' => $interest->id, 'version' => $interest->version, 'proposal' => 'SYNTHETIC changed proposal', 'payload' => ['consent' => true]])->assertOk(); $saved = DB::table('partnership_interests')->where('id', $interest->id)->first(); $this->assertSame($org, $saved->organization_id); $payload = json_decode($saved->payload, true); $this->assertSame('SYNTHETIC original capacity', $payload['capacity']); $this->assertSame('SYNTHETIC original availability', $payload['availability']); $this->assertSame('SYNTHETIC declared conflicts', $payload['conflicts']); } public function test_explicit_candidacy_identity_updates_the_selected_withdrawn_organization_record(): void { $personal = $this->interest(); $otherAuthor = User::factory()->create(); $org = $this->organization($otherAuthor); DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'officer']); $organizational = $this->interest(['organization_id' => $org], $otherAuthor); $organizational = app(PartnershipExchange::class)->decideInterest($otherAuthor, $organizational->id, $organizational->version, 'withdraw', 'SYNTHETIC withdrawal'); DB::table('partnership_interests')->where('id', $personal->id)->update(['version' => $organizational->version]); $this->postJson($this->url(), $this->input(['interest_id' => $organizational->id, 'version' => $organizational->version, 'organization_id' => $org, 'proposal' => 'SYNTHETIC intended organization update']))->assertOk()->assertJsonPath('id', $organizational->id); $this->assertDatabaseHas('partnership_interests', ['id' => $personal->id, 'organization_id' => null, 'proposal' => 'SYNTHETIC original proposal']); $this->assertDatabaseHas('partnership_interests', ['id' => $organizational->id, 'status' => 'submitted', 'proposal' => 'SYNTHETIC intended organization update']); } public function test_read_only_representative_can_consult_but_has_no_candidacy_write_actions(): void { $org = $this->organization($this->candidate); $interest = $this->interest(['organization_id' => $org]); $viewer = User::factory()->create(); DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $org, 'role' => 'viewer']); $this->actingAs($viewer)->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('interests.0.can_edit', false); $this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 1, 'organization_id' => $org]))->assertForbidden(); } public function test_owning_actor_can_recover_a_committed_submission_without_resetting_assessment_or_version(): void { $input = $this->input(['client_key' => (string) Str::uuid()]); $interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input); $assessed = app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 1, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence']]); $before = DB::table('partnership_events')->count(); $this->postJson($this->url(), $input)->assertOk()->assertJsonPath('id', $interest->id)->assertJsonPath('version', $assessed->version)->assertJsonPath('replayed', true); $this->assertSame($before, DB::table('partnership_events')->count()); $this->assertSame('met', json_decode(DB::table('partnership_interests')->where('id', $interest->id)->value('assessment'), true)['capacity']['state']); } public function test_explicit_clearing_creates_a_new_submission_and_preserves_the_previous_evaluation_in_the_event(): void { $org = $this->organization($this->candidate); $interest = $this->interest(['organization_id' => $org]); $interest = app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 1, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC prior evidence']]); $input = $this->input(['client_key' => (string) Str::uuid(), 'interest_id' => $interest->id, 'version' => 2, 'organization_id' => null, 'payload' => ['consent' => true, 'capacity' => '', 'conflicts' => null]]); $this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 3); $saved = DB::table('partnership_interests')->where('id', $interest->id)->first(); $this->assertNull($saved->organization_id); $this->assertEmpty(json_decode($saved->assessment, true)); $this->assertNull(json_decode($saved->payload, true)['capacity']); $this->assertNull(json_decode($saved->payload, true)['conflicts']); $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->orderByDesc('aggregate_version')->first(); $payload = json_decode($event->payload, true)['payload']; $this->assertSame(2, $payload['previous']['version']); $this->assertSame($org, $payload['previous']['organization_id']); $this->assertSame('SYNTHETIC prior evidence', $payload['previous']['assessment']['capacity']['evidence']); $this->assertSame('SYNTHETIC original capacity', $payload['previous']['payload']['capacity']); $this->assertSame(3, $payload['submitted']['version']); } public function test_ambiguous_legacy_submission_and_a_foreign_candidacy_cannot_change_another_record(): void { $personal = $this->interest(); $other = User::factory()->create(); $org = $this->organization($other); DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'officer']); $organizational = $this->interest(['organization_id' => $org], $other); $this->postJson($this->url(), $this->input(['version' => 1, 'organization_id' => $org]))->assertConflict(); $different = $this->published(User::factory()->create()); $foreign = app(PartnershipExchange::class)->interest($this->candidate, $different->id, $this->input()); $this->postJson($this->url(), $this->input(['interest_id' => $foreign->id, 'version' => 1]))->assertNotFound(); $this->assertDatabaseHas('partnership_interests', ['id' => $personal->id, 'organization_id' => null, 'version' => 1]); $this->assertDatabaseHas('partnership_interests', ['id' => $organizational->id, 'organization_id' => $org, 'version' => 1]); } public function test_replay_is_content_bound_case_normalized_and_never_grants_revoked_representation(): void { $org = $this->organization($this->candidate); $input = $this->input(['organization_id' => $org, 'client_key' => (string) Str::uuid()]); $interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input); $this->postJson($this->url(), [...$input, 'client_key' => strtoupper($input['client_key'])])->assertOk()->assertJsonPath('replayed', true); $this->postJson($this->url(), [...$input, 'proposal' => 'SYNTHETIC different content'])->assertConflict(); DB::table('memberships')->where('organization_id', $org)->update(['deleted_at' => now()]); $this->postJson($this->url(), $input)->assertForbidden(); $this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('canStartInterest', false)->assertJsonCount(0, 'interests'); $this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'version' => 1]); } public function test_submission_and_decision_reject_stale_partnership_context_without_writes(): void { $interest = $this->interest(); $before = DB::table('partnership_events')->count(); $this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 1, 'partnership_version' => $this->record->version - 1]))->assertConflict(); $this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'partnership_version' => $this->record->version - 1, 'action' => 'assess', 'reason' => 'SYNTHETIC stale context', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC stale evidence']]])->assertConflict(); $this->assertSame($before, DB::table('partnership_events')->count()); $this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'version' => 1, 'assessment' => '{}']); } public function test_matching_weights_are_checked_and_legacy_decisions_keep_their_existing_path(): void { $interest = $this->interest(); $hash = app(PartnershipMatching::class)->explain($this->record, $interest)['weights_hash']; config(['partnerships.weights.capacity' => 21]); $this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'partnership_version' => $this->record->version, 'weights_hash' => $hash, 'action' => 'assess', 'reason' => 'SYNTHETIC old weights', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence']]])->assertConflict(); $this->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'action' => 'assess', 'reason' => 'SYNTHETIC legacy client', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence', 'irrelevant' => 'Must not persist']]])->assertOk(); $stored = json_decode(DB::table('partnership_interests')->where('id', $interest->id)->value('assessment'), true)['capacity']; $this->assertArrayNotHasKey('irrelevant', $stored); $this->assertSame($this->owner->id, $stored['assessed_by']); $event = DB::table('partnership_events')->where('event_type', 'partnership_interest.changed.v1')->orderByDesc('aggregate_version')->first(); $payload = json_decode($event->payload, true)['payload']; $this->assertSame($this->record->version, $payload['partnership_version']); $this->assertNotSame($hash, $payload['matching_context']['weights_hash']); $this->assertSame('transparent-rules.v1', $payload['matching_context']['algorithm_version']); $capacity = array_values(array_filter($payload['matching_context']['criteria'], fn ($criterion) => $criterion['code'] === 'capacity'))[0]; $this->assertSame(21, $capacity['weight']); $this->assertTrue($capacity['mandatory']); } public function test_replaying_a_decision_does_not_repeat_events_or_affect_another_candidacy(): void { $interest = $this->interest(); $other = $this->interest([], User::factory()->create()); $input = ['client_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'shortlist', 'reason' => 'SYNTHETIC shortlist unknown evidence']; $this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, $input)->assertOk(); $events = DB::table('partnership_events')->count(); $this->postJson('/parcerias/candidaturas/'.$interest->id, $input)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('version', 2); $this->postJson('/parcerias/candidaturas/'.$other->id, $input)->assertConflict(); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertDatabaseHas('partnership_interests', ['id' => $other->id, 'version' => 1, 'status' => 'submitted']); $this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('interests.0.commands.0.key', $input['client_key']); $this->actingAs($this->candidate)->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonCount(0, 'interests.0.commands'); } public function test_withdrawal_and_read_only_recovery_do_not_depend_on_a_current_publication_window(): void { $input = $this->input(['client_key' => (string) Str::uuid()]); $interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'archived', 'version' => $this->record->version + 1]); $withdraw = ['client_key' => (string) Str::uuid(), 'version' => 1, 'partnership_version' => 1, 'weights_hash' => str_repeat('0', 64), 'action' => 'withdraw', 'reason' => 'SYNTHETIC withdrawal independent of publication']; $this->postJson('/parcerias/candidaturas/'.$interest->id, $withdraw)->assertOk(); $this->postJson($this->url(), $input)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('version', 2); $this->postJson('/parcerias/candidaturas/'.$interest->id, $withdraw)->assertOk()->assertJsonPath('replayed', true); $this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 2]))->assertConflict(); $this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'status' => 'withdrawn', 'version' => 2]); } public function test_explicit_update_replay_preserves_later_decisions_and_requires_accepted_consent(): void { $interest = $this->interest(); $input = $this->input(['interest_id' => $interest->id, 'version' => 1, 'client_key' => (string) Str::uuid()]); $this->postJson($this->url(), [...$input, 'payload' => ['consent' => false]])->assertUnprocessable(); $this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 2); app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 2, 'shortlist', 'SYNTHETIC shortlist after update'); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 3)->assertJsonPath('replayed', true); $this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'status' => 'shortlisted', 'version' => 3]); $this->assertSame($events, DB::table('partnership_events')->count()); } public function test_organization_choices_keep_distinct_entities_with_equal_names(): void { $first = $this->organization($this->candidate); $second = $this->organization($this->candidate); $choices = $this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonCount(2, 'organizations')->json('organizations'); $this->assertEqualsCanonicalizing([$first, $second], array_column($choices, 'id')); $this->assertSame($choices[0]['name'], $choices[1]['name']); } }