205 lines
14 KiB
PHP
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);
|
|
}
|
|
}
|