251 lines
17 KiB
PHP
251 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use App\Services\PartnershipExchange;
|
|
use App\Services\PartnershipMatching;
|
|
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 PartnershipCandidaciesTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $owner;
|
|
|
|
private User $candidate;
|
|
|
|
private object $record;
|
|
|
|
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->record = $this->published($this->owner);
|
|
$this->actingAs($this->candidate);
|
|
}
|
|
|
|
private function published(User $owner): object
|
|
{
|
|
$service = app(PartnershipExchange::class);
|
|
$record = $service->save($owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC candidature context', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true, 'mandatory' => ['capacity']]]);
|
|
$record = $service->transition($owner, $record->id, $record->version, 'submit', 'SYNTHETIC submission');
|
|
$moderator = User::factory()->create();
|
|
config(['portal.managers' => [$moderator->email]]);
|
|
|
|
return $service->transition($moderator, $record->id, $record->version, 'publish', 'SYNTHETIC independent review');
|
|
}
|
|
|
|
private function input(array $changes = []): array
|
|
{
|
|
return ['proposal' => 'SYNTHETIC original proposal', 'payload' => ['consent' => true, 'capacity' => 'SYNTHETIC original capacity', 'availability' => 'SYNTHETIC original availability', 'contribution' => 'SYNTHETIC original contribution', 'conflicts' => 'SYNTHETIC declared conflicts'], ...$changes];
|
|
}
|
|
|
|
private function url(): string
|
|
{
|
|
return '/parcerias/'.$this->record->id.'/interesses';
|
|
}
|
|
|
|
private function interest(array $changes = [], ?User $actor = null): object
|
|
{
|
|
return app(PartnershipExchange::class)->interest($actor ?? $this->candidate, $this->record->id, $this->input($changes));
|
|
}
|
|
|
|
private function organization(User $user, string $role = 'officer'): string
|
|
{
|
|
$id = (string) Str::uuid();
|
|
DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC candidacy organization', 'organization_type' => 'private']);
|
|
DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $id, 'role' => $role]);
|
|
|
|
return $id;
|
|
}
|
|
|
|
public function test_omitted_declarations_and_organization_survive_a_partial_resubmission(): void
|
|
{
|
|
$org = $this->organization($this->candidate);
|
|
$interest = $this->interest(['organization_id' => $org]);
|
|
$this->postJson($this->url(), ['interest_id' => $interest->id, 'version' => $interest->version, 'proposal' => 'SYNTHETIC changed proposal', 'payload' => ['consent' => true]])->assertOk();
|
|
$saved = DB::table('partnership_interests')->where('id', $interest->id)->first();
|
|
$this->assertSame($org, $saved->organization_id);
|
|
$payload = json_decode($saved->payload, true);
|
|
$this->assertSame('SYNTHETIC original capacity', $payload['capacity']);
|
|
$this->assertSame('SYNTHETIC original availability', $payload['availability']);
|
|
$this->assertSame('SYNTHETIC declared conflicts', $payload['conflicts']);
|
|
}
|
|
|
|
public function test_explicit_candidacy_identity_updates_the_selected_withdrawn_organization_record(): void
|
|
{
|
|
$personal = $this->interest();
|
|
$otherAuthor = User::factory()->create();
|
|
$org = $this->organization($otherAuthor);
|
|
DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'officer']);
|
|
$organizational = $this->interest(['organization_id' => $org], $otherAuthor);
|
|
$organizational = app(PartnershipExchange::class)->decideInterest($otherAuthor, $organizational->id, $organizational->version, 'withdraw', 'SYNTHETIC withdrawal');
|
|
DB::table('partnership_interests')->where('id', $personal->id)->update(['version' => $organizational->version]);
|
|
$this->postJson($this->url(), $this->input(['interest_id' => $organizational->id, 'version' => $organizational->version, 'organization_id' => $org, 'proposal' => 'SYNTHETIC intended organization update']))->assertOk()->assertJsonPath('id', $organizational->id);
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $personal->id, 'organization_id' => null, 'proposal' => 'SYNTHETIC original proposal']);
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $organizational->id, 'status' => 'submitted', 'proposal' => 'SYNTHETIC intended organization update']);
|
|
}
|
|
|
|
public function test_read_only_representative_can_consult_but_has_no_candidacy_write_actions(): void
|
|
{
|
|
$org = $this->organization($this->candidate);
|
|
$interest = $this->interest(['organization_id' => $org]);
|
|
$viewer = User::factory()->create();
|
|
DB::table('memberships')->insert(['user_id' => $viewer->id, 'organization_id' => $org, 'role' => 'viewer']);
|
|
$this->actingAs($viewer)->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('interests.0.can_edit', false);
|
|
$this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 1, 'organization_id' => $org]))->assertForbidden();
|
|
}
|
|
|
|
public function test_owning_actor_can_recover_a_committed_submission_without_resetting_assessment_or_version(): void
|
|
{
|
|
$input = $this->input(['client_key' => (string) Str::uuid()]);
|
|
$interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input);
|
|
$assessed = app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 1, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence']]);
|
|
$before = DB::table('partnership_events')->count();
|
|
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('id', $interest->id)->assertJsonPath('version', $assessed->version)->assertJsonPath('replayed', true);
|
|
$this->assertSame($before, DB::table('partnership_events')->count());
|
|
$this->assertSame('met', json_decode(DB::table('partnership_interests')->where('id', $interest->id)->value('assessment'), true)['capacity']['state']);
|
|
}
|
|
|
|
public function test_explicit_clearing_creates_a_new_submission_and_preserves_the_previous_evaluation_in_the_event(): void
|
|
{
|
|
$org = $this->organization($this->candidate);
|
|
$interest = $this->interest(['organization_id' => $org]);
|
|
$interest = app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 1, 'assess', 'SYNTHETIC evidence reviewed', ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC prior evidence']]);
|
|
$input = $this->input(['client_key' => (string) Str::uuid(), 'interest_id' => $interest->id, 'version' => 2, 'organization_id' => null, 'payload' => ['consent' => true, 'capacity' => '', 'conflicts' => null]]);
|
|
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 3);
|
|
$saved = DB::table('partnership_interests')->where('id', $interest->id)->first();
|
|
$this->assertNull($saved->organization_id);
|
|
$this->assertEmpty(json_decode($saved->assessment, true));
|
|
$this->assertNull(json_decode($saved->payload, true)['capacity']);
|
|
$this->assertNull(json_decode($saved->payload, true)['conflicts']);
|
|
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->orderByDesc('aggregate_version')->first();
|
|
$payload = json_decode($event->payload, true)['payload'];
|
|
$this->assertSame(2, $payload['previous']['version']);
|
|
$this->assertSame($org, $payload['previous']['organization_id']);
|
|
$this->assertSame('SYNTHETIC prior evidence', $payload['previous']['assessment']['capacity']['evidence']);
|
|
$this->assertSame('SYNTHETIC original capacity', $payload['previous']['payload']['capacity']);
|
|
$this->assertSame(3, $payload['submitted']['version']);
|
|
}
|
|
|
|
public function test_ambiguous_legacy_submission_and_a_foreign_candidacy_cannot_change_another_record(): void
|
|
{
|
|
$personal = $this->interest();
|
|
$other = User::factory()->create();
|
|
$org = $this->organization($other);
|
|
DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'officer']);
|
|
$organizational = $this->interest(['organization_id' => $org], $other);
|
|
$this->postJson($this->url(), $this->input(['version' => 1, 'organization_id' => $org]))->assertConflict();
|
|
$different = $this->published(User::factory()->create());
|
|
$foreign = app(PartnershipExchange::class)->interest($this->candidate, $different->id, $this->input());
|
|
$this->postJson($this->url(), $this->input(['interest_id' => $foreign->id, 'version' => 1]))->assertNotFound();
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $personal->id, 'organization_id' => null, 'version' => 1]);
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $organizational->id, 'organization_id' => $org, 'version' => 1]);
|
|
}
|
|
|
|
public function test_replay_is_content_bound_case_normalized_and_never_grants_revoked_representation(): void
|
|
{
|
|
$org = $this->organization($this->candidate);
|
|
$input = $this->input(['organization_id' => $org, 'client_key' => (string) Str::uuid()]);
|
|
$interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input);
|
|
$this->postJson($this->url(), [...$input, 'client_key' => strtoupper($input['client_key'])])->assertOk()->assertJsonPath('replayed', true);
|
|
$this->postJson($this->url(), [...$input, 'proposal' => 'SYNTHETIC different content'])->assertConflict();
|
|
DB::table('memberships')->where('organization_id', $org)->update(['deleted_at' => now()]);
|
|
$this->postJson($this->url(), $input)->assertForbidden();
|
|
$this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('canStartInterest', false)->assertJsonCount(0, 'interests');
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'version' => 1]);
|
|
}
|
|
|
|
public function test_submission_and_decision_reject_stale_partnership_context_without_writes(): void
|
|
{
|
|
$interest = $this->interest();
|
|
$before = DB::table('partnership_events')->count();
|
|
$this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 1, 'partnership_version' => $this->record->version - 1]))->assertConflict();
|
|
$this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'partnership_version' => $this->record->version - 1, 'action' => 'assess', 'reason' => 'SYNTHETIC stale context', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC stale evidence']]])->assertConflict();
|
|
$this->assertSame($before, DB::table('partnership_events')->count());
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'version' => 1, 'assessment' => '{}']);
|
|
}
|
|
|
|
public function test_matching_weights_are_checked_and_legacy_decisions_keep_their_existing_path(): void
|
|
{
|
|
$interest = $this->interest();
|
|
$hash = app(PartnershipMatching::class)->explain($this->record, $interest)['weights_hash'];
|
|
config(['partnerships.weights.capacity' => 21]);
|
|
$this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'partnership_version' => $this->record->version, 'weights_hash' => $hash, 'action' => 'assess', 'reason' => 'SYNTHETIC old weights', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence']]])->assertConflict();
|
|
$this->postJson('/parcerias/candidaturas/'.$interest->id, ['version' => 1, 'action' => 'assess', 'reason' => 'SYNTHETIC legacy client', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'SYNTHETIC evidence', 'irrelevant' => 'Must not persist']]])->assertOk();
|
|
$stored = json_decode(DB::table('partnership_interests')->where('id', $interest->id)->value('assessment'), true)['capacity'];
|
|
$this->assertArrayNotHasKey('irrelevant', $stored);
|
|
$this->assertSame($this->owner->id, $stored['assessed_by']);
|
|
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.changed.v1')->orderByDesc('aggregate_version')->first();
|
|
$payload = json_decode($event->payload, true)['payload'];
|
|
$this->assertSame($this->record->version, $payload['partnership_version']);
|
|
$this->assertNotSame($hash, $payload['matching_context']['weights_hash']);
|
|
$this->assertSame('transparent-rules.v1', $payload['matching_context']['algorithm_version']);
|
|
$capacity = array_values(array_filter($payload['matching_context']['criteria'], fn ($criterion) => $criterion['code'] === 'capacity'))[0];
|
|
$this->assertSame(21, $capacity['weight']);
|
|
$this->assertTrue($capacity['mandatory']);
|
|
}
|
|
|
|
public function test_replaying_a_decision_does_not_repeat_events_or_affect_another_candidacy(): void
|
|
{
|
|
$interest = $this->interest();
|
|
$other = $this->interest([], User::factory()->create());
|
|
$input = ['client_key' => (string) Str::uuid(), 'version' => 1, 'action' => 'shortlist', 'reason' => 'SYNTHETIC shortlist unknown evidence'];
|
|
$this->actingAs($this->owner)->postJson('/parcerias/candidaturas/'.$interest->id, $input)->assertOk();
|
|
$events = DB::table('partnership_events')->count();
|
|
$this->postJson('/parcerias/candidaturas/'.$interest->id, $input)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('version', 2);
|
|
$this->postJson('/parcerias/candidaturas/'.$other->id, $input)->assertConflict();
|
|
$this->assertSame($events, DB::table('partnership_events')->count());
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $other->id, 'version' => 1, 'status' => 'submitted']);
|
|
$this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonPath('interests.0.commands.0.key', $input['client_key']);
|
|
$this->actingAs($this->candidate)->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonCount(0, 'interests.0.commands');
|
|
}
|
|
|
|
public function test_withdrawal_and_read_only_recovery_do_not_depend_on_a_current_publication_window(): void
|
|
{
|
|
$input = $this->input(['client_key' => (string) Str::uuid()]);
|
|
$interest = app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, $input);
|
|
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'archived', 'version' => $this->record->version + 1]);
|
|
$withdraw = ['client_key' => (string) Str::uuid(), 'version' => 1, 'partnership_version' => 1, 'weights_hash' => str_repeat('0', 64), 'action' => 'withdraw', 'reason' => 'SYNTHETIC withdrawal independent of publication'];
|
|
$this->postJson('/parcerias/candidaturas/'.$interest->id, $withdraw)->assertOk();
|
|
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('version', 2);
|
|
$this->postJson('/parcerias/candidaturas/'.$interest->id, $withdraw)->assertOk()->assertJsonPath('replayed', true);
|
|
$this->postJson($this->url(), $this->input(['interest_id' => $interest->id, 'version' => 2]))->assertConflict();
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'status' => 'withdrawn', 'version' => 2]);
|
|
}
|
|
|
|
public function test_explicit_update_replay_preserves_later_decisions_and_requires_accepted_consent(): void
|
|
{
|
|
$interest = $this->interest();
|
|
$input = $this->input(['interest_id' => $interest->id, 'version' => 1, 'client_key' => (string) Str::uuid()]);
|
|
$this->postJson($this->url(), [...$input, 'payload' => ['consent' => false]])->assertUnprocessable();
|
|
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 2);
|
|
app(PartnershipExchange::class)->decideInterest($this->owner, $interest->id, 2, 'shortlist', 'SYNTHETIC shortlist after update');
|
|
$events = DB::table('partnership_events')->count();
|
|
$this->postJson($this->url(), $input)->assertOk()->assertJsonPath('version', 3)->assertJsonPath('replayed', true);
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $interest->id, 'status' => 'shortlisted', 'version' => 3]);
|
|
$this->assertSame($events, DB::table('partnership_events')->count());
|
|
}
|
|
|
|
public function test_organization_choices_keep_distinct_entities_with_equal_names(): void
|
|
{
|
|
$first = $this->organization($this->candidate);
|
|
$second = $this->organization($this->candidate);
|
|
$choices = $this->getJson('/parcerias/'.$this->record->id)->assertOk()->assertJsonCount(2, 'organizations')->json('organizations');
|
|
$this->assertEqualsCanonicalizing([$first, $second], array_column($choices, 'id'));
|
|
$this->assertSame($choices[0]['name'], $choices[1]['name']);
|
|
}
|
|
}
|