224 lines
16 KiB
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());
|
|
}
|
|
}
|