*/ private array $data; private string $project; private string $course; protected function setUp(): void { parent::setUp(); $this->withoutVite(); Http::fake(); Mail::fake(); $this->manager = User::factory()->create(['name' => 'SYNTHETIC education manager']); $this->other = User::factory()->create(['name' => 'SYNTHETIC other submitter']); config(['education.enabled' => true, 'portal.managers' => [$this->manager->email, $this->other->email], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); $this->actingAsWithMfa($this->manager); $geo = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $geo, 'name' => 'SYNTHETIC province', 'code' => $geo, 'location_type' => 'province']); $this->project = Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id; $provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']); $this->course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft'])->id; $this->data = ['occupation_id' => Occupation::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC occupation'])->id, 'skill_id' => Skill::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC skill'])->id, 'geo_location_id' => $geo, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 10, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(3)->toDateString(), 'reference_date' => today()->subMonth()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'source' => 'PRIVATE SYNTHETIC EDUCATION SOURCE', 'status' => 'submitted']; } /** @param array $changes */ private function demand(User $actor, array $changes = []): LabourDemand { return LabourDemand::create([...$this->data, 'project_id' => $this->project, 'employer' => 'SYNTHETIC employer', 'submitted_by' => $actor->id, ...$changes])->fresh(); } /** @param array $changes */ private function capacity(User $actor, array $changes = []): TrainingCapacity { return TrainingCapacity::create([...$this->data, 'course_id' => $this->course, 'submitted_by' => $actor->id, ...$changes])->fresh(); } /** @return array{client_key:string,version_no:int,status:string,review_reason:string} */ private function decision(): array { return ['client_key' => (string) Str::uuid(), 'version_no' => 1, 'status' => 'reviewed', 'review_reason' => 'SYNTHETIC independent review with evidence.']; } public function test_personal_counts_include_legacy_declarations_and_separate_units_and_expiry(): void { $this->demand($this->manager, ['quantity' => 100]); $this->demand($this->manager, ['status' => 'reviewed', 'reviewed_by' => $this->other->id, 'review_reason' => 'SYNTHETIC previous review']); $this->demand($this->manager, ['valid_until' => today()->subDay()->toDateString()]); $this->demand($this->other, ['quantity' => 500]); $this->demand($this->other, ['valid_until' => today()->subDay()->toDateString()]); $this->capacity($this->manager, ['quantity' => 700]); $this->capacity($this->other, ['quantity' => 999]); $this->demand($this->manager)->delete(); $this->get('/management/education?q=not-found')->assertOk()->assertInertia(fn (Assert $page) => $page->component('education/index')->has('demands.data', 0) ->where('workQueue.actor_id', $this->manager->id)->where('workQueue.demands.total', 3)->where('workQueue.demands.submitted', 1)->where('workQueue.demands.reviewed', 1) ->where('workQueue.demands.expired', 1)->where('workQueue.demands.to_review', 1)->where('workQueue.capacities.total', 1)->where('workQueue.capacities.to_review', 1)); } public function test_mine_and_independent_review_scopes_do_not_mix_authors_or_expired_decisions(): void { $mine = $this->demand($this->manager); $other = $this->demand($this->other); $this->demand($this->other, ['valid_until' => today()->subDay()->toDateString()]); $this->get('/management/education?scope=mine')->assertOk()->assertInertia(fn (Assert $page) => $page->has('demands.data', 1)->where('demands.data.0.id', $mine->id) ->where('demands.data.0.can_review', false)->where('demands.data.0.review_unavailable', 'self')); $this->get('/management/education?scope=review&status=reviewed')->assertOk()->assertInertia(fn (Assert $page) => $page->has('demands.data', 1)->where('demands.data.0.id', $other->id) ->where('demands.data.0.can_review', true)->where('filters.scope', 'review')->missing('filters.status')); } public function test_personal_view_keeps_territorial_catalogue_evidence_from_other_submitters(): void { $this->capacity($this->other); $this->get('/management/education?scope=mine&geo_location_id='.$this->data['geo_location_id'])->assertOk()->assertInertia(fn (Assert $page) => $page ->has('capacities.data', 0)->has('providers', 1)->has('courses', 1)->where('courses.0.id', $this->course)); } public function test_private_open_drafts_are_counted_without_counting_derived_submissions_twice(): void { $input = ['version_no' => 0, 'client_key' => (string) Str::uuid(), 'payload' => []]; $this->postJson('/management/education/drafts/demands', $input)->assertOk(); $this->demand($this->manager); $payload = $this->data; unset($payload['status']); $payload = [...$payload, 'project_id' => $this->project, 'employer' => 'SYNTHETIC derived employer']; $created = $this->postJson('/management/education/drafts/demands', [...$input, 'client_key' => (string) Str::uuid(), 'payload' => $payload])->assertOk()->json('id'); $this->postJson('/management/education/drafts/demands/'.$created.'/submit', ['version_no' => 1, 'payload' => $payload])->assertOk(); $this->postJson('/management/education/drafts/demands/'.$created.'/submit', ['version_no' => 1, 'payload' => $payload])->assertOk(); $this->actingAsWithMfa($this->other); $this->postJson('/management/education/drafts/demands', [...$input, 'client_key' => (string) Str::uuid()])->assertOk(); $this->actingAsWithMfa($this->manager); $this->get('/management/education')->assertInertia(fn (Assert $page) => $page->where('workQueue.drafts', 1)->where('workQueue.demands.total', 2)->has('ownDrafts.data', 2)); } public function test_detail_preserves_filter_context_and_shows_current_capabilities_and_reference_names(): void { $record = $this->demand($this->other); $this->get('/management/education/demands/'.$record->id.'?scope=review&q=SYNTHETIC&demand_page=2')->assertOk()->assertHeader('Cache-Control', 'no-store, private') ->assertInertia(fn (Assert $page) => $page->component('education/declaration')->where('actorId', $this->manager->id)->where('declaration.can_review', true) ->where('declaration.submitted_by_name', 'SYNTHETIC other submitter')->where('references.project', 'SYNTHETIC project')->where('references.location', 'SYNTHETIC province') ->where('backUrl', function (string $url): bool { parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query); return str_starts_with($url, '/management/education?') && $query == ['scope' => 'review', 'q' => 'SYNTHETIC', 'demand_page' => '2']; })); } public function test_same_decision_key_replays_without_another_decision_version_or_business_event(): void { $record = $this->demand($this->other); $url = '/management/education/demands/'.$record->id.'/review'; $data = $this->decision(); $this->post($url, $data)->assertRedirect(); $events = DB::table('audit_events')->where('entity_type', 'labour_demands')->count(); $this->post($url, $data)->assertRedirect(); $this->assertSame(2, $record->fresh()->version_no); $this->assertSame($events, DB::table('audit_events')->where('entity_type', 'labour_demands')->count()); $this->get('/management/education/demands/'.$record->id)->assertInertia(fn (Assert $page) => $page->has('declaration.review_context.commands', 1) ->where('declaration.review_context.commands.0.key', $data['client_key'])->where('declaration.review_context.commands.0.version', 2) ->missing('declaration.review_context.commands.0.hash')); } public function test_used_key_with_different_data_and_fresh_key_with_stale_version_are_rejected(): void { $record = $this->capacity($this->other); $url = '/management/education/capacities/'.$record->id.'/review'; $data = $this->decision(); $this->post($url, $data)->assertRedirect(); $this->postJson($url, [...$data, 'status' => 'rejected'])->assertConflict(); $this->postJson($url, [...$data, 'client_key' => (string) Str::uuid()])->assertConflict(); $this->assertSame('reviewed', $record->fresh()->status); $this->assertSame(2, $record->fresh()->version_no); } public function test_self_review_and_expired_new_decisions_are_denied_while_reading_remains_available(): void { $own = $this->demand($this->manager); $expired = $this->capacity($this->other, ['valid_until' => today()->subDay()->toDateString()]); $this->postJson('/management/education/demands/'.$own->id.'/review', $this->decision())->assertForbidden(); $this->postJson('/management/education/capacities/'.$expired->id.'/review', $this->decision())->assertUnprocessable(); $this->get('/management/education/capacities/'.$expired->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('declaration.can_review', false)->where('declaration.review_unavailable', 'expired')); } public function test_replay_after_expiry_confirms_the_old_decision_without_extending_validity(): void { $record = $this->demand($this->other); $url = '/management/education/demands/'.$record->id.'/review'; $data = $this->decision(); $this->post($url, $data)->assertRedirect(); $before = $record->fresh()->getAttributes(); $this->travel(2)->years(); $this->post($url, $data)->assertRedirect(); $this->assertSame($before, $record->fresh()->getAttributes()); $this->postJson($url, [...$data, 'client_key' => (string) Str::uuid(), 'version_no' => 2])->assertUnprocessable(); } public function test_replay_requires_current_access_and_command_metadata_is_private_to_its_actor(): void { $record = $this->demand($this->other); $url = '/management/education/demands/'.$record->id.'/review'; $data = $this->decision(); $this->post($url, $data)->assertRedirect(); $this->actingAsWithMfa($this->other); $this->get('/management/education/demands/'.$record->id)->assertOk()->assertInertia(fn (Assert $page) => $page->has('declaration.review_context.commands', 0)); $this->actingAsWithMfa($this->manager); config(['portal.managers' => [$this->other->email]]); $this->postJson($url, $data)->assertForbidden(); $this->get('/management/education/demands/'.$record->id)->assertForbidden(); $this->assertSame(2, $record->fresh()->version_no); } public function test_legacy_review_contract_and_public_scope_are_preserved(): void { $record = $this->demand($this->other); $data = $this->decision(); unset($data['client_key']); $url = '/management/education/demands/'.$record->id.'/review'; $this->post($url, $data)->assertRedirect(); $this->postJson($url, $data)->assertConflict(); $this->assertSame(1, DB::table('audit_events')->where('source_system', 'portal-education-review')->count()); $event = json_decode(DB::table('audit_events')->where('source_system', 'portal-education-review')->value('after_data'), true); $this->assertNull($event['education_review']['key']); $this->actingAsWithMfa(User::factory()->create()); $this->get('/management/education')->assertForbidden(); $this->get('/management/education/demands/'.$record->id)->assertForbidden(); config(['education.enabled' => false]); $this->actingAsWithMfa($this->manager); $this->get('/management/education')->assertNotFound(); } public function test_personal_pagination_keeps_the_other_collection_and_filter_context(): void { for ($i = 0; $i < 22; $i++) { $this->demand($this->manager); }$this->demand($this->other); $this->get('/management/education?scope=mine&q=SYNTHETIC&capacity_page=3')->assertOk()->assertInertia(fn (Assert $page) => $page->has('demands.data', 20)->where('demands.total', 22) ->where('demands.next_page_url', fn (string $url) => str_contains($url, 'scope=mine') && str_contains($url, 'capacity_page=3') && str_contains($url, 'demand_page=2'))); } public function test_an_old_receipt_does_not_overwrite_a_later_independent_decision(): void { $record = $this->demand($this->other); $url = '/management/education/demands/'.$record->id.'/review'; $original = $this->decision(); $this->post($url, $original)->assertRedirect(); $third = User::factory()->create(); config(['portal.managers' => [$this->manager->email, $this->other->email, $third->email]]); $this->actingAsWithMfa($third); $this->post($url, [...$this->decision(), 'version_no' => 2, 'status' => 'rejected'])->assertRedirect(); $before = $record->fresh()->getAttributes(); $this->actingAsWithMfa($this->manager); $this->post($url, $original)->assertRedirect(); $this->assertSame($before, $record->fresh()->getAttributes()); $this->get('/management/education/demands/'.$record->id)->assertInertia(fn (Assert $page) => $page->where('declaration.version_no', 3)->where('declaration.status', 'rejected') ->where('declaration.review_context.commands.0.version', 2)->has('declaration.review_context.commands', 1)); } public function test_current_declarations_remain_available_without_the_optional_draft_table(): void { Schema::rename('education_declaration_drafts', 'education_declaration_drafts_not_installed'); $record = $this->demand($this->other); $this->get('/management/education?scope=review')->assertOk()->assertInertia(fn (Assert $page) => $page->where('workQueue.drafts', null)->where('workQueue.demands.to_review', 1)->has('demands.data', 1)); $this->post('/management/education/demands/'.$record->id.'/review', $this->decision())->assertRedirect(); } public function test_review_history_preserves_encrypted_subject_and_previous_reason_without_exposing_command_metadata(): void { $record = $this->demand($this->other, ['review_reason' => 'PRIVATE SYNTHETIC previous legacy reason']); $data = $this->decision(); $this->post('/management/education/demands/'.$record->id.'/review', $data)->assertRedirect(); $raw = DB::table('audit_events')->where('source_system', 'portal-education-review')->value('after_data'); $this->assertStringNotContainsString($data['review_reason'], $raw); $this->assertStringNotContainsString('PRIVATE SYNTHETIC previous legacy reason', $raw); $envelope = json_decode($raw, true)['education_review']; $snapshot = json_decode(Crypt::decryptString($envelope['snapshot']), true); $this->assertSame($record->id, $snapshot['subject']['id']); $this->assertSame('PRIVATE SYNTHETIC previous legacy reason', $snapshot['before']['review_reason']); $this->assertSame($data['review_reason'], $snapshot['after']['review_reason']); $this->get('/management/education/demands/'.$record->id)->assertOk()->assertInertia(fn (Assert $page) => $page->has('history.data', 1) ->where('history.data.0.available', true)->where('history.data.0.reason', $data['review_reason'])->where('history.data.0.previous_reason', 'PRIVATE SYNTHETIC previous legacy reason') ->missing('history.data.0.snapshot')->missing('history.data.0.key')->missing('history.data.0.hash')); } public function test_review_history_paginates_versions_and_keeps_unreadable_originals_without_inventing_reasons(): void { $record = $this->capacity($this->other); for ($version = 1; $version <= 12; $version++) { $this->post('/management/education/capacities/'.$record->id.'/review', [...$this->decision(), 'version_no' => $version])->assertRedirect(); } $demand = $this->demand($this->other); $this->post('/management/education/demands/'.$demand->id.'/review', $this->decision())->assertRedirect(); $this->get('/management/education/capacities/'.$record->id.'?scope=review&q=SYNTHETIC')->assertOk()->assertInertia(fn (Assert $page) => $page ->has('history.data', 10)->where('history.total', 12)->where('history.data.0.version', 13)->where('history.data.9.version', 4) ->where('history.next_page_url', fn (string $url) => str_contains($url, 'scope=review') && str_contains($url, 'history_page=2'))); DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'entity_type' => 'training_capacities', 'entity_id' => $record->id, 'actor_user_id' => $this->manager->id, 'action' => 'review_committed', 'data_classification' => 'restricted', 'after_data' => json_encode(['education_review' => ['version' => 14, 'snapshot' => 'SYNTHETIC unreadable ciphertext']])]); $this->get('/management/education/capacities/'.$record->id)->assertOk()->assertInertia(fn (Assert $page) => $page->where('history.data.0.available', false)->where('history.data.0.reason', null)->where('history.data.0.version', 14)); $this->get('/management/education/capacities/'.$record->id.'?history_page=2')->assertOk()->assertInertia(fn (Assert $page) => $page->has('history.data', 3)->where('history.data.0.version', 4)->where('history.data.2.version', 2)); $this->assertSame(14, DB::table('audit_events')->where('source_system', 'portal-education-review')->count()); } }