Files
Investidor-V2/tests/Feature/PartnershipAgreementRecoveryTest.php
T

233 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\PartnershipExchange;
use Illuminate\Foundation\Testing\RefreshDatabase;
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 Tests\TestCase;
final class PartnershipAgreementRecoveryTest 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_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();
}
}