Files
Investidor-V2/tests/Feature/PartnershipDashboardPrivacyTest.php

325 lines
23 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 Illuminate\Testing\TestResponse;
use Tests\TestCase;
final class PartnershipDashboardPrivacyTest 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 published title', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC offer', 'payload' => ['consent' => true]]);
$record = $service->transition($this->owner, $record->id, $record->version, 'submit', 'SYNTHETIC submit');
$this->record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish');
$this->interest = $service->interest($this->candidate, $record->id, ['client_key' => (string) Str::uuid(), 'proposal' => 'SYNTHETIC candidate', 'payload' => ['consent' => true]]);
$this->actingAs($this->candidate);
}
private function panel(): TestResponse
{
$version = app(HandleInertiaRequests::class)->version(Request::create('/parcerias/painel'));
return $this->get('/parcerias/painel', ['X-Inertia' => 'true', 'X-Inertia-Version' => $version ?? '']);
}
private function organization(User $person, string $role = 'officer'): string
{
$id = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC dashboard organization', 'organization_type' => 'private']);
DB::table('memberships')->insert(['user_id' => $person->id, 'organization_id' => $id, 'role' => $role]);
return $id;
}
public function test_dashboard_does_not_disclose_current_title_after_unpublication(): void
{
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'draft', 'title' => 'SYNTHETIC undisclosed private replacement']);
$response = $this->panel()->assertOk()->assertJsonPath('props.interests.0.title', null)->assertJsonPath('props.interests.0.partnership_href', null);
$this->assertStringNotContainsString('SYNTHETIC undisclosed private replacement', $response->getContent());
}
public function test_deleted_organization_does_not_leave_its_drafts_in_the_panel(): void
{
$org = $this->organization($this->candidate);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org, 'status' => 'draft', 'title' => 'SYNTHETIC deleted organization draft']);
DB::table('partnership_interests')->where('id', $this->interest->id)->update(['organization_id' => $org]);
DB::table('organizations')->where('id', $org)->update(['deleted_at' => now()]);
$response = $this->panel()->assertOk()->assertJsonCount(0, 'props.mine')->assertJsonCount(0, 'props.interests');
$this->assertStringNotContainsString('SYNTHETIC deleted organization draft', $response->getContent());
}
public function test_revoked_candidate_does_not_read_or_acknowledge_old_notification(): 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 candidate', 'payload' => ['consent' => true]]);
app(PartnershipExchange::class)->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC clarification');
$notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->orderByDesc('id')->first();
DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]);
$response = $this->panel()->assertOk();
$ids = array_column($response->json('props.notifications'), 'id');
$this->assertNotContains($notice->id, $ids);
$this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound();
$this->assertNull(DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at'));
}
public function test_current_parent_reader_or_moderator_keeps_the_authorized_proposal_link(): void
{
$this->panel()->assertOk()->assertJsonPath('props.interests.0.title', 'SYNTHETIC published title')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'draft']);
$org = $this->organization($this->owner);
DB::table('memberships')->insert(['user_id' => $this->candidate->id, 'organization_id' => $org, 'role' => 'viewer']);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org]);
$this->panel()->assertOk()->assertJsonPath('props.mine.0.title', 'SYNTHETIC published title')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id);
config(['portal.managers' => [$this->candidate->email]]);
DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['deleted_at' => now()]);
$this->panel()->assertOk()->assertJsonCount(0, 'props.mine')->assertJsonPath('props.interests.0.partnership_href', '/parcerias/'.$this->record->id);
}
public function test_new_notices_follow_current_operational_participants_without_notifying_revoked_authors(): void
{
$proponentOrg = $this->organization($this->owner);
$candidateOrg = $this->organization($this->candidate);
$proponent = User::factory()->create();
$candidate = User::factory()->create();
$viewer = User::factory()->create();
DB::table('memberships')->insert([['user_id' => $proponent->id, 'organization_id' => $proponentOrg, 'role' => 'manager'], ['user_id' => $candidate->id, 'organization_id' => $candidateOrg, 'role' => 'officer'], ['user_id' => $viewer->id, 'organization_id' => $candidateOrg, 'role' => 'viewer']]);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $proponentOrg]);
app(PartnershipExchange::class)->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $candidateOrg, 'proposal' => 'SYNTHETIC organizational candidate', 'payload' => ['consent' => true]]);
DB::table('memberships')->whereIn('user_id', [$this->owner->id, $this->candidate->id])->update(['deleted_at' => now()]);
app(PartnershipExchange::class)->decideInterest($proponent, $this->interest->id, 2, 'clarify', 'SYNTHETIC current representatives');
$event = DB::table('partnership_events')->where('partnership_id', $this->record->id)->orderByDesc('aggregate_version')->first();
$recipients = DB::table('partnership_notifications')->where('event_id', $event->id)->pluck('user_id')->all();
sort($recipients);
$expected = [$proponent->id, $candidate->id];
sort($expected);
$this->assertSame($expected, $recipients);
$payload = json_decode($event->payload, true)['payload']['_notification_context'];
$this->assertSame($proponentOrg, $payload['partnership']['organization_id']);
$this->assertSame($candidateOrg, $payload['interest']['organization_id']);
$this->actingAs($candidate);
$this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico');
$this->actingAs($viewer);
$this->panel()->assertOk()->assertJsonCount(0, 'props.notifications');
}
public function test_change_to_another_organization_does_not_transfer_old_notices_to_the_new_representation(): void
{
$orgA = $this->organization($this->candidate);
$representative = User::factory()->create();
DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgA, 'role' => 'officer']);
$service = app(PartnershipExchange::class);
$service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $orgA, 'proposal' => 'SYNTHETIC organization A', 'payload' => ['consent' => true]]);
$service->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC A clarification');
$old = DB::table('partnership_notifications')->where('user_id', $representative->id)->orderByDesc('id')->first();
$orgB = $this->organization($this->candidate);
DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgB, 'role' => 'officer']);
$service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 3, 'organization_id' => $orgB, 'proposal' => 'SYNTHETIC organization B', 'payload' => ['consent' => true]]);
DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $representative->id)->update(['deleted_at' => now()]);
$this->actingAs($representative);
$response = $this->panel()->assertOk()->assertJsonCount(1, 'props.notifications');
$this->assertNotContains($old->id, array_column($response->json('props.notifications'), 'id'));
$this->post('/parcerias/notificacoes/'.$old->id.'/ler')->assertNotFound();
}
public function test_historical_scope_is_required_even_when_current_proponent_organization_is_authorized(): void
{
$orgA = $this->organization($this->owner);
$representative = User::factory()->create();
DB::table('memberships')->insert(['user_id' => $representative->id, 'organization_id' => $orgA, 'role' => 'officer']);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $orgA]);
$service = app(PartnershipExchange::class);
$service->event($service->find($this->record->id), $this->owner, 'partnership_opportunity.changed.v1', [], 'SYNTHETIC A private notice', [$this->owner->id]);
$old = DB::table('partnership_notifications')->where('user_id', $representative->id)->orderByDesc('id')->first();
$orgB = $this->organization($representative);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $orgB]);
DB::table('memberships')->where('organization_id', $orgA)->where('user_id', $representative->id)->update(['deleted_at' => now()]);
$this->actingAs($representative);
$this->panel()->assertOk()->assertJsonCount(0, 'props.notifications');
$this->post('/parcerias/notificacoes/'.$old->id.'/ler')->assertNotFound();
}
public function test_legacy_candidate_snapshot_is_reused_without_inventing_missing_proponent_scope(): void
{
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first();
$payload = json_decode($event->payload, true);
unset($payload['payload']['_notification_context']);
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]);
$this->panel()->assertOk()->assertJsonCount(1, 'props.notifications')->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico');
$this->actingAs($this->owner);
$response = $this->panel()->assertOk();
$candidateNotice = DB::table('partnership_notifications')->where('event_id', $event->id)->where('user_id', $this->owner->id)->first();
$this->assertNotContains($candidateNotice->id, array_column($response->json('props.notifications'), 'id'));
unset($payload['payload']['submitted']['organization_id']);
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]);
$this->actingAs($this->candidate);
$this->panel()->assertOk()->assertJsonCount(0, 'props.notifications');
$this->assertSame(2, DB::table('partnership_notifications')->where('event_id', $event->id)->count());
}
public function test_inaccessible_rows_do_not_consume_the_visible_limit_or_hide_an_older_authorized_notice(): void
{
$record = app(PartnershipExchange::class)->find($this->record->id);
$sequence = DB::table('partnership_events')->where('partnership_id', $record->id)->max('aggregate_version');
$events = [];
$notices = [];
for ($i = 1; $i <= 105; $i++) {
$event = (string) Str::uuid();
$events[] = ['id' => $event, 'partnership_id' => $record->id, 'actor_id' => $this->owner->id, 'event_type' => 'partnership_opportunity.changed.v1', 'aggregate_version' => $sequence + $i, 'payload' => json_encode(['payload' => ['_notification_context' => ['version' => 1, 'partnership' => ['id' => $record->id, 'user_id' => $this->owner->id, 'organization_id' => null], 'interest' => null, 'room_id' => null]]])];
$notices[] = ['event_id' => $event, 'user_id' => $this->candidate->id, 'title' => 'SYNTHETIC inaccessible title', 'href' => 'javascript:synthetic()'];
}
DB::table('partnership_events')->insert($events);
DB::table('partnership_notifications')->insert($notices);
$response = $this->panel()->assertOk()->assertJsonCount(1, 'props.notifications');
$this->assertStringNotContainsString('SYNTHETIC inaccessible title', $response->getContent());
$this->assertStringNotContainsString('javascript:', $response->getContent());
}
public function test_notification_link_is_rebuilt_and_marking_read_is_idempotent_without_business_events(): void
{
$notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->first();
DB::table('partnership_notifications')->where('id', $notice->id)->update(['href' => 'javascript:synthetic()']);
$events = DB::table('partnership_events')->count();
$audits = DB::table('audit_events')->count();
$this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/candidaturas/'.$this->interest->id.'/historico');
$this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect();
$first = DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at');
$this->assertNotNull($first);
$this->travel(5)->minutes();
$this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect();
$this->assertSame($first, DB::table('partnership_notifications')->where('id', $notice->id)->value('read_at'));
$this->assertSame($events, DB::table('partnership_events')->count());
$this->assertSame($audits, DB::table('audit_events')->count());
$this->actingAs($this->owner)->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound();
auth()->forgetGuards();
$this->postJson('/parcerias/notificacoes/'.$notice->id.'/ler')->assertUnauthorized();
}
public function test_room_notices_link_to_the_current_authorized_room_and_message_replay_does_not_duplicate_delivery(): void
{
$service = app(PartnershipExchange::class);
$service->decideInterest($this->owner, $this->interest->id, 1, 'shortlist', 'SYNTHETIC shortlist');
$service->decideInterest($this->owner, $this->interest->id, 2, 'connect', 'SYNTHETIC connect');
$room = DB::table('partnership_rooms')->where('interest_id', $this->interest->id)->first();
$data = ['client_key' => (string) Str::uuid(), 'body' => 'SYNTHETIC message'];
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', $data)->assertOk();
$count = DB::table('partnership_notifications')->count();
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', $data)->assertOk();
$this->assertSame($count, DB::table('partnership_notifications')->count());
$this->panel()->assertOk()->assertJsonPath('props.notifications.0.href', '/parcerias/salas/'.$room->id);
}
public function test_malformed_explicit_context_does_not_fall_back_to_a_legacy_snapshot(): void
{
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first();
$payload = json_decode($event->payload, true);
$notice = DB::table('partnership_notifications')->where('event_id', $event->id)->where('user_id', $this->candidate->id)->first();
foreach ([null, ['version' => 99], ['version' => 1, 'partnership' => ['id' => $this->record->id + 999, 'user_id' => $this->owner->id, 'organization_id' => null]]] as $invalid) {
$payload['payload']['_notification_context'] = $invalid;
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]);
$this->panel()->assertOk()->assertJsonCount(0, 'props.notifications');
$this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertNotFound();
}
}
public function test_a_read_only_representative_keeps_reading_an_existing_notice_without_receiving_new_operational_delivery(): void
{
$org = $this->organization($this->candidate);
$service = app(PartnershipExchange::class);
$service->interest($this->candidate, $this->record->id, ['interest_id' => $this->interest->id, 'version' => 1, 'organization_id' => $org, 'proposal' => 'SYNTHETIC organization', 'payload' => ['consent' => true]]);
$notice = DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->orderByDesc('id')->first();
DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->candidate->id)->update(['role' => 'viewer']);
$this->panel()->assertOk()->assertJsonPath('props.notifications.0.id', $notice->id);
$this->post('/parcerias/notificacoes/'.$notice->id.'/ler')->assertRedirect();
$service->decideInterest($this->owner, $this->interest->id, 2, 'clarify', 'SYNTHETIC later decision');
$this->assertSame(2, DB::table('partnership_notifications')->where('user_id', $this->candidate->id)->count());
$this->postJson('/parcerias/candidaturas/'.$this->interest->id, ['version' => 3, 'action' => 'withdraw', 'reason' => 'SYNTHETIC unauthorized write'])->assertForbidden();
}
public function test_visible_limit_keeps_the_latest_fifty_authorized_notifications_in_order(): void
{
$event = DB::table('partnership_events')->where('event_type', 'partnership_interest.submitted.v1')->first();
$payload = $event->payload;
$sequence = DB::table('partnership_events')->where('partnership_id', $this->record->id)->max('aggregate_version');
$events = [];
$notices = [];
for ($i = 1; $i <= 55; $i++) {
$id = (string) Str::uuid();
$events[] = ['id' => $id, 'partnership_id' => $this->record->id, 'actor_id' => $this->candidate->id, 'event_type' => 'partnership_interest.submitted.v1', 'aggregate_version' => $sequence + $i, 'payload' => $payload];
$notices[] = ['event_id' => $id, 'user_id' => $this->candidate->id, 'title' => 'SYNTHETIC notice '.$i, 'href' => '/unused'];
}
DB::table('partnership_events')->insert($events);
DB::table('partnership_notifications')->insert($notices);
$response = $this->panel()->assertOk()->assertJsonCount(50, 'props.notifications')->assertJsonPath('props.notifications.0.title', 'SYNTHETIC notice 55')->assertJsonPath('props.notifications.49.title', 'SYNTHETIC notice 6');
$ids = array_column($response->json('props.notifications'), 'id');
$sorted = $ids;
rsort($sorted);
$this->assertSame($sorted, $ids);
$this->assertCount(50, array_unique($ids));
}
public function test_an_inconsistent_event_context_rolls_back_without_cross_partnership_delivery(): void
{
$service = app(PartnershipExchange::class);
$other = $service->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC other partnership', 'kind' => 'seeking']);
$events = DB::table('partnership_events')->count();
$notices = DB::table('partnership_notifications')->count();
$audits = DB::table('audit_events')->count();
try {
$service->event($other, $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id], 'SYNTHETIC inconsistent context', [$this->candidate->id]);
$this->fail('A foreign candidacy must not produce a notification.');
} catch (\LogicException $error) {
$this->assertSame('Notification belongs to another partnership.', $error->getMessage());
}
$this->assertSame($events, DB::table('partnership_events')->count());
$this->assertSame($notices, DB::table('partnership_notifications')->count());
$this->assertSame($audits, DB::table('audit_events')->count());
}
public function test_events_without_a_notification_intent_remain_without_delivery_and_shared_parties_are_deduplicated(): void
{
$service = app(PartnershipExchange::class);
$before = DB::table('partnership_notifications')->count();
$service->event($service->find($this->record->id), $this->owner, 'partnership.document_accessed.v1', [], 'SYNTHETIC read only');
$this->assertSame($before, DB::table('partnership_notifications')->count());
$org = $this->organization($this->owner);
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['organization_id' => $org]);
DB::table('partnership_interests')->where('id', $this->interest->id)->update(['organization_id' => $org]);
$service->event($service->find($this->record->id), $this->owner, 'partnership_interest.changed.v1', ['interest_id' => $this->interest->id], 'SYNTHETIC same party', [$this->owner->id, $this->candidate->id]);
$event = DB::table('partnership_events')->where('partnership_id', $this->record->id)->orderByDesc('aggregate_version')->first();
$this->assertSame([$this->owner->id], DB::table('partnership_notifications')->where('event_id',$event->id)->pluck('user_id')->all());
}
}