233 lines
15 KiB
PHP
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();
|
|
}
|
|
}
|