Files
Investidor-V2/tests/Feature/EducationReviewPreparationTest.php
2026-11-11 08:21:56 +02:00

175 lines
12 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 App\Services\EducationReviews;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class EducationReviewPreparationTest extends TestCase
{
use DatabaseTransactions;
private User $reviewer;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
config(['education.enabled' => true]);
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
$this->assertSame(55441, (int) $identity->port);
$this->reviewer = $this->manager();
}
private function manager(): User
{
$u = User::factory()->create();
config(['portal.managers' => [...config('portal.managers', []), $u->email]]);
$this->actingAs($u);
return $u;
}
private function record(string $kind): LabourDemand|TrainingCapacity
{
$place = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $place, 'name' => 'SYNTHETIC province', 'code' => $place, 'location_type' => 'province']);
$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' => $place, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'period_start' => today()->addMonth()->toDateString(), 'period_end' => today()->addMonths(2)->toDateString(),
'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'valid_until' => today()->addYear()->toDateString(), 'submitted_by' => User::factory()->create()->id, 'status' => 'submitted', 'version_no' => 1];
if ($kind === 'demands') {
return LabourDemand::create([...$data, 'project_id' => Project::create(['code' => (string) Str::uuid(), 'name' => 'SYNTHETIC project', 'status' => 'draft'])->id, 'employer' => 'SYNTHETIC employer']);
}
$provider = TrainingProvider::create(['name' => 'SYNTHETIC provider', 'status' => 'candidate']);
$course = Course::create(['provider_id' => $provider->id, 'code' => (string) Str::uuid(), 'title' => 'SYNTHETIC course', 'status' => 'draft']);
return TrainingCapacity::create([...$data, 'course_id' => $course->id]);
}
private function state(LabourDemand|TrainingCapacity $record): array
{
$base = ['version_no' => 1, 'qualification' => 'SYNTHETIC qualification', 'quantity' => 20, 'status' => 'submitted', 'period_start' => $record->getRawOriginal('period_start'), 'period_end' => $record->getRawOriginal('period_end'),
'valid_until' => $record->getRawOriginal('valid_until'), 'source' => 'SYNTHETIC documentary source', 'reference_date' => today()->toDateString(), 'employer' => '', 'references' => []];
return ['values' => ['version_no' => 1, 'status' => 'reviewed', 'review_reason' => ' SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE '], 'generation' => ['status' => 0, 'review_reason' => 1], 'base' => $base, 'pending' => null];
}
private function url(string $kind, LabourDemand|TrainingCapacity $r): string
{
return '/management/education/'.$kind.'/'.$r->id.'/review-preparation';
}
public function test_private_preparation_is_encrypted_actor_scoped_idempotent_and_preserves_the_declaration(): void
{
foreach (['demands', 'capacities'] as $kind) {
$record = $this->record($kind);
$before = $record->fresh()->getAttributes();
$url = $this->url($kind, $record);
$body = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($record)];
$response = $this->postJson($url, $body)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('version', 1);
$this->assertStringContainsString('"references":{}', $response->getContent());
$saved = $response->json();
$this->assertSame($body['snapshot'], $saved['snapshot']);
$this->postJson($url, $body)->assertOk()->assertJsonPath('version', 1);
$query = DB::table('audit_events')->where('source_system', 'portal-education-review')->where('entity_id', $record->id)->where('action', 'review_preparation_saved');
$this->assertSame(1, $query->count());
$this->assertStringNotContainsString('SENSITIVE SYNTHETIC PRIVATE REVIEW NOTE', $query->value('after_data'));
$this->getJson($url)->assertOk()->assertJsonPath('snapshot.values.review_reason', $body['snapshot']['values']['review_reason']);
$this->assertSame($before, $record->fresh()->getAttributes());
$this->assertSame(0, DB::table('audit_events')->where('entity_id', $record->id)->where('action', 'review_committed')->count());
$this->get('/management/education/'.$kind.'/'.$record->id)->assertOk()->assertInertia(fn ($page) => $page->where('preparation.version', 1)->where('history.total', 0));
$this->manager();
$this->getJson($url)->assertOk()->assertJsonPath('version', 0)->assertJsonPath('snapshot', null);
$this->actingAs($this->reviewer);
$this->postJson($url, [...$body, 'snapshot' => [...$body['snapshot'], 'values' => [...$body['snapshot']['values'], 'review_reason' => 'DIFFERENT NOTE USING THE SAME KEY']]])->assertConflict();
}
}
public function test_private_preparation_optimistic_version_and_late_replay_never_replace_newer_notes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$first = ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)];
$this->postJson($url, $first)->assertOk();
$second = [...$first, 'version' => 1, 'operation_key' => (string) Str::uuid()];
$second['snapshot']['values']['review_reason'] = 'SYNTHETIC deliberate newer note from a second window.';
$this->postJson($url, $second)->assertOk()->assertJsonPath('version', 2);
$this->postJson($url, $first)->assertOk()->assertJsonPath('version', 2)->assertJsonPath('snapshot.values.review_reason', $second['snapshot']['values']['review_reason']);
$this->postJson($url, [...$first, 'operation_key' => (string) Str::uuid()])->assertConflict();
$this->assertSame(2, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->count());
$other = $this->manager();
$this->postJson($url, $first)->assertConflict();
$this->getJson($url.'?actor_id='.$this->reviewer->id)->assertConflict();
$this->getJson($url)->assertOk()->assertJsonPath('snapshot', null)->assertJsonPath('actor_id', $other->id);
}
public function test_uncertain_review_is_recovered_from_its_private_intent_and_same_native_command_once(): void
{
$r = $this->record('demands');
$state = $this->state($r);
$key = (string) Str::uuid();
$state['pending'] = ['values' => [...$state['values'], 'client_key' => $key], 'reasonGeneration' => 1, 'statusGeneration' => 0, 'base' => $state['base']];
$state['values']['review_reason'] = 'SYNTHETIC later edit retained after an uncertain response.';
$state['generation']['review_reason'] = 2;
$url = $this->url('demands', $r);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 0, 'operation_key' => (string) Str::uuid(), 'snapshot' => $state])->assertOk();
$native = '/management/education/demands/'.$r->id.'/review';
$this->post($native, $state['pending']['values'])->assertRedirect();
$recovered = $this->getJson($url)->assertOk()->json('snapshot');
$this->assertSame($state, $recovered);
$this->post($native, $recovered['pending']['values'])->assertRedirect();
$this->assertSame(2, $r->fresh()->version_no);
$this->assertSame(1, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_committed')->count());
$context = app(EducationReviews::class)->context($this->reviewer, $r->fresh());
$this->assertSame($key, $context['commands'][0]['key']);
$page = $this->get('/management/education/demands/'.$r->id)->assertOk()->viewData('page')['props'];
$directory = base_path('outputs/education-review-preparation-20261111');
if (! is_dir($directory)) {
mkdir($directory, 0700, true);
}
file_put_contents($directory.'/recovered-page.json', json_encode(['synthetic' => true, 'transactional_QA' => true, 'props' => array_intersect_key($page, array_flip(['declaration', 'kind', 'actorId', 'today', 'references', 'preparation', 'history', 'backUrl']))], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
public function test_unreadable_private_preparation_and_unauthorised_access_preserve_previous_bytes(): void
{
$r = $this->record('demands');
$url = $this->url('demands', $r);
$bytes = json_encode(['education_preparation' => ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => 'SYNTHETIC unreadable original encrypted bytes']], JSON_THROW_ON_ERROR);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted', 'after_data' => $bytes]);
$stored = DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data');
$this->getJson($url)->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$this->postJson($url, ['actor_id' => $this->reviewer->id, 'version' => 1, 'operation_key' => (string) Str::uuid(), 'snapshot' => $this->state($r)])->assertConflict();
$this->assertSame($stored, DB::table('audit_events')->where('entity_id', $r->id)->where('action', 'review_preparation_saved')->value('after_data'));
$this->actingAs(User::factory()->create());
$this->getJson($url)->assertForbidden();
$this->postJson($url, [])->assertForbidden();
}
public function test_invalid_decrypted_snapshots_are_unavailable_without_losing_the_encrypted_original(): void
{
foreach (['{', json_encode(['unexpected' => 'SYNTHETIC invalid snapshot'], JSON_THROW_ON_ERROR)] as $plain) {
$r = $this->record('demands');
$cipher = Crypt::encryptString($plain);
DB::table('audit_events')->insert(['source_system' => 'portal-education-review', 'actor_user_id' => $this->reviewer->id, 'entity_type' => 'labour_demands', 'entity_id' => $r->id, 'action' => 'review_preparation_saved', 'data_classification' => 'restricted',
'after_data' => json_encode(['education_preparation' => ['version' => 1, 'operation_key' => (string) Str::uuid(), 'hash' => str_repeat('a', 64), 'snapshot' => $cipher]], JSON_THROW_ON_ERROR)]);
$this->getJson($this->url('demands', $r))->assertOk()->assertJsonPath('unavailable', true)->assertJsonPath('snapshot', null);
$actual = json_decode(DB::table('audit_events')->where('entity_id', $r->id)->value('after_data'), true);
$this->assertSame($cipher, $actual['education_preparation']['snapshot']);
}
}
}