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

295 lines
19 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Course;
use App\Models\LabourDemand;
use App\Models\Occupation;
use App\Models\Project;
use App\Models\Skill;
use App\Models\TrainingCapacity;
use App\Models\TrainingProvider;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\AuthenticatesQaActors;
use Tests\TestCase;
final class EducationWorkQueueTest extends TestCase
{
use AuthenticatesQaActors,RefreshDatabase;
private User $manager;
private User $other;
/** @var array<string,mixed> */
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<string,mixed> $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<string,mixed> $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());
}
}