238 lines
16 KiB
PHP
238 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Tests\TestCase;
|
|
|
|
class ExistingBusinessIntakeTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private string $base = '/api/v1/existing-business/listings';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
config(['existing-business.enabled' => true]);
|
|
}
|
|
|
|
private function reviewer(): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
|
|
config(['portal.managers' => [$user->email], 'existing-business.reviewers' => [$user->email]]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function designated(string $role): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
|
|
config(['portal.managers' => [...config('portal.managers', []), $user->email],
|
|
'existing-business.'.$role => [...config('existing-business.'.$role, []), strtolower($user->email)]]);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function draft(): array
|
|
{
|
|
return ['modality' => 'participation_sale', 'classification' => 'restricted', 'reason' => 'Synthetic initial draft',
|
|
'payload' => ['title' => 'Private synthetic business', 'description' => 'Synthetic reserved description for the internal review only.',
|
|
'source_reference' => 'Synthetic source reference', 'valid_until' => today()->addMonth()->toDateString()]];
|
|
}
|
|
|
|
public function test_private_intake_review_information_and_withdrawal_preserve_isolation(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$reviewer = $this->reviewer();
|
|
$triager = $this->designated('triagers');
|
|
$this->actingAs($owner);
|
|
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
|
$url = $this->base.'/'.$id;
|
|
$this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private');
|
|
$this->actingAs($reviewer)->getJson($url)->assertForbidden();
|
|
$this->actingAs($owner)->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested'])->assertUnprocessable();
|
|
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested', 'review_consent' => true])->assertOk()->assertJsonPath('version_no', 2);
|
|
$this->putJson($url, [...$this->draft(), 'version_no' => 2])->assertForbidden();
|
|
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Attempted self review'])->assertForbidden();
|
|
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'triage', 'note' => 'Synthetic triage completed'])->assertOk()->assertJsonPath('version_no', 3);
|
|
$this->actingAs($reviewer)->getJson('/api/v1/existing-business/reviews')->assertOk()->assertJsonPath('records.total', 1);
|
|
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertConflict();
|
|
$this->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk()->assertJsonPath('version_no', 4);
|
|
$this->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'needs_information', 'note' => 'Please clarify synthetic source'])->assertOk();
|
|
$this->actingAs($owner)->getJson($url)->assertOk()->assertJsonPath('history.0.note', 'Please clarify synthetic source');
|
|
$this->postJson($url, [...$this->draft(), '_method' => 'PUT', 'version_no' => 5])->assertOk()->assertJsonPath('version_no', 6);
|
|
$this->postJson($url.'/transitions', ['version_no' => 6, 'action' => 'withdrawn', 'note' => 'Synthetic withdrawal requested'])->assertOk();
|
|
$this->actingAs($reviewer)->getJson($url)->assertForbidden();
|
|
$this->getJson('/api/v1/existing-business/reviews')->assertJsonPath('records.total', 0);
|
|
$this->assertDatabaseCount('existing_business_reviews', 5);
|
|
$this->assertDatabaseCount('existing_business_revisions', 7);
|
|
$this->assertDatabaseCount('business_workflow_transitions', 5);
|
|
$this->assertDatabaseCount('outbox_events', 1);
|
|
$transition = DB::table('business_workflow_transitions')->where('to_status', 'needs_information')->first();
|
|
$this->assertSame('in_review', $transition->from_status);
|
|
$this->assertStringNotContainsString('Please clarify', $transition->reason);
|
|
$this->assertStringNotContainsString('Please clarify', DB::table('existing_business_reviews')->where('action', 'needs_information')->value('note'));
|
|
}
|
|
|
|
public function test_granular_verification_requires_independent_review_and_invalidates_after_changes(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$reviewer = $this->reviewer();
|
|
$this->actingAs($owner);
|
|
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
|
$url = $this->base.'/'.$id;
|
|
$finding = ['version_no' => 3, 'check' => 'identity', 'outcome' => 'confirmed',
|
|
'evidence_reference' => 'Synthetic identity evidence', 'note' => 'Synthetic independent review',
|
|
'valid_until' => today()->addDay()->toDateString()];
|
|
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested', 'review_consent' => true])->assertOk();
|
|
$this->postJson($url.'/verifications', $finding)->assertForbidden();
|
|
$triager = $this->designated('triagers');
|
|
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'triage', 'note' => 'Synthetic triage completed'])->assertOk();
|
|
$this->actingAs($reviewer)->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk();
|
|
$this->postJson($url.'/verifications', [...$finding, 'version_no' => 3])->assertConflict();
|
|
$finding['version_no'] = 4;
|
|
$this->postJson($url.'/verifications', [...$finding, 'valid_until' => null])->assertUnprocessable();
|
|
$this->postJson($url.'/verifications', [...$finding, 'object_id' => '00000000-0000-4000-8000-000000000001'])->assertNotFound();
|
|
$this->postJson($url.'/verifications', $finding)->assertOk()->assertJsonPath('version_no', 5);
|
|
$this->postJson($url.'/verifications', $finding)->assertConflict();
|
|
$this->getJson($url)->assertOk()->assertJsonPath('verifications.0.effective_outcome', 'confirmed');
|
|
$raw = DB::table('existing_business_revisions')->where('business_id', $id)->where('action', 'verification')->value('snapshot');
|
|
$this->assertStringNotContainsString('Synthetic identity evidence', $raw);
|
|
$this->travel(2)->days();
|
|
$this->getJson($url)->assertOk()->assertJsonPath('verifications.0.effective_outcome', 'expired');
|
|
$this->travelBack();
|
|
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'needs_information', 'note' => 'Synthetic clarification requested'])->assertOk();
|
|
$this->actingAs($owner)->putJson($url, [...$this->draft(), 'version_no' => 6])->assertOk();
|
|
$this->getJson($url)->assertJsonPath('verifications.0.effective_outcome', 'stale');
|
|
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
|
|
}
|
|
|
|
public function test_flag_mfa_and_current_designation_are_required(): void
|
|
{
|
|
$this->getJson($this->base)->assertUnauthorized();
|
|
$reviewer = $this->reviewer();
|
|
$this->actingAs($reviewer)->getJson('/api/v1/existing-business/reviews')->assertOk();
|
|
config(['existing-business.reviewers' => []]);
|
|
$this->getJson('/api/v1/existing-business/reviews')->assertForbidden();
|
|
config(['existing-business.reviewers' => [$reviewer->email]]);
|
|
$reviewer->forceFill(['two_factor_confirmed_at' => null])->save();
|
|
$this->getJson('/api/v1/existing-business/reviews')->assertForbidden();
|
|
config(['existing-business.enabled' => false]);
|
|
$this->getJson($this->base)->assertNotFound();
|
|
}
|
|
|
|
public function test_arbitrary_states_and_incomplete_or_expired_submission_are_rejected(): void
|
|
{
|
|
$this->actingAs(User::factory()->create());
|
|
$draft = $this->draft();
|
|
$draft['payload']['valid_until'] = today()->subDay()->toDateString();
|
|
$id = $this->postJson($this->base, $draft)->assertCreated()->json('id');
|
|
foreach (['published', 'approved'] as $action) {
|
|
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => $action, 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertForbidden();
|
|
}
|
|
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertUnprocessable();
|
|
$this->assertDatabaseCount('existing_business_reviews', 0);
|
|
}
|
|
|
|
public function test_submission_triage_two_level_approval_and_publication_require_distinct_designations(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$triager = $this->designated('triagers');
|
|
$reviewer = $this->designated('reviewers');
|
|
$levelOne = $this->designated('approvers_l1');
|
|
$levelTwo = $this->designated('approvers_l2');
|
|
$publisher = $this->designated('publishers');
|
|
$sectorId = DB::table('sectors')->insertGetId(['name' => 'Synthetic approval sector', 'slug' => 'synthetic-approval-sector', 'is_active' => true]);
|
|
|
|
$this->actingAs($owner);
|
|
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
|
$this->putJson($this->base.'/'.$id, [...$this->draft(), 'version_no' => 1, 'sector_id' => $sectorId,
|
|
'public_title' => 'Synthetic public listing'])->assertOk();
|
|
$url = $this->base.'/'.$id;
|
|
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'submitted', 'note' => 'Synthetic submission for staged approval', 'review_consent' => true])->assertOk();
|
|
$this->assertDatabaseHas('tasks', ['existing_business_id' => $id, 'task_type' => 'triage', 'status' => 'open']);
|
|
|
|
$this->actingAs($triager)->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'triage', 'note' => 'Synthetic initial triage completed'])->assertOk();
|
|
$this->actingAs($reviewer)->postJson($url.'/transitions', ['version_no' => 4, 'action' => 'in_review', 'note' => 'Synthetic independent review started'])->assertOk();
|
|
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'approval_l1', 'note' => 'Synthetic review checks completed'])->assertOk();
|
|
$this->actingAs($levelOne)->postJson($url.'/transitions', ['version_no' => 6, 'action' => 'approval_l2', 'note' => 'Synthetic first level approval'])->assertOk();
|
|
|
|
config(['existing-business.approvers_l2' => [...config('existing-business.approvers_l2'), strtolower($levelOne->email)]]);
|
|
$this->actingAs($levelOne)->postJson($url.'/transitions', ['version_no' => 7, 'action' => 'approved', 'note' => 'Synthetic second level approval attempt'])->assertForbidden();
|
|
$this->actingAs($levelTwo)->postJson($url.'/transitions', ['version_no' => 7, 'action' => 'approved', 'note' => 'Synthetic independent second level approval'])->assertOk();
|
|
$this->actingAs($publisher)->postJson($url.'/transitions', ['version_no' => 8, 'action' => 'published', 'note' => 'Synthetic independent publication decision'])->assertOk()->assertJsonPath('published_at', fn ($value) => is_string($value));
|
|
|
|
$this->assertDatabaseHas('business_workflow_transitions', ['existing_business_id' => $id, 'from_status' => 'approval_l2', 'to_status' => 'approved']);
|
|
$this->assertDatabaseHas('existing_businesses', ['id' => $id, 'status' => 'published']);
|
|
$this->assertDatabaseCount('outbox_events', 1);
|
|
}
|
|
|
|
public function test_reserved_objects_are_scoped_versioned_and_never_verified_by_declaration(): void
|
|
{
|
|
$this->actingAs(User::factory()->create());
|
|
$first = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
|
$second = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
|
|
$object = ['kind' => 'license', 'version_no' => 1, 'reason' => 'Synthetic license declaration',
|
|
'payload' => ['label' => 'Private synthetic license', 'source_reference' => 'Synthetic source only',
|
|
'declared_holder' => 'Synthetic holder', 'valid_from' => '2026-01-01', 'valid_until' => '2027-01-01',
|
|
'amount' => '1200.25', 'currency' => 'MZN', 'value_kind' => 'asking']];
|
|
$id = $this->postJson($this->base.'/'.$first.'/objects', $object)->assertCreated()->assertJsonPath('verification_status', 'pending')->assertJsonPath('version_no', 2)->json('id');
|
|
$this->postJson($this->base.'/'.$first.'/objects', $object)->assertConflict();
|
|
$this->putJson($this->base.'/'.$second.'/objects/'.$id, $object)->assertNotFound();
|
|
$this->putJson($this->base.'/'.$first.'/objects/'.$id, [...$object, 'version_no' => 2, 'kind' => 'asset'])->assertUnprocessable();
|
|
$object['payload']['currency'] = null;
|
|
$this->putJson($this->base.'/'.$first.'/objects/'.$id, [...$object, 'version_no' => 2])->assertUnprocessable();
|
|
$this->getJson($this->base.'/'.$first)->assertOk()->assertJsonPath('objects.0.payload.amount', '1200.25');
|
|
$this->assertStringNotContainsString('Synthetic holder', DB::table('existing_business_objects')->where('id', $id)->value('payload'));
|
|
$snapshot = Crypt::decryptString(DB::table('existing_business_revisions')->where('business_id', $first)->where('version_no', 2)->value('snapshot'));
|
|
$this->assertStringContainsString('Synthetic holder', $snapshot);
|
|
$this->actingAs(User::factory()->create())->getJson($this->base.'/'.$first)->assertForbidden();
|
|
}
|
|
|
|
public function test_company_sale_real_estate_and_land_right_have_distinct_private_subject_records(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$this->actingAs($owner);
|
|
$cases = [
|
|
['modality' => 'company_sale', 'kind' => 'company', 'label' => 'Synthetic company sale'],
|
|
['modality' => 'real_estate', 'kind' => 'property', 'label' => 'Synthetic commercial property'],
|
|
['modality' => 'land_right', 'kind' => 'land_right', 'label' => 'Synthetic declared land right'],
|
|
];
|
|
|
|
foreach ($cases as $case) {
|
|
$businessId = $this->postJson($this->base, [
|
|
'modality' => $case['modality'],
|
|
'classification' => 'confidential',
|
|
'reason' => 'Synthetic modality-specific QA draft',
|
|
'payload' => ['title' => $case['label'], 'wizard_version' => '2', 'wizard_step' => '6'],
|
|
])->assertCreated()->json('id');
|
|
|
|
$this->get('/investidor/negocios/'.$businessId)->assertOk()
|
|
->assertInertia(fn ($page) => $page->component('investment/business')
|
|
->where('record.modality', $case['modality'])
|
|
->has('formSchema')
|
|
->where('objectKinds', fn ($kinds) => $kinds->contains($case['kind'])));
|
|
|
|
$objectId = $this->postJson($this->base.'/'.$businessId.'/objects', [
|
|
'kind' => $case['kind'],
|
|
'version_no' => 1,
|
|
'reason' => 'Synthetic subject record for QA',
|
|
'payload' => ['label' => $case['label'], 'source_reference' => 'Synthetic source reference'],
|
|
])->assertCreated()->assertJsonPath('verification_status', 'pending')->json('id');
|
|
|
|
$this->getJson($this->base.'/'.$businessId)->assertOk()
|
|
->assertJsonPath('record.modality', $case['modality'])
|
|
->assertJsonPath('objects.0.id', $objectId)
|
|
->assertJsonPath('objects.0.kind', $case['kind'])
|
|
->assertJsonPath('objects.0.verification_status', 'pending');
|
|
}
|
|
}
|
|
}
|