187 lines
12 KiB
PHP
187 lines
12 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 PartnershipParticipantsTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->withoutVite();
|
|
Http::fake();
|
|
Mail::fake();
|
|
Queue::fake();
|
|
}
|
|
|
|
private function organization(string $name): string
|
|
{
|
|
$id = (string) Str::uuid();
|
|
DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC '.$name, 'source_system' => 'unit-synthetic', 'organization_type' => 'private']);
|
|
|
|
return $id;
|
|
}
|
|
|
|
private function member(User $user, string $organization, string $role = 'officer'): void
|
|
{
|
|
DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $organization, 'role' => $role]);
|
|
}
|
|
|
|
private function room(?string $proponentOrganization = null, ?string $candidateOrganization = null): array
|
|
{
|
|
$owner = User::factory()->create(['name' => 'SYNTHETIC original proponent']);
|
|
$candidate = User::factory()->create(['name' => 'SYNTHETIC original candidate']);
|
|
if ($proponentOrganization) {
|
|
$this->member($owner, $proponentOrganization);
|
|
}
|
|
if ($candidateOrganization) {
|
|
$this->member($candidate, $candidateOrganization);
|
|
}
|
|
$service = app(PartnershipExchange::class);
|
|
$record = $service->save($owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC partnership', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'organization_id' => $proponentOrganization, 'offer' => 'SYNTHETIC offer', 'seeking' => 'SYNTHETIC cooperation', 'payload' => ['consent' => true]]);
|
|
$service->transition($owner, $record->id, $record->version, 'submit', 'SYNTHETIC consent to review');
|
|
$moderator = User::factory()->create();
|
|
config(['portal.managers' => [$moderator->email]]);
|
|
$record = $service->find($record->id);
|
|
$service->transition($moderator, $record->id, $record->version, 'publish', 'SYNTHETIC reviewed');
|
|
$interest = $service->interest($candidate, $record->id, ['organization_id' => $candidateOrganization, 'proposal' => 'SYNTHETIC proposal', 'payload' => ['consent' => true]]);
|
|
$interest = $service->decideInterest($owner, $interest->id, $interest->version, 'shortlist', 'SYNTHETIC consideration');
|
|
$service->decideInterest($owner, $interest->id, $interest->version, 'connect', 'SYNTHETIC connection');
|
|
$room = DB::table('partnership_rooms')->where('interest_id', $interest->id)->first();
|
|
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 1, 'action' => 'record', 'title' => 'SYNTHETIC plan', 'terms' => 'SYNTHETIC terms', 'reason' => 'SYNTHETIC record'])->assertOk();
|
|
|
|
return [$room->id, $owner, $candidate];
|
|
}
|
|
|
|
public function test_authorized_representatives_are_selectable_and_accepted_without_exposing_account_secrets(): void
|
|
{
|
|
$proponentOrganization = $this->organization('proponent');
|
|
$candidateOrganization = $this->organization('candidate');
|
|
[$room, $owner, $candidate] = $this->room($proponentOrganization, $candidateOrganization);
|
|
$officer = User::factory()->create(['name' => 'SYNTHETIC current proponent officer']);
|
|
$manager = User::factory()->create(['name' => 'SYNTHETIC current candidate manager']);
|
|
$viewer = User::factory()->create();
|
|
$outsider = User::factory()->create();
|
|
$this->member($officer, $proponentOrganization);
|
|
$this->member($manager, $candidateOrganization, 'manager');
|
|
$this->member($viewer, $proponentOrganization, 'viewer');
|
|
$otherOrganization = $this->organization('unrelated');
|
|
$this->member($outsider, $otherOrganization);
|
|
$response = $this->actingAs($officer)->getJson('/parcerias/salas/'.$room)->assertOk();
|
|
$choices = collect($response->json('participants'))->keyBy('id');
|
|
$this->assertEqualsCanonicalizing([$owner->id, $candidate->id, $officer->id, $manager->id], $choices->keys()->all());
|
|
$this->assertSame(['proponent'], $choices[$officer->id]['parties']);
|
|
$this->assertSame(['candidate'], $choices[$manager->id]['parties']);
|
|
foreach ($choices as $choice) {
|
|
$this->assertEqualsCanonicalizing(['id', 'name', 'parties'], array_keys($choice));
|
|
}
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC representative deliverable', 'responsible_id' => $manager->id, 'status' => 'pending'])->assertOk();
|
|
$this->assertDatabaseHas('partnership_milestones', ['room_id' => $room, 'responsible_id' => $manager->id]);
|
|
$this->getJson('/parcerias/salas/'.$room)->assertJsonPath('milestones.0.responsible_name', $manager->name)->assertJsonPath('milestones.0.responsible_is_participant', true);
|
|
}
|
|
|
|
public function test_revoked_author_is_historical_but_not_silently_reassigned_or_selectable(): void
|
|
{
|
|
$organization = $this->organization('revocation');
|
|
[$room, $owner] = $this->room($organization);
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC assigned before revocation', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertOk();
|
|
$milestone = DB::table('partnership_milestones')->where('room_id', $room)->first();
|
|
$officer = User::factory()->create(['name' => 'SYNTHETIC replacement representative']);
|
|
$this->member($officer, $organization);
|
|
DB::table('memberships')->where('user_id', $owner->id)->where('organization_id', $organization)->update(['deleted_at' => now()]);
|
|
$events = DB::table('partnership_events')->count();
|
|
$response = $this->actingAs($officer)->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('milestones.0.responsible_name', $owner->name)->assertJsonPath('milestones.0.responsible_is_participant', false);
|
|
$this->assertNotContains($owner->id, array_column($response->json('participants'), 'id'));
|
|
$this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $owner->id, 'version' => 1]);
|
|
$this->assertSame($events, DB::table('partnership_events')->count());
|
|
$input = ['version' => 1, 'title' => $milestone->title, 'responsible_id' => $owner->id, 'status' => 'pending'];
|
|
$this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, $input)->assertUnprocessable();
|
|
$this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, [...$input, 'responsible_id' => $officer->id])->assertOk();
|
|
$this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $officer->id, 'version' => 2]);
|
|
$event = DB::table('partnership_events')->where('event_type', 'partnership_milestone.changed.v1')->orderByDesc('aggregate_version')->first();
|
|
$payload = json_decode($event->payload, true)['payload'];
|
|
$this->assertSame($owner->id, $payload['previous_responsible_id']);
|
|
$this->assertSame($officer->id, $payload['responsible_id']);
|
|
$this->actingAs($owner)->getJson('/parcerias/salas/'.$room)->assertForbidden();
|
|
}
|
|
|
|
public function test_personal_parties_are_not_expanded_by_unrelated_memberships_and_dual_representation_is_deduplicated(): void
|
|
{
|
|
[$room, $owner, $candidate] = $this->room();
|
|
$organization = $this->organization('unrelated personal membership');
|
|
$this->member($owner, $organization);
|
|
$other = User::factory()->create();
|
|
$this->member($other, $organization);
|
|
$response = $this->getJson('/parcerias/salas/'.$room)->assertOk();
|
|
$this->assertEqualsCanonicalizing([$owner->id, $candidate->id], array_column($response->json('participants'), 'id'));
|
|
$this->actingAs($other)->getJson('/parcerias/salas/'.$room)->assertForbidden();
|
|
$a = $this->organization('dual a');
|
|
$b = $this->organization('dual b');
|
|
[$secondRoom] = $this->room($a, $b);
|
|
$dual = User::factory()->create();
|
|
$this->member($dual, $a);
|
|
$this->member($dual, $b);
|
|
$choices = $this->actingAs($dual)->getJson('/parcerias/salas/'.$secondRoom)->assertOk()->json('participants');
|
|
$matching = array_values(array_filter($choices, fn ($choice) => $choice['id'] === $dual->id));
|
|
$this->assertCount(1, $matching);
|
|
$this->assertSame(['proponent', 'candidate'], $matching[0]['parties']);
|
|
}
|
|
|
|
public function test_read_only_and_closed_rooms_do_not_offer_assignment_choices_and_unrelated_users_are_denied(): void
|
|
{
|
|
$organization = $this->organization('read only');
|
|
[$room, $owner] = $this->room($organization);
|
|
$viewer = User::factory()->create();
|
|
$this->member($viewer, $organization, 'viewer');
|
|
$this->actingAs($viewer)->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('canWrite', false)->assertJsonPath('participants', []);
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC refused', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertForbidden();
|
|
$this->actingAs(User::factory()->create())->getJson('/parcerias/salas/'.$room)->assertForbidden();
|
|
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room.'/acordo', ['version' => 2, 'action' => 'cancel', 'reason' => 'SYNTHETIC cancelled'])->assertOk();
|
|
$this->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonPath('participants', []);
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC refused', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertConflict();
|
|
}
|
|
|
|
public function test_cached_choices_do_not_authorize_assignment_after_downgrade_and_unrelated_ids_are_refused(): void
|
|
{
|
|
$organization = $this->organization('cached selection');
|
|
[$room, $owner, $candidate] = $this->room($organization);
|
|
$officer = User::factory()->create();
|
|
$this->member($officer, $organization);
|
|
$this->getJson('/parcerias/salas/'.$room)->assertOk()->assertJsonFragment(['id' => $officer->id, 'name' => $officer->name, 'parties' => ['proponent']]);
|
|
DB::table('memberships')->where('user_id', $officer->id)->where('organization_id', $organization)->update(['role' => 'viewer']);
|
|
$input = ['title' => 'SYNTHETIC rejected cached assignment', 'responsible_id' => $officer->id, 'status' => 'pending'];
|
|
$events = DB::table('partnership_events')->count();
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', $input)->assertUnprocessable();
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', [...$input, 'responsible_id' => User::factory()->create()->id])->assertUnprocessable();
|
|
$this->assertDatabaseCount('partnership_milestones', 0);
|
|
$this->assertSame($events, DB::table('partnership_events')->count());
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', [...$input, 'responsible_id' => $candidate->id])->assertOk();
|
|
$milestone = DB::table('partnership_milestones')->where('room_id', $room)->first();
|
|
$this->putJson('/parcerias/salas/'.$room.'/marcos/'.$milestone->id, [...$input, 'responsible_id' => $owner->id, 'version' => 99])->assertConflict();
|
|
$this->assertDatabaseHas('partnership_milestones', ['id' => $milestone->id, 'responsible_id' => $candidate->id, 'version' => 1]);
|
|
}
|
|
|
|
public function test_deleted_organizations_are_not_assignment_sources_and_personal_participant_remains_available(): void
|
|
{
|
|
$organization = $this->organization('deleted');
|
|
[$room, $owner, $candidate] = $this->room($organization);
|
|
DB::table('organizations')->where('id', $organization)->update(['deleted_at' => now()]);
|
|
$response = $this->actingAs($candidate)->getJson('/parcerias/salas/'.$room)->assertOk();
|
|
$this->assertSame([['id' => $candidate->id, 'name' => $candidate->name, 'parties' => ['candidate']]], $response->json('participants'));
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC unavailable organization', 'responsible_id' => $owner->id, 'status' => 'pending'])->assertUnprocessable();
|
|
$this->postJson('/parcerias/salas/'.$room.'/marcos', ['title' => 'SYNTHETIC personal participant', 'responsible_id' => $candidate->id, 'status' => 'pending'])->assertOk();
|
|
$this->actingAs($owner)->getJson('/parcerias/salas/'.$room)->assertForbidden();
|
|
}
|
|
}
|