Files
Investidor-V2/tests/Feature/PartnershipAgreementHistoryTest.php

257 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PartnershipAgreements;
use App\Services\PartnershipExchange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class PartnershipAgreementHistoryTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private User $candidate;
private int $roomId;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}