withoutVite(); Http::fake(); Mail::fake(); Queue::fake(); $this->owner = User::factory()->create(); $this->candidate = User::factory()->create(); $moderator = User::factory()->create(); config(['portal.managers' => [$moderator->email]]); $service = app(PartnershipExchange::class); $record = $service->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC agreement', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'payload' => ['consent' => true]]); $record = $service->transition($this->owner, $record->id, $record->version, 'submit', 'SYNTHETIC submit'); $record = $service->transition($moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish'); $interest = $service->interest($this->candidate, $record->id, ['proposal' => 'SYNTHETIC candidacy', 'payload' => ['consent' => true]]); $interest = $service->decideInterest($this->owner, $interest->id, $interest->version, 'shortlist', 'SYNTHETIC shortlist'); $service->decideInterest($this->owner, $interest->id, $interest->version, 'connect', 'SYNTHETIC connect'); $this->roomId = (int) DB::table('partnership_rooms')->where('interest_id', $interest->id)->value('id'); $this->actingAs($this->owner)->postJson($this->url(), ['version' => 1, 'action' => 'record', 'title' => 'SYNTHETIC instrument', 'terms' => 'SYNTHETIC terms', 'reason' => 'SYNTHETIC record'])->assertOk(); } private function url(): string { return '/parcerias/salas/'.$this->roomId.'/acordo'; } private function command(string $action = 'cancel'): array { $context = app(PartnershipExchange::class)->room($this->owner, $this->roomId); return ['client_key' => (string) Str::uuid(), 'version' => $context['room']->version, 'partnership_version' => $context['partnership']->version, 'interest_version' => $context['interest']->version, 'action' => $action, 'reason' => 'SYNTHETIC documented action']; } public function test_cancel_replay_after_terminal_state_is_a_read_without_repeated_events(): void { $data = $this->command(); $this->postJson($this->url(), $data)->assertOk(); $events = DB::table('partnership_events')->count(); $audits = DB::table('audit_events')->count(); $notices = DB::table('partnership_notifications')->count(); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('status', 'cancelled'); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($audits, DB::table('audit_events')->count()); $this->assertSame($notices, DB::table('partnership_notifications')->count()); } public function test_changed_proposal_context_requires_review_even_when_room_version_is_unchanged(): void { $data = $this->command('confirm'); $context = app(PartnershipExchange::class)->room($this->owner, $this->roomId); $record = $context['partnership']; app(PartnershipExchange::class)->save($this->owner, ['version' => $record->version, 'kind' => 'seeking', 'title' => 'SYNTHETIC changed proposal'], $record->id); $this->postJson($this->url(), $data)->assertConflict(); $this->assertSame(2, DB::table('partnership_rooms')->where('id', $this->roomId)->value('version')); } private function instrument(): array { return json_decode(DB::table('partnership_rooms')->where('id', $this->roomId)->value('agreement'), true); } private function confirmBoth(): void { $this->actingAs($this->owner)->postJson($this->url(), $this->command('confirm'))->assertOk(); $this->actingAs($this->candidate)->postJson($this->url(), $this->command('confirm'))->assertOk()->assertJsonPath('status', 'agreed'); $this->actingAs($this->owner); } public function test_record_replay_preserves_a_later_confirmation(): void { $data = [...$this->command('record'), 'title' => 'SYNTHETIC revised instrument', 'terms' => 'SYNTHETIC revised terms']; $this->postJson($this->url(), $data)->assertOk(); $this->postJson($this->url(), $this->command('confirm'))->assertOk(); $before = $this->instrument(); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('saved_version', 3); $this->assertSame($before, $this->instrument()); $this->assertSame($events, DB::table('partnership_events')->count()); } public function test_key_conflicts_and_case_normalization_do_not_duplicate_decisions(): void { $data = $this->command('confirm'); $this->postJson($this->url(), $data)->assertOk(); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), [...$data, 'reason' => 'SYNTHETIC changed reason'])->assertConflict(); $this->postJson($this->url(), [...$data, 'client_key' => strtoupper($data['client_key'])])->assertOk()->assertJsonPath('replayed', true); $this->assertSame($events, DB::table('partnership_events')->count()); } public function test_only_the_two_named_parties_count_and_supplied_party_does_not_override_authority(): void { $agreement = $this->instrument(); $agreement['confirmations'] = ['unrelated' => ['actor_id' => $this->candidate->id, 'date' => 'SYNTHETIC']]; DB::table('partnership_rooms')->where('id', $this->roomId)->update(['agreement' => json_encode($agreement)]); $data = $this->command('confirm'); $this->postJson($this->url(), [...$data, 'party' => 'candidate', 'actor_id' => $this->candidate->id])->assertOk()->assertJsonPath('status', 'agreement_recorded'); $this->assertSame($this->owner->id, $this->instrument()['confirmations']['proponent']['actor_id']); $this->assertArrayNotHasKey('candidate', $this->instrument()['confirmations']); $this->actingAs($this->candidate)->postJson($this->url(), [...$this->command('confirm'), 'client_key' => $data['client_key']])->assertOk()->assertJsonPath('replayed', false)->assertJsonPath('status', 'agreed'); $this->assertSame('not_verified', $this->instrument()['legal_signature_status']); } public function test_record_and_renew_preserve_versions_and_reset_only_their_confirmation_set(): void { $this->postJson($this->url(), [...$this->command('record'), 'title' => 'SYNTHETIC version two', 'terms' => 'SYNTHETIC version two terms'])->assertOk(); $this->assertSame('SYNTHETIC instrument', $this->instrument()['previous_versions'][0]['title']); $this->confirmBoth(); $data = $this->command('renew'); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('status', 'negotiation'); $instrument = $this->instrument(); $this->assertCount(2, $instrument['previous_versions']); $this->assertSame([], $instrument['confirmations']); $this->assertArrayHasKey('recorded_context', $instrument['previous_versions'][1]); $this->assertArrayHasKey('proponent', $instrument['previous_versions'][1]['confirmations']); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', true); $this->assertSame($instrument, $this->instrument()); } public function test_completion_requires_finished_milestones_and_closure_replay_is_read_only(): void { $this->confirmBoth(); $data = ['title' => 'SYNTHETIC delivery', 'responsible_id' => $this->candidate->id, 'status' => 'pending']; $this->postJson('/parcerias/salas/'.$this->roomId.'/marcos', $data)->assertOk(); $this->postJson($this->url(), $this->command('start'))->assertOk()->assertJsonPath('status', 'execution'); $complete = $this->command('complete'); $this->postJson($this->url(), $complete)->assertConflict(); $item = DB::table('partnership_milestones')->where('room_id', $this->roomId)->first(); $this->putJson('/parcerias/salas/'.$this->roomId.'/marcos/'.$item->id, [...$data, 'version' => $item->version, 'status' => 'completed', 'evidence' => 'SYNTHETIC evidence'])->assertOk(); $this->postJson($this->url(), $complete)->assertOk()->assertJsonPath('status', 'closed'); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), $complete)->assertOk()->assertJsonPath('replayed', true); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame('completed', DB::table('partnership_milestones')->where('id', $item->id)->value('status')); } public function test_revocation_or_read_only_role_prevents_replay_and_private_command_receipts(): void { $context = app(PartnershipExchange::class)->room($this->owner, $this->roomId); $org = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC agreement organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $this->owner->id, 'organization_id' => $org, 'role' => 'officer']); app(PartnershipExchange::class)->save($this->owner, ['version' => $context['partnership']->version, 'kind' => 'seeking', 'organization_id' => $org], $context['partnership']->id); $data = $this->command(); $this->postJson($this->url(), $data)->assertOk(); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->owner->id)->update(['role' => 'viewer']); $this->postJson($this->url(), $data)->assertForbidden(); $this->getJson('/parcerias/salas/'.$this->roomId)->assertOk()->assertJsonPath('agreementCommands', [])->assertJsonPath('agreementParty', null)->assertJsonPath('canWrite', false); DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->owner->id)->update(['deleted_at' => now()]); $this->postJson($this->url(), $data)->assertForbidden(); } public function test_receipts_are_actor_specific_and_snapshots_do_not_expose_internal_hashes(): void { $data = $this->command('confirm'); $this->postJson($this->url(), $data)->assertOk(); $response = $this->getJson('/parcerias/salas/'.$this->roomId)->assertOk()->assertJsonPath('agreementActorId', $this->owner->id)->assertJsonPath('agreementParty', 'proponent')->assertJsonPath('agreementCommands.0.key', $data['client_key'])->assertJsonPath('agreementCommands.0.party', 'proponent'); foreach (['command_hash', '_creation_hash', '_notification_context'] as $key) { $this->assertStringNotContainsString($key, $response->getContent()); }$this->actingAs($this->candidate)->getJson('/parcerias/salas/'.$this->roomId)->assertOk()->assertJsonPath('agreementCommands', [])->assertJsonPath('agreementParty', 'candidate'); $event = DB::table('partnership_events')->where('event_type', 'partnership_agreement.changed.v1')->orderByDesc('aggregate_version')->first(); $metadata = json_decode($event->payload, true)['payload']; $this->assertSame('SYNTHETIC terms', $metadata['previous']['agreement']['terms']); $this->assertSame($this->owner->id, $metadata['applied']['agreement']['confirmations']['proponent']['actor_id']); $this->assertSame('proponent', $metadata['confirmation_party']); } public function test_context_tokens_reject_stale_candidacy_and_legacy_calls_keep_the_room_protocol(): void { $data = $this->command('confirm'); $this->postJson($this->url(), [...$data, 'interest_version' => $data['interest_version'] + 1])->assertConflict(); $this->postJson($this->url(), [...$data, 'client_key' => 'invalid'])->assertUnprocessable(); unset($data['client_key'],$data['interest_version'],$data['partnership_version']); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', false); } public function test_malformed_legacy_instrument_is_not_overwritten_or_a_server_error(): void { $instrument = $this->instrument(); $instrument['previous_versions'] = 'SYNTHETIC invalid legacy'; DB::table('partnership_rooms')->where('id', $this->roomId)->update(['agreement' => json_encode($instrument)]); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), [...$this->command('record'), 'title' => 'SYNTHETIC new title', 'terms' => 'SYNTHETIC new terms'])->assertConflict(); $this->assertSame($instrument, $this->instrument()); $this->assertSame($events, DB::table('partnership_events')->count()); $instrument['previous_versions'] = []; $instrument['confirmations'] = 'SYNTHETIC invalid confirmations'; DB::table('partnership_rooms')->where('id', $this->roomId)->update(['agreement' => json_encode($instrument)]); $this->postJson($this->url(), $this->command('confirm'))->assertConflict(); } public function test_keys_are_room_scoped_and_moderation_does_not_grant_private_room_access(): void { $data = $this->command(); $this->postJson($this->url(), $data)->assertOk(); $service = app(PartnershipExchange::class); $context = $service->room($this->owner, $this->roomId); $other = User::factory()->create(); $interest = $service->interest($other, $context['partnership']->id, ['proposal' => 'SYNTHETIC other candidature', 'payload' => ['consent' => true]]); $interest = $service->decideInterest($this->owner, $interest->id, $interest->version, 'shortlist', 'SYNTHETIC shortlist'); $interest = $service->decideInterest($this->owner, $interest->id, $interest->version, 'connect', 'SYNTHETIC connect'); $room = DB::table('partnership_rooms')->where('interest_id',$interest->id)->first(); $this->postJson('/parcerias/salas/'.$room->id.'/acordo',[...$data, 'version' => $room->version, 'interest_version' => $interest->version])->assertOk()->assertJsonPath('replayed',false); config(['portal.managers' => [$other->email]]); $this->actingAs($other)->postJson($this->url(),$data)->assertForbidden(); } }