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

224 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\InvestmentDossier;
use App\Models\Project;
use App\Models\Sector;
use App\Models\User;
use App\Services\PartnershipExchange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
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 PartnershipPublicationHistoryTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private User $moderator;
private object $record;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
Queue::fake();
$this->owner = User::factory()->create();
$this->moderator = User::factory()->create();
config(['portal.managers' => [$this->moderator->email]]);
$this->record = app(PartnershipExchange::class)->save($this->owner, ['client_key' => (string) Str::uuid(), 'title' => 'SYNTHETIC original history title', 'kind' => 'seeking', 'partnership_type' => 'joint_venture', 'offer' => 'SYNTHETIC original offer', 'payload' => ['consent' => true, 'confidentiality' => 'private', 'mandatory' => ['capacity']]]);
$this->actingAs($this->owner);
}
private function url(?int $id = null): string
{
return '/parcerias/'.($id ?? $this->record->id).'/historico';
}
private function edit(array $data = []): object
{
$current = app(PartnershipExchange::class)->find($this->record->id);
return app(PartnershipExchange::class)->save($this->owner, ['version' => $current->version, 'kind' => 'seeking', ...$data], $current->id);
}
private function organization(User $person, string $role = 'officer'): string
{
$id = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $id, 'name' => 'SYNTHETIC versions organization', 'organization_type' => 'private']);
DB::table('memberships')->insert(['user_id' => $person->id, 'organization_id' => $id, 'role' => $role]);
return $id;
}
public function test_creation_edit_and_transition_have_real_before_and_after_versions_without_a_placeholder(): void
{
$edit = $this->edit(['title' => 'SYNTHETIC revised title']);
app(PartnershipExchange::class)->transition($this->owner, $edit->id, $edit->version, 'submit', 'SYNTHETIC reviewed content', (string) Str::uuid());
$this->getJson($this->url())->assertOk()->assertJsonCount(3, 'entries')->assertJsonPath('entries.0.action', 'submit')->assertJsonPath('entries.0.previous.fields.title', 'SYNTHETIC revised title')->assertJsonPath('entries.0.after.status', 'submitted')->assertJsonPath('entries.1.previous.fields.title', 'SYNTHETIC original history title')->assertJsonPath('entries.1.after.fields.title', 'SYNTHETIC revised title')->assertJsonPath('entries.2.action', 'create')->assertJsonPath('entries.2.previous.availability', 'not_recorded')->assertJsonPath('entries.2.after.version', $this->record->version);
}
public function test_publication_is_not_historical_access_for_another_candidate_or_anonymous_reader(): void
{
$service = app(PartnershipExchange::class);
$record = $service->transition($this->owner, $this->record->id, $this->record->version, 'submit', 'SYNTHETIC submit');
$record = $service->transition($this->moderator, $record->id, $record->version, 'publish', 'SYNTHETIC publish');
$candidate = User::factory()->create();
$service->interest($candidate, $record->id, ['proposal' => 'SYNTHETIC candidate', 'payload' => ['consent' => true]]);
$this->actingAs($candidate)->getJson($this->url())->assertForbidden();
$this->actingAs($this->moderator)->getJson($this->url())->assertOk();
auth()->forgetGuards();
$this->getJson($this->url())->assertUnauthorized();
}
public function test_changing_organizations_does_not_transfer_the_prior_private_snapshot(): void
{
$orgA = $this->organization($this->owner);
$this->edit(['organization_id' => $orgA, 'offer' => 'SYNTHETIC A-only offer']);
$orgB = $this->organization($this->owner);
$this->edit(['organization_id' => $orgB, 'offer' => 'SYNTHETIC B offer']);
$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.offer', 'SYNTHETIC B offer')->assertJsonPath('entries.0.previous.availability', 'restricted')->assertJsonPath('entries.1.restricted', true)->assertJsonPath('entries.2.restricted', true);
$this->assertStringNotContainsString('SYNTHETIC A-only offer', $response->getContent());
$this->postJson('/parcerias/'.$this->record->id.'/estado', ['version' => $this->record->version + 2, 'action' => 'archive', 'reason' => 'SYNTHETIC unauthorized action'])->assertForbidden();
$this->actingAs($this->moderator)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.fields.offer', 'SYNTHETIC A-only offer');
}
public function test_revoked_author_and_deleted_organization_do_not_keep_owner_history_access(): void
{
$org = $this->organization($this->owner);
$this->edit(['organization_id' => $org]);
DB::table('memberships')->where('user_id', $this->owner->id)->where('organization_id', $org)->update(['deleted_at' => now()]);
$this->getJson($this->url())->assertForbidden();
DB::table('memberships')->where('user_id', $this->owner->id)->where('organization_id', $org)->update(['deleted_at' => null]);
DB::table('organizations')->where('id', $org)->update(['deleted_at' => now()]);
$this->getJson($this->url())->assertForbidden();
$this->actingAs($this->moderator)->getJson($this->url())->assertOk();
}
public function test_legacy_unknown_scope_is_partial_for_moderation_and_not_reconstructed_from_current_content(): void
{
app(PartnershipExchange::class)->event($this->record, $this->owner, 'partnership_opportunity.changed.v1', ['previous_status' => 'published'], 'SYNTHETIC legacy event');
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['title' => 'SYNTHETIC current-only private title']);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.restricted', true)->assertJsonPath('entries.0.after.availability', 'scope_unknown');
$response = $this->actingAs($this->moderator)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.action', null)->assertJsonPath('entries.0.previous.status', 'published')->assertJsonPath('entries.0.previous.version', null)->assertJsonPath('entries.0.after.complete', false)->assertJsonPath('entries.0.after.status', null)->assertJsonPath('entries.0.after.scope_known', false);
$this->assertArrayNotHasKey('title', $response->json('entries.0.after.fields'));
$this->assertStringNotContainsString('SYNTHETIC current-only private title', $response->getContent());
}
public function test_known_legacy_context_authorizes_only_a_partial_recorded_state_and_reason(): void
{
DB::table('portal_partnerships')->where('id', $this->record->id)->update(['status' => 'returned']);
$record = app(PartnershipExchange::class)->find($this->record->id);
app(PartnershipExchange::class)->event($record, $this->owner, 'partnership_opportunity.returned.v1', ['from' => 'submitted', 'to' => 'returned', 'reason' => 'SYNTHETIC legacy recorded reason'], 'SYNTHETIC legacy notice', [$this->owner->id]);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.reason', 'SYNTHETIC legacy recorded reason')->assertJsonPath('entries.0.action', null)->assertJsonPath('entries.0.after.status', 'returned')->assertJsonPath('entries.0.after.complete', false)->assertJsonPath('entries.0.previous.availability', 'scope_unknown');
}
public function test_pagination_is_stable_when_new_versions_arrive_and_never_includes_candidacy_events(): void
{
for ($i = 1; $i <= 20; $i++) {
$this->edit(['title' => 'SYNTHETIC revision '.$i]);
}app(PartnershipExchange::class)->event(app(PartnershipExchange::class)->find($this->record->id), $this->owner, 'partnership_interest.changed.v1', [], 'SYNTHETIC other aggregate');
$first = $this->getJson($this->url())->assertOk()->assertJsonCount(10, 'entries')->assertJsonPath('entries.0.version', 22)->assertJsonPath('entries.9.version', 13);
$next = $first->json('next_url');
$this->edit(['title' => 'SYNTHETIC later revision']);
$second = $this->getJson($next)->assertOk()->assertJsonCount(10, 'entries')->assertJsonPath('entries.0.version', 12)->assertJsonPath('entries.9.version', 3);
$this->getJson($second->json('next_url'))->assertOk()->assertJsonCount(1, 'entries')->assertJsonPath('entries.0.version', 2)->assertJsonPath('next_url', null);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.version', 23);
}
public function test_cursor_is_bound_to_actor_process_and_purpose_and_never_grants_access_after_revocation(): void
{
$org = $this->organization($this->owner);
$this->edit(['organization_id' => $org]);
for ($i = 0; $i < 10; $i++) {
$this->edit(['title' => 'SYNTHETIC cursor revision '.$i]);
}$cursor = $this->getJson($this->url())->assertOk()->json('next_url');
$this->assertIsString($cursor);
$this->actingAs($this->moderator)->getJson($cursor)->assertUnprocessable();
$this->actingAs($this->owner);
$other = app(PartnershipExchange::class)->save($this->owner, ['client_key' => (string) Str::uuid(), 'kind' => 'seeking']);
$this->getJson($this->url($other->id).'?'.parse_url($cursor, PHP_URL_QUERY))->assertUnprocessable();
$this->getJson($this->url().'?before=invalid-token')->assertUnprocessable();
$token = Crypt::encryptString(json_encode(['actor' => $this->owner->id, 'interest' => $this->record->id, 'before' => 999]));
$this->getJson($this->url().'?before='.rawurlencode($token))->assertUnprocessable();
DB::table('memberships')->where('organization_id', $org)->where('user_id', $this->owner->id)->update(['deleted_at' => now()]);
$this->getJson($cursor)->assertForbidden();
}
public function test_native_history_is_read_only_private_and_does_not_serialize_command_or_event_envelopes(): void
{
app(PartnershipExchange::class)->transition($this->owner, $this->record->id, $this->record->version, 'archive', 'SYNTHETIC archive', (string) Str::uuid());
$row = DB::table('portal_partnerships')->where('id', $this->record->id)->first();
$events = DB::table('partnership_events')->count();
$audits = DB::table('audit_events')->count();
$notices = 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/publication-history')->assertJsonPath('encryptHistory', true);
$this->assertTrue($response->headers->hasCacheControlDirective('private'));
$this->assertTrue($response->headers->hasCacheControlDirective('no-store'));
foreach (['command_hash', 'client_key', 'aggregate_version', '_notification_context', '_creation_hash'] as $private) {
$this->assertStringNotContainsString($private, $response->getContent());
}
$this->assertEquals($row, DB::table('portal_partnerships')->where('id', $this->record->id)->first());
$this->assertSame($events, DB::table('partnership_events')->count());
$this->assertSame($audits, DB::table('audit_events')->count());
$this->assertSame($notices, DB::table('partnership_notifications')->count());
}
public function test_recorded_references_and_declarations_survive_current_catalogue_changes_and_explicit_clearing(): void
{
$place = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $place, 'code' => $place, 'name' => 'SYNTHETIC location', 'location_type' => 'country', 'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:synthetic-'.substr($place, 0, 8)]);
$project = Project::factory()->create();
InvestmentDossier::create(['project_id' => $project->id, 'title' => 'SYNTHETIC source', 'source' => 'Synthetic fixture only', 'content' => [], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'data_classification' => 'public', 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA fixture only']);
$sector = Sector::create(['name' => 'SYNTHETIC sector', 'slug' => 'synthetic-'.Str::uuid(), 'is_active' => true]);
$this->edit(['sector_id' => $sector->id, 'origins' => [['type' => 'project', 'target_id' => $project->id]], 'locations' => [['geo_location_id' => $place, 'role' => 'coverage', 'source_reference' => 'SYNTHETIC original declared source']]]);
$this->edit(['sector_id' => null, 'offer' => null, 'origins' => [], 'locations' => [], 'payload' => ['consent' => false, 'mandatory' => []]]);
DB::table('geo_locations')->where('id', $place)->update(['name' => 'SYNTHETIC renamed current location']);
$sector->update(['is_active' => false]);
$response = $this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.previous.sector_id', (string) $sector->id)->assertJsonPath('entries.0.previous.origins.0.target_id', $project->id)->assertJsonPath('entries.0.previous.locations.0.source_reference', 'SYNTHETIC original declared source')->assertJsonPath('entries.0.after.fields.offer', '')->assertJsonPath('entries.0.after.consent', false)->assertJsonPath('entries.0.after.mandatory_known', true)->assertJsonPath('entries.0.after.mandatory', [])->assertJsonPath('entries.0.after.origins', []);
$this->assertStringNotContainsString('SYNTHETIC renamed current location', $response->getContent());
}
public function test_malformed_scope_or_references_are_unknown_instead_of_a_different_proposal_or_an_empty_source_list(): void
{
$event = DB::table('partnership_events')->where('partnership_id', $this->record->id)->first();
$payload = json_decode($event->payload, true);
$original = $payload['payload']['applied'];
$payload['payload']['applied']['id'] = $this->record->id + 999;
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]);
$this->actingAs($this->moderator)->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.availability', 'scope_unknown');
$payload['payload']['applied'] = $original;
$payload['payload']['applied']['origins'] = [['invalid' => true]];
$payload['payload']['applied']['locations'] = [['geo_location_id' => 'invalid identity']];
$payload['payload']['applied']['sector_id'] = ['invalid'];
DB::table('partnership_events')->where('id', $event->id)->update(['payload' => json_encode($payload)]);
$this->getJson($this->url())->assertOk()->assertJsonPath('entries.0.after.origins_incomplete', true)->assertJsonPath('entries.0.after.locations_incomplete', true)->assertJsonPath('entries.0.after.complete', false)->assertJsonPath('entries.0.after.sector_recorded', false);
}
public function test_repeating_creation_does_not_add_a_false_revision_or_rewrite_a_snapshot(): void
{
$service = app(PartnershipExchange::class);
$input = ['client_key' => (string) Str::uuid(), 'kind' => 'seeking', 'title' => 'SYNTHETIC replay'];
$record = $service->save($this->owner, $input);
$before = DB::table('partnership_events')->where('partnership_id', $record->id)->first();
$service->save($this->owner, $input);
$this->assertSame(1, DB::table('partnership_events')->where('partnership_id', $record->id)->count());
$this->assertEquals($before, DB::table('partnership_events')->where('partnership_id', $record->id)->first());
}
}