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

205 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\User;
use App\Services\PartnershipExchange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
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 PartnershipCandidacyHistoryTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private User $candidate;
private User $moderator;
private object $record;
private object $interest;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
Queue::fake();
$this->owner = User::factory()->create();
$this->candidate = User::factory()->create();
$this->moderator = User::factory()->create();
config(['portal.managers' => [$this->moderator->email]]);
$service = app(PartnershipExchange::class);
$record = $service->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC private history context', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true, 'mandatory' => ['capacity']]]);
$record = $service->transition($this->owner, $record->id, $record->version, 'submit', 'SYNTHETIC submission');
$this->record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC review');
$this->interest = $service->interest($this->candidate, $record->id, ['client_key' => (string) Str::uuid(), 'proposal' => 'SYNTHETIC original proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC original capacity']]);
$this->actingAs($this->candidate);
}
private function url(?int $id = null): string
{
return '/parcerias/candidaturas/'.($id ?? $this->interest->id).'/historico';
}
private function assess(int $version, string $evidence = 'SYNTHETIC preserved evidence'): object
{
return app(PartnershipExchange::class)->decideInterest($this->owner, $this->interest->id, $version, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => $evidence]]);
}
private function organization(User $person, string $role = 'officer'): string
{
$id = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC history organization', 'organization_type' => 'private']);
DB::table('memberships')->insert(['user_id' => $person->id, 'organization_id' => $id, 'role' => $role]);
return $id;
}
public function test_history_recovers_real_versions_and_frozen_matching_without_recalculation(): void
{
$this->assess(1);
app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 2, 'proposal' => 'SYNTHETIC revised proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC revised capacity']]);
config(['partnerships.weights.capacity' => 99, 'partnerships.criteria.capacity' => 'SYNTHETIC renamed current label']);
$response = $this->getJson($this->url())->assertOk()->assertJsonCount(3, 'entries')->assertJsonPath('entries.0.version', 3)->assertJsonPath('entries.0.after.fields.proposal', 'SYNTHETIC revised proposal')->assertJsonPath('entries.0.previous.fields.capacity', 'SYNTHETIC original capacity')->assertJsonPath('entries.1.after.fields.proposal', 'SYNTHETIC original proposal')->assertJsonPath('entries.1.after.assessment.0.evidence', 'SYNTHETIC preserved evidence')->assertJsonPath('entries.2.previous.availability', 'not_recorded');
$criteria = collect($response->json('entries.1.matching.criteria'))->keyBy('code');
$this->assertSame(20, $criteria['capacity']['weight']);
$this->assertSame('Capacidade técnica/produtiva', $criteria['capacity']['label']);
$this->assertTrue($criteria['capacity']['mandatory']);
$this->assertStringNotContainsString('client_key', $response->getContent());
$this->assertStringNotContainsString('command_hash', $response->getContent());
}
public function test_anonymous_competitors_and_moderation_do_not_receive_another_candidacy_history(): void
{
$other = User::factory()->create();
$otherInterest = app(PartnershipExchange::class)->interest($other, $this->record->id, ['proposal' => 'SYNTHETIC other candidature', 'payload' => ['consent' => true]]);
$this->getJson($this->url($otherInterest->id))->assertForbidden();
$this->actingAs($this->moderator)->getJson($this->url())->assertForbidden();
$this->actingAs($other)->getJson($this->url())->assertForbidden();
$this->getJson($this->url($otherInterest->id))->assertOk()->assertJsonCount(1, 'entries');
$this->actingAs($this->owner)->getJson($this->url())->assertOk();
auth()->forgetGuards();
$this->app['auth']->forgetGuards();
$this->getJson($this->url())->assertUnauthorized();
}
public function test_read_only_organization_members_can_read_and_revoked_authors_cannot(): void
{
$org = $this->organization($this->candidate);
app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $org, 'proposal' => 'SYNTHETIC organizational version', 'payload' => ['consent' => true]]);
$viewer = User::factory()->create();
DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $org, 'role' => 'viewer']);
$this->actingAs($viewer)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.availability', 'available')->assertJsonPath('entries.0.previous.availability', 'restricted')->assertJsonPath('entries.1.restricted', true);
DB::table('memberships')->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]);
$this->actingAs($this->candidate)->getJson($this->url())->assertForbidden();
$this->actingAs($viewer)->getJson($this->url())->assertOk();
}
public function test_new_organization_does_not_gain_old_organization_snapshots_or_assessment(): void
{
$orgA = $this->organization($this->candidate);
$orgB = $this->organization($this->candidate);
app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $orgA, 'proposal' => 'SYNTHETIC A-only proposal', 'payload' => ['consent' => true]]);
$this->assess(2, 'SYNTHETIC A-only evidence');
app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 3, 'organization_id' => $orgB, 'proposal' => 'SYNTHETIC B submission', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC B capacity']]);
$viewer = User::factory()->create();
DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $orgB, 'role' => 'viewer']);
$response = $this->actingAs($viewer)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.fields.proposal', 'SYNTHETIC B submission')->assertJsonPath('entries.0.previous.availability', 'restricted')->assertJsonPath('entries.1.restricted', true)->assertJsonPath('entries.1.actor_name', null)->assertJsonPath('entries.1.matching', null);
$this->assertStringNotContainsString('SYNTHETIC A-only', $response->getContent());
$this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.fields.proposal', 'SYNTHETIC A-only proposal');
DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]);
$this->actingAs($this->candidate)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.availability', 'restricted');
}
public function test_legacy_partial_events_explain_missing_snapshots_without_copying_current_declarations(): void
{
DB::table('partnership_interests')->where('id', $this->interest->id)->update(['proposal' => 'SYNTHETIC current-only declaration', 'version' => 2, 'status' => 'clarification']);
app(PartnershipExchange::class)->event($this->record, $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id, 'interest_version' => 2, 'from' => 'submitted', 'to' => 'clarification', 'reason' => 'SYNTHETIC legacy reason', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC legacy evidence']]], 'SYNTHETIC legacy fixture');
$response = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.action', null)->assertJsonPath('entries.0.previous.availability', 'not_recorded')->assertJsonPath('entries.0.after.complete', false)->assertJsonPath('entries.0.after.status', 'clarification')->assertJsonPath('entries.0.matching', null);
$this->assertArrayNotHasKey('proposal', $response->json('entries.0.after.fields'));
$this->assertStringNotContainsString('SYNTHETIC current-only', $response->getContent());
$this->actingAs($this->candidate)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.restricted', true)->assertJsonPath('entries.0.after.availability', 'scope_unknown');
}
public function test_pagination_is_stable_when_new_decisions_arrive_and_never_mixes_candidacies(): void
{
for ($version = 1; $version <= 21; $version++) {
$this->assess($version);
}
$other = app(PartnershipExchange::class)->interest(User::factory()->create(), $this->record->id, ['proposal' => 'SYNTHETIC unrelated timeline', 'payload' => ['consent' => true]]);
$first = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonCount(10, 'entries');
$versions = array_column($first->json('entries'), 'version');
$this->assertSame(range(22, 13), $versions);
$this->assess(22);
$second = $this->getJson($first->json('next_url'))->assertOk()->assertJsonCount(10, 'entries');
$third = $this->getJson($second->json('next_url'))->assertOk()->assertJsonCount(2, 'entries')->assertJsonPath('next_url', null);
$this->assertSame(range(22, 1), [...$versions, ...array_column($second->json('entries'), 'version'), ...array_column($third->json('entries'), 'version')]);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.version', 23);
$this->getJson($this->url($other->id))->assertOk()->assertJsonCount(1, 'entries');
}
public function test_cursors_are_bound_to_actor_and_candidacy_and_checked_after_current_access(): void
{
for ($version = 1; $version <= 10; $version++) {
$this->assess($version);
}
$cursor = $this->actingAs($this->owner)->getJson($this->url())->assertOk()->json('next_url');
$this->actingAs($this->candidate)->getJson($cursor)->assertUnprocessable();
$other = app(PartnershipExchange::class)->interest(User::factory()->create(), $this->record->id, ['proposal' => 'SYNTHETIC other', 'payload' => ['consent' => true]]);
$query = parse_url($cursor, PHP_URL_QUERY);
$this->actingAs($this->owner)->getJson($this->url($other->id).'?'.$query)->assertUnprocessable();
$this->getJson($this->url().'?before=invalid-token')->assertUnprocessable();
$this->getJson($cursor)->assertOk();
}
public function test_private_history_remains_readable_after_archiving_without_exposing_a_new_draft(): void
{
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'archived', 'title' => 'SYNTHETIC undisclosed revised title', 'offer' => 'SYNTHETIC private new draft']);
$response = $this->getJson($this->url())->assertOk()->assertJsonPath('partnership_href', null)->assertJsonPath('dashboard_href', '/parcerias/painel');
$this->assertStringNotContainsString('SYNTHETIC undisclosed', $response->getContent());
$this->assertStringNotContainsString('SYNTHETIC private new draft', $response->getContent());
$this->getJson('/parcerias/'.$this->record->id)->assertNotFound();
$this->actingAs($this->owner)->getJson($this->url())->assertOk()->assertJsonPath('partnership_href', '/parcerias/'.$this->record->id);
}
public function test_consultation_is_read_only_private_and_uses_the_native_history_page(): void
{
$this->assess(1);
$row = DB::table('partnership_interests')->where('id', $this->interest->id)->first();
$events = DB::table('partnership_events')->count();
$audits = DB::table('audit_events')->count();
$notifications = DB::table('partnership_notifications')->count();
$version = app(HandleInertiaRequests::class)->version(Request::create($this->url()));
$response = $this->get($this->url(), ['X-Inertia' => 'true', 'X-Inertia-Version' => $version ?? ''])->assertOk()->assertJsonPath('component', 'partnerships/interest-history')->assertJsonPath('encryptHistory', true);
$this->assertTrue($response->headers->hasCacheControlDirective('private'));
$this->assertTrue($response->headers->hasCacheControlDirective('no-store'));
$this->assertEquals($row, DB::table('partnership_interests')->where('id', $this->interest->id)->first());
$this->assertSame($events, DB::table('partnership_events')->count());
$this->assertSame($audits, DB::table('audit_events')->count());
$this->assertSame($notifications, DB::table('partnership_notifications')->count());
}
public function test_missing_or_malformed_matching_parameters_stay_unknown_and_never_become_zero(): void
{
$this->assess(1);
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.changed.v1')->first();
$data = json_decode($event->payload, true);
$data['payload']['matching_context']['score_min'] = ['invalid'];
$data['payload']['matching_context']['score_max'] = null;
$data['payload']['matching_context']['criteria'][0]['weight'] = ['invalid'];
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($data)]);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.matching.score_min', null)->assertJsonPath('entries.0.matching.score_max', null)->assertJsonPath('entries.0.matching.criteria.0.weight', null);
}
}