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_history_reads_the_saved_instrument_without_changing_the_room(): void { $room = DB::table('partnership_rooms')->where('id', $this->roomId)->first(); $events = DB::table('partnership_events')->count(); $audits = DB::table('audit_events')->count(); $notices = DB::table('partnership_notifications')->count(); $this->getJson($this->history())->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('room_id', $this->roomId) ->assertJsonCount(1, 'entries')->assertJsonPath('entries.0.after.instrument.title', 'SYNTHETIC instrument') ->assertJsonPath('entries.0.after.instrument.legal_signature_status', 'not_verified')->assertJsonPath('entries.0.previous.instrument_availability', 'not_recorded'); $this->assertEquals($room, DB::table('partnership_rooms')->where('id', $this->roomId)->first()); $this->assertSame($events, DB::table('partnership_events')->count()); $this->assertSame($audits, DB::table('audit_events')->count()); $this->assertSame($notices, DB::table('partnership_notifications')->count()); } private function history(): string { return '/parcerias/salas/'.$this->roomId.'/historico'; } private function record(string $title = 'SYNTHETIC next instrument'): void { app(PartnershipAgreements::class)->act($this->owner, $this->roomId, [...$this->command('record'), 'title' => $title, 'terms' => 'SYNTHETIC frozen conditions']); } private function alterEvent(callable $change): void { $event = DB::table('partnership_events')->where('event_type', 'partnership_agreement.changed.v1')->orderByDesc('aggregate_version')->first(); $payload = json_decode($event->payload, true); $payload['payload'] = $change($payload['payload']); DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]); } public function test_both_parties_read_closed_history_but_moderation_is_not_private_room_access(): void { $this->postJson($this->url(), $this->command())->assertOk(); $this->actingAs($this->candidate)->getJson($this->history())->assertOk()->assertJsonPath('entries.0.action', 'cancel'); $this->actingAs($this->owner)->get($this->history())->assertOk()->assertInertia(fn (Assert $page) => $page->component('partnerships/agreement-history')->where('room_id', $this->roomId)); $other = User::factory()->create(); config(['portal.managers' => [$other->email]]); $this->actingAs($other)->getJson($this->history())->assertForbidden(); $this->getJson('/parcerias/salas/999999999/historico')->assertNotFound(); } public function test_instruments_and_confirmations_are_frozen_and_only_projected_fields_leave_events(): void { $this->postJson($this->url(), $this->command('confirm'))->assertOk(); $this->alterEvent(function ($data) { $data['applied']['agreement']['private_secret'] = 'SYNTHETIC do not disclose'; $data['applied']['agreement']['confirmations']['proponent']['private_secret'] = 'SYNTHETIC do not disclose'; return $data; }); $instrument = json_decode(DB::table('partnership_rooms')->where('id', $this->roomId)->value('agreement'), true); $instrument['title'] = 'SYNTHETIC current title never a historical fallback'; DB::table('partnership_rooms')->where('id', $this->roomId)->update(['agreement' => json_encode($instrument)]); $result = $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.previous.instrument.title', 'SYNTHETIC instrument') ->assertJsonPath('entries.0.after.instrument.title', 'SYNTHETIC instrument')->assertJsonPath('entries.0.confirmation_party', 'proponent') ->assertJsonPath('entries.0.after.instrument.confirmations.proponent.reason', 'SYNTHETIC documented action'); foreach (['private_secret', 'do not disclose', 'command_hash', 'client_key', '_notification_context', 'previous_versions', 'current title never'] as $secret) { $this->assertStringNotContainsString($secret, $result->getContent()); } } public function test_pagination_is_bounded_and_inserting_a_newer_event_does_not_repeat_older_pages(): void { for ($i = 0; $i < 12; $i++) { $this->record('SYNTHETIC revision '.$i); } $first = $this->getJson($this->history())->assertOk()->assertJsonCount(10, 'entries'); $next = $first->json('next_url'); $this->assertIsString($next); $this->record('SYNTHETIC inserted after pagination'); $older = $this->getJson($next)->assertOk()->assertJsonCount(3, 'entries')->assertJsonPath('next_url', null)->assertJsonPath('older_page', true); $versions = [...array_column($first->json('entries'), 'version'), ...array_column($older->json('entries'), 'version')]; $this->assertCount(13, array_unique($versions)); $sorted = $versions; rsort($sorted); $this->assertSame($sorted, $versions); } public function test_cursor_is_bound_to_account_room_relationship_and_purpose(): void { for ($i = 0; $i < 10; $i++) { $this->record(); } $next = $this->getJson($this->history())->assertOk()->json('next_url'); $this->actingAs($this->candidate)->getJson($next)->assertUnprocessable(); $this->actingAs($this->owner)->getJson($this->history().'?before=invalid')->assertUnprocessable(); $context = app(PartnershipExchange::class)->room($this->owner, $this->roomId); foreach (['purpose' => 'publication', 'actor' => $this->candidate->id, 'room' => $this->roomId + 1, 'interest' => $context['interest']->id + 1, 'partnership' => $context['partnership']->id + 1, 'before' => '1'] as $key => $wrong) { $cursor = ['purpose' => 'agreement', 'actor' => $this->owner->id, 'room' => $this->roomId, 'interest' => (int) $context['interest']->id, 'partnership' => (int) $context['partnership']->id, 'before' => 9999]; $cursor[$key] = $wrong; $this->getJson($this->history().'?before='.rawurlencode(Crypt::encryptString(json_encode($cursor))))->assertUnprocessable(); } } public function test_current_representative_cannot_inherit_an_older_organization_instrument(): void { $a = (string) Str::uuid(); $b = (string) Str::uuid(); $viewer = User::factory()->create(); foreach ([$a, $b] as $org) { DB::table('organizations')->insert(['id' => $org, 'name' => 'SYNTHETIC organization', 'organization_type' => 'private']); DB::table('memberships')->insert(['user_id' => $this->owner->id, 'organization_id' => $org, 'role' => 'officer']); } DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $b, 'role' => 'viewer']); $exchange = app(PartnershipExchange::class); $parent = $exchange->room($this->owner, $this->roomId)['partnership']; $parent = $exchange->save($this->owner, ['version' => $parent->version, 'kind' => 'seeking', 'organization_id' => $a], $parent->id); $this->record('SYNTHETIC OLD organization instrument'); $exchange->save($this->owner, ['version' => $parent->version, 'kind' => 'seeking', 'organization_id' => $b], $parent->id); $this->postJson($this->url(), $this->command('confirm'))->assertOk(); $result = $this->actingAs($viewer)->getJson($this->history())->assertOk()->assertJsonPath('entries.0.restricted', false) ->assertJsonPath('entries.0.after.instrument_availability', 'restricted')->assertJsonPath('entries.1.restricted', true); $this->assertStringNotContainsString('OLD organization instrument', $result->getContent()); $this->actingAs($this->candidate)->getJson($this->history())->assertOk()->assertJsonPath('entries.0.after.instrument.title', 'SYNTHETIC OLD organization instrument'); DB::table('memberships')->where('user_id', $viewer->id)->update(['deleted_at' => now()]); $this->actingAs($viewer)->getJson($this->history())->assertForbidden(); } public function test_missing_or_inconsistent_historical_scope_hides_content_instead_of_using_current_parties(): void { $this->alterEvent(function ($data) { $data['_notification_context']['interest']['id']++; return $data; }); $result = $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.restricted', true)->assertJsonPath('entries.0.reason', null)->assertJsonPath('entries.0.after.availability', 'scope_unknown'); $this->assertStringNotContainsString('SYNTHETIC instrument', $result->getContent()); $this->alterEvent(function ($data) { unset($data['_notification_context']); return $data; }); $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.after.availability', 'scope_unknown'); } public function test_legacy_events_without_snapshots_are_explicitly_incomplete(): void { $this->alterEvent(function ($data) { unset($data['previous'], $data['applied']); return $data; }); $result = $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.previous.availability', 'not_recorded')->assertJsonPath('entries.0.after.availability', 'not_recorded'); $this->assertStringNotContainsString('SYNTHETIC instrument', $result->getContent()); } public function test_foreign_snapshot_id_and_unknown_instrument_origin_do_not_disclose_values(): void { $this->alterEvent(function ($data) { $data['previous'] = $data['applied']; $data['previous']['id']++; unset($data['applied']['agreement']['recorded_context']); return $data; }); $result = $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.previous.availability', 'scope_unknown')->assertJsonPath('entries.0.after.instrument_availability', 'scope_unknown'); $this->assertStringNotContainsString('SYNTHETIC instrument', $result->getContent()); } public function test_legacy_confirmation_shapes_remain_explicit_and_private_fields_are_omitted(): void { $this->alterEvent(function ($data) { $data['applied']['agreement']['confirmations'] = ['proponent' => ['actor_id' => 'wrong', 'date' => ['SYNTHETIC private']], 'unknown-secret-key' => ['SYNTHETIC private']]; return $data; }); $result = $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.after.instrument.confirmations.proponent.actor_id', null)->assertJsonPath('entries.0.after.instrument.confirmations._unrecognized_references', true); $this->assertStringNotContainsString('unknown-secret-key', $result->getContent()); $this->assertStringNotContainsString('SYNTHETIC private', $result->getContent()); $this->alterEvent(function ($data) { $data['applied']['agreement']['confirmations'] = null; return $data; }); $this->getJson($this->history())->assertOk()->assertJsonPath('entries.0.after.instrument.confirmations', null); } public function test_another_room_is_never_included_in_the_history(): void { $exchange = app(PartnershipExchange::class); $context = $exchange->room($this->owner, $this->roomId); $other = User::factory()->create(); $interest = $exchange->interest($other, $context['partnership']->id, ['proposal' => 'SYNTHETIC other candidacy', 'payload' => ['consent' => true]]); $interest = $exchange->decideInterest($this->owner, $interest->id, $interest->version, 'shortlist', 'SYNTHETIC shortlist'); $exchange->decideInterest($this->owner, $interest->id, $interest->version, 'connect', 'SYNTHETIC connect'); $room = DB::table('partnership_rooms')->where('interest_id', $interest->id)->first(); app(PartnershipAgreements::class)->act($this->owner, $room->id, ['version' => 1, 'action' => 'record', 'reason' => 'SYNTHETIC other', 'title' => 'SYNTHETIC OTHER PRIVATE', 'terms' => 'SYNTHETIC private']); $result = $this->getJson($this->history())->assertOk()->assertJsonCount(1, 'entries'); $this->assertStringNotContainsString('OTHER PRIVATE', $result->getContent()); $this->actingAs($other)->getJson($this->history())->assertForbidden(); } }