withoutVite(); Http::fake(); Mail::fake(); Queue::fake(); $this->owner = User::factory()->create(); $this->moderator = User::factory()->create(); config(['portal.managers' => [$this->moderator->email]]); $this->record = app(PartnershipExchange::class)->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC publication proposal', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'payload' => ['consent' => true, 'confidentiality' => 'private']]); $this->actingAs($this->owner); } private function command(string $action, ?int $version = null): array { return ['client_key' => (string) Str::uuid(), 'version' => $version ?? $this->record->version, 'action' => $action, 'reason' => 'SYNTHETIC documented action']; } private function url(): string { return '/parcerias/'.$this->record->id.'/estado'; } public function test_repeating_a_committed_transition_is_a_read_after_a_later_transition(): void { $data = $this->command('archive'); $this->postJson($this->url(), $data)->assertOk(); $version = (int) DB::table('portal_partnerships')->where('id', $this->record->id)->value('version'); $this->postJson($this->url(), $this->command('renew', $version))->assertOk(); $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('status', 'draft'); $this->assertSame($events, DB::table('partnership_events')->count()); } public function test_current_authority_precedes_a_stale_version_conflict(): void { $this->actingAs(User::factory()->create())->postJson($this->url(), $this->command('archive', $this->record->version + 99))->assertForbidden(); } public function test_publication_actions_match_existing_server_authority_including_suspension(): void { $service = app(PartnershipExchange::class); $submitted = $service->transition($this->owner, $this->record->id, $this->record->version, 'submit', 'SYNTHETIC submit'); $published = $service->transition($this->moderator, $this->record->id, $submitted->version, 'publish', 'SYNTHETIC publish'); $this->actingAs($this->moderator)->getJson('/parcerias/'.$published->id)->assertOk()->assertJsonPath('publicationActions', ['suspend']); } public function test_same_nonce_with_changed_action_or_reason_is_refused_without_new_writes(): void { $data = $this->command('archive'); $this->postJson($this->url(), $data)->assertOk()->assertJsonPath('replayed', false); $events = DB::table('partnership_events')->count(); $notices = DB::table('partnership_notifications')->count(); $this->postJson($this->url(), [...$data, 'reason' => 'SYNTHETIC different reason'])->assertConflict(); $this->postJson($this->url(), [...$data, 'action' => 'renew'])->assertConflict(); $this->postJson($this->url(), [...$data, 'client_key' => strtoupper($data['client_key']), 'reason' => ' '.$data['reason'].' '])->assertOk()->assertJsonPath('replayed', true); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($notices, DB::table('partnership_notifications')->count()); } public function test_audit_keeps_the_proposal_used_before_and_after_transition_without_child_cascades(): void { $data = $this->command('submit'); $this->postJson($this->url(), $data)->assertOk(); $event = DB::table('partnership_events')->where('event_type', 'partnership_opportunity.submitted.v1')->first(); $metadata = json_decode($event->payload, true)['payload']; $this->assertSame('SYNTHETIC publication proposal', $metadata['previous']['title']); $this->assertSame('SYNTHETIC offer', $metadata['applied']['offer']); $this->assertSame('draft', $metadata['previous']['status']); $this->assertSame('submitted', $metadata['applied']['status']); $this->assertSame($this->record->version, $metadata['previous']['version']); $this->assertSame($this->record->version + 1, $metadata['result_version']); $this->assertSame('private', $metadata['previous']['payload']['confidentiality']); $this->assertArrayNotHasKey('_creation_hash', $metadata['previous']['payload']); $this->assertSame(0, DB::table('partnership_interests')->count()); $this->assertSame(0, DB::table('partnership_rooms')->count()); } public function test_revoked_organization_author_cannot_replay_even_a_committed_transition(): void { $org = (string) Str::uuid(); DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC publisher organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $this->owner->id, 'organization_id' => $org, 'role' => 'officer']); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org]); $data = $this->command('archive'); $this->postJson($this->url(), $data)->assertOk(); DB::table('memberships')->where('user_id', $this->owner->id)->where('organization_id', $org)->update(['deleted_at' => now()]); $this->postJson($this->url(), $data)->assertForbidden(); } public function test_owner_cannot_publish_their_own_proposal_even_with_the_moderation_gate(): void { $service = app(PartnershipExchange::class); $record = $service->transition($this->owner, $this->record->id, $this->record->version, 'submit', 'SYNTHETIC submit'); config(['portal.managers' => [$this->owner->email]]); $this->getJson('/parcerias/'.$record->id)->assertOk()->assertJsonPath('publicationActions', []); $this->postJson($this->url(), $this->command('publish', $record->version))->assertForbidden(); } public function test_command_keys_are_private_to_the_current_authorized_actor_and_raw_events_stay_private(): void { $data = $this->command('submit'); $this->postJson($this->url(), $data)->assertOk(); $response = $this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('publicationCommands.0.key', strtolower($data['client_key'])); $response->assertJsonMissingPath('history'); $summary = $response->json('historySummary.entries.0'); $this->assertIsArray($summary); foreach (['payload', 'event_type', 'actor_id', 'command_hash'] as $private) { $this->assertArrayNotHasKey($private, $summary); } $this->assertStringNotContainsString('command_hash', $response->getContent()); $this->assertStringNotContainsString('_notification_context', $response->getContent()); $this->actingAs($this->moderator)->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('publicationCommands', []); $record = app(PartnershipExchange::class)->find($this->record->id); app(PartnershipExchange::class)->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish'); $this->actingAs(User::factory()->create())->getJson('/parcerias/'.$record->id)->assertOk()->assertJsonPath('publicationCommands', [])->assertJsonPath('publicationActions', [])->assertJsonPath('historySummary', null)->assertJsonMissingPath('history'); } public function test_validation_and_legacy_requests_preserve_existing_submission_requirements(): void { $events = DB::table('partnership_events')->count(); $this->postJson($this->url(), [...$this->command('submit'), 'reason' => ' '])->assertUnprocessable(); $this->postJson($this->url(), [...$this->command('submit'), 'client_key' => 'invalid'])->assertUnprocessable(); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['payload' => json_encode(['consent' => false])]); $this->postJson($this->url(), $this->command('submit'))->assertUnprocessable(); $this->assertSame($events, DB::table('partnership_events')->count()); DB::table('portal_partnerships')->where('id', $this->record->id)->update(['payload' => json_encode(['consent' => true])]); $legacy = $this->command('submit'); unset($legacy['client_key']); $this->postJson($this->url(), $legacy)->assertOk()->assertJsonPath('replayed', false); } public function test_losing_moderation_authority_prevents_replay_and_removes_its_command_metadata(): void { $service = app(PartnershipExchange::class); $record = $service->transition($this->owner, $this->record->id, $this->record->version, 'submit', 'SYNTHETIC submit'); $data = $this->command('publish', $record->version); $this->actingAs($this->moderator)->postJson($this->url(), $data)->assertOk(); config(['portal.managers' => []]); $this->getJson('/parcerias/'.$record->id)->assertOk()->assertJsonPath('publicationCommands', []); $this->postJson($this->url(), $data)->assertForbidden(); } public function test_stale_version_does_not_write_and_suspension_uses_the_existing_state_graph(): void { $this->postJson($this->url(), $this->command('archive', $this->record->version + 1))->assertConflict(); $service = app(PartnershipExchange::class); $record = $service->transition($this->owner, $this->record->id, $this->record->version, 'submit', 'SYNTHETIC submit'); $record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish'); $this->actingAs($this->moderator)->postJson($this->url(), $this->command('suspend', $record->version))->assertOk()->assertJsonPath('status', 'suspended'); $this->getJson('/parcerias/'.$record->id)->assertOk()->assertJsonPath('publicationActions', []); $this->actingAs($this->owner)->getJson('/parcerias/'.$record->id)->assertOk()->assertJsonPath('publicationActions', ['archive', 'renew']); } public function test_nonce_is_scoped_to_process_and_actor_and_another_owner_cannot_recover_it(): void { $data = $this->command('archive'); $this->postJson($this->url(), $data)->assertOk(); $other = User::factory()->create(); $record = app(PartnershipExchange::class)->save($other, ['client_key' => (string) Str::uuid(), 'kind' => 'seeking']); $this->actingAs($other)->postJson('/parcerias/'.$record->id.'/estado', [...$data, 'version' => $record->version])->assertOk()->assertJsonPath('replayed', false); $this->postJson($this->url(), $data)->assertForbidden(); } }