166 lines
18 KiB
PHP
166 lines
18 KiB
PHP
<?php
|
|
namespace Tests\Feature;
|
|
use App\Modules\Workshop\WorkshopWorkflow;
|
|
use App\Shared\Context\CompanyContext;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Tests\Support\WorkshopFixture;
|
|
use Tests\Support\IdentityFixture;
|
|
use Tests\TestCase;
|
|
|
|
final class WorkshopTest extends TestCase
|
|
{
|
|
use \Tests\Support\UsesModuleFixture;
|
|
protected function moduleFixture(): string { return 'workshop'; }
|
|
use RefreshDatabase;
|
|
private array $f; private string $token; private int $order; private int $task; private int $auth;
|
|
protected function setUp(): void { parent::setUp(); $this->f=WorkshopFixture::create(); $this->login(); }
|
|
private function login(string $who='manager'): void {
|
|
$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();
|
|
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');
|
|
}
|
|
private function send(string $path,array $input,?string $key=null) {
|
|
return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/workshop'.$path,$input);
|
|
}
|
|
private function rev(): int { return isset($this->order)?(int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$this->order])->v:0; }
|
|
private function openOrder(?string $reference=null): void {
|
|
$this->order=$this->send('/orders',['reference'=>$reference??'OT-TEST-'.Str::uuid(),'client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-'.Str::uuid(),'equipment_description'=>'PC sintético','serial'=>'SER-TEST','request'=>'Diagnóstico sintético','location'=>'Local sintético','evidence'=>'Pedido artificial.'])->assertOk()->json('id');
|
|
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertOk();
|
|
$this->login('technician'); $this->task=$this->record('task',['description'=>'Reparação sintética autorizável.'])->assertOk()->json('id');
|
|
}
|
|
private function record(string $kind,array $data=[],?string $key=null) {
|
|
return $this->send('/orders/'.$this->order.'/entries',array_replace(['kind'=>$kind,'origin'=>$kind.'-'.Str::uuid(),'version'=>1,'revision'=>$this->rev()],$data),$key);
|
|
}
|
|
private function authorize(): void {
|
|
$this->login(); $this->auth=$this->record('authorization',['task_id'=>$this->task,'authorized_by'=>'Responsável do cliente sintético','capacity'=>'Representante declarado de teste','authorized_at'=>now()->subMinute()->toIso8601String(),'channel'=>'Documento de teste','scope'=>'Apenas a tarefa sintética identificada.','evidence'=>'TEST-AUTH-1'])->assertOk()->json('id'); $this->login('technician');
|
|
}
|
|
private function verifyEntry(int $id,string $decision='verified',?string $key=null,array $changes=[]) {
|
|
$e=DB::table('wo_entries')->find($id);
|
|
return $this->send('/entries/'.$id.'/verification',array_replace(['version'=>$e->version,'revision'=>$this->rev(),'decision'=>$decision,'reason'=>'Revisão sintética explícita.'],$changes),$key);
|
|
}
|
|
private function ledger() { return $this->getJson('/api/workshop')->assertOk(); }
|
|
public function test_authorized_work_time_materials_and_distinct_verification(): void {
|
|
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 08:00:00',WorkshopWorkflow::ZONE)); $this->openOrder();
|
|
$this->record('start',['task_id'=>$this->task,'evidence'=>'Tentativa sem autorização.'])->assertStatus(409);
|
|
$this->authorize(); $this->travelTo(\Carbon\Carbon::parse('2026-10-27 09:00:00',WorkshopWorkflow::ZONE));
|
|
$start=$this->record('start',['task_id'=>$this->task,'authorization_id'=>$this->auth,'evidence'=>'Início explícito.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
|
|
$this->travelTo(\Carbon\Carbon::parse('2026-10-27 11:00:00',WorkshopWorkflow::ZONE));
|
|
$time=$this->record('time',['task_id'=>$this->task,'authorization_id'=>$this->auth,'start_id'=>$start,'starts_at'=>'2026-10-27T09:00:00+02:00','ends_at'=>'2026-10-27T11:00:00+02:00','mode'=>'started','time_type'=>'work','pauses'=>[['start'=>'2026-10-27T10:00:00+02:00','end'=>'2026-10-27T10:15:00+02:00']],'evidence'=>'Trabalho sintético.'])->assertOk()->assertJsonPath('outside_authority',false)->json('id');
|
|
$ids=[$this->task,$time];
|
|
foreach(['delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$qty) $ids[]=$this->record('material',['task_id'=>$this->task,'authorization_id'=>$this->auth,'starts_at'=>now()->toIso8601String(),'resource'=>'CABO-TEST','unit'=>'UN','owner'=>'company','event'=>$event,'quantity'=>$qty,'evidence'=>'Material apenas observado.'])->assertOk()->json('id');
|
|
$this->ledger()->assertJsonPath('orders.0.minutes_reported',105)->assertJsonPath('orders.0.minutes_verified',0)->assertJsonPath('orders.0.materials.0.unexplained','0');
|
|
$this->verifyEntry($time)->assertForbidden();
|
|
$this->login('reviewer'); foreach($ids as $id) $this->verifyEntry($id)->assertOk();
|
|
$this->ledger()->assertJsonPath('orders.0.minutes_verified',105)->assertJsonPath('orders.0.state','records_verified')->assertJsonPath('orders.0.materials.0.verified_consumed','2');
|
|
$this->assertSame(DB::table('wo_commands')->count(),DB::table('wo_events')->count()); $this->assertSame(DB::table('wo_events')->count(),DB::table('wo_outbox')->count()); $this->travelBack();
|
|
}
|
|
public function test_unauthorized_past_occurrence_is_auditable_never_retroactively_authorized(): void {
|
|
$this->openOrder();
|
|
$d=['task_id'=>$this->task,'starts_at'=>now()->subHours(2)->toIso8601String(),'ends_at'=>now()->subHour()->toIso8601String(),'mode'=>'reported','time_type'=>'work','evidence'=>'Ocorrência já acontecida sem cobertura.'];
|
|
$id=$this->record('time',$d)->assertOk()->assertJsonPath('outside_authority',true)->json('id');
|
|
$this->authorize(); $this->assertTrue(DB::table('wo_entries')->find($id)->outside_authority);
|
|
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($id,'acknowledged')->assertOk();
|
|
$this->ledger()->assertJsonPath('orders.0.state','exception_review')->assertJsonPath('orders.0.minutes_verified',0);
|
|
$this->login('technician'); $r=DB::table('wo_entries')->find($id);
|
|
$new=$this->record('time',$d+['previous_id'=>$id,'version'=>2,'origin'=>$r->origin,'correction_reason'=>'Precisão do relato, sem apagar excepção.'])->assertOk()->assertJsonPath('outside_authority',true)->json('id');
|
|
$this->login('reviewer'); $this->verifyEntry($id)->assertStatus(409); $this->verifyEntry($new)->assertStatus(409);
|
|
}
|
|
public function test_scope_change_requires_new_authorization_and_exact_version(): void {
|
|
$this->openOrder(); $this->authorize(); $task=DB::table('wo_entries')->find($this->task);
|
|
$new=$this->record('task',['description'=>'Trabalho adicional fora do primeiro âmbito.','previous_id'=>$task->id,'version'=>2,'origin'=>$task->origin,'correction_reason'=>'Cliente pediu alteração.'])->assertOk()->json('id');
|
|
$this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Autorização antiga não serve.'])->assertStatus(409);
|
|
$this->record('start',['task_id'=>$task->id,'authorization_id'=>$this->auth,'evidence'=>'Tarefa antiga.'])->assertStatus(409);
|
|
$this->task=$new; $this->authorize(); $this->record('start',['task_id'=>$new,'authorization_id'=>$this->auth,'evidence'=>'Novo âmbito autorizado.'])->assertOk();
|
|
$this->login('reviewer'); $this->verifyEntry($task->id)->assertStatus(409); $this->verifyEntry($new,'refused')->assertOk();
|
|
}
|
|
public function test_overlap_invalid_pauses_units_and_permissions(): void {
|
|
$this->openOrder();
|
|
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Apontamento retrospectivo.'];
|
|
$this->record('time',$d)->assertOk(); $this->record('time',$d)->assertStatus(409);
|
|
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T11:00:00+02:00','ends_at'=>'2026-10-26T12:00:00+02:00']))->assertOk();
|
|
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T13:00:00+02:00','ends_at'=>'2026-10-26T14:00:00+02:00','pauses'=>[['start'=>'2026-10-26T13:10:00+02:00','end'=>'2026-10-26T14:10:00+02:00']]]))->assertUnprocessable();
|
|
$this->record('time',array_replace($d,['starts_at'=>'2026-10-26T14:00:00+02:00','ends_at'=>'2026-10-26T14:00:30+02:00']))->assertUnprocessable();
|
|
$m=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','resource'=>'CABO','unit'=>'UN','owner'=>'company','event'=>'required','quantity'=>'3','evidence'=>'Necessidade, não consumo.'];
|
|
$this->record('material',$m)->assertOk(); $this->record('material',array_replace($m,['unit'=>'KG']))->assertStatus(409); $this->record('material',array_replace($m,['event'=>'reserved']))->assertUnprocessable();
|
|
$this->login('reader'); $this->getJson('/api/workshop')->assertForbidden();
|
|
$this->login('manager'); $this->record('task',['description'=>'Abertura não é execução.'])->assertForbidden();
|
|
}
|
|
public function test_idempotency_failures_revocation_and_real_guard(): void {
|
|
$this->openOrder(); $d=['kind'=>'task','description'=>'Tarefa idempotente','origin'=>'TEST-REPEAT','version'=>1,'revision'=>$this->rev()]; $key=(string)Str::uuid();
|
|
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
|
|
$before=DB::table('wo_entries')->count(); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertStatus(500); $this->assertDatabaseCount('wo_entries',$before);
|
|
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');
|
|
$id=$this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->json('id'); $this->send('/orders/'.$this->order.'/entries',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
|
$this->send('/orders/'.$this->order.'/entries',array_replace($d,['description'=>'Different']),$key)->assertStatus(409);
|
|
$this->record('task',['description'=>'Duplicate source','origin'=>'TEST-REPEAT'])->assertStatus(409);
|
|
DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'workshop.record'])->delete(); $this->record('task',['description'=>'Revogado'])->assertForbidden();
|
|
$this->app->instance('env','local'); $this->withHeader('X-CSRF-TOKEN',session()->token()); $this->record('task',[])->assertForbidden();
|
|
try { WorkshopWorkflow::guard(); $this->fail('CLI bypass'); } catch(\Symfony\Component\HttpKernel\Exception\HttpException $e) { $this->assertSame(403,$e->getStatusCode()); }
|
|
}
|
|
private function sqlDenied(callable $write): void {
|
|
try { DB::transaction($write); $this->fail('Database invariant bypass'); }
|
|
catch(\Illuminate\Database\QueryException $e) { $this->assertMatchesRegularExpression('/SGF_(WO_|APPEND_ONLY_AUDIT)/',$e->getMessage()); }
|
|
}
|
|
public function test_sql_immutability_versions_overlap_and_distinct_verifier(): void {
|
|
$this->openOrder(); $d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de auditoria.'];
|
|
$id=$this->record('time',$d)->assertOk()->json('id');
|
|
$this->sqlDenied(fn()=>DB::table('wo_entries')->where('id',$id)->update(['outside_authority'=>false]));
|
|
foreach(['wo_entries','wo_orders','wo_equipment','wo_commands','wo_events','wo_outbox'] as $table) $this->sqlDenied(fn()=>DB::table($table)->delete());
|
|
$row=(array)DB::table('wo_entries')->find($id); unset($row['id']); $row['origin']='SQL-OVERLAP';$row['revision']=$this->rev();
|
|
$this->sqlDenied(fn()=>DB::table('wo_entries')->insert($row));
|
|
$row=(array)DB::table('wo_orders')->find($this->order);unset($row['id']);$row['unit_id']=$this->f['hidden'];$row['reference']='SQL-CROSS';
|
|
// Same equipment in another unit is not a transfer of an existing order: creation must remain an authorized server command.
|
|
$this->sqlDenied(fn()=>DB::table('wo_orders')->where('id',$this->order)->update(['unit_id'=>$this->f['hidden']]));
|
|
IdentityFixture::grant($this->f['technician']->id,$this->f['tenant'],$this->f['company'],'workshop.verify',$this->f['unit']);
|
|
$this->verifyEntry($id,'acknowledged')->assertForbidden();
|
|
$this->sqlDenied(fn()=>DB::table('wo_decisions')->insert(['entry_id'=>$id,'entry_version'=>1,'revision'=>$this->rev(),'decision'=>'acknowledged','reason'=>'SQL self decision.','actor_id'=>$this->f['technician']->id,'created_at'=>now()]));
|
|
}
|
|
public function test_correction_replaces_current_view_not_verified_history(): void {
|
|
$this->openOrder();
|
|
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato sem autorização.','origin'=>'CORRECTION-ORIGIN'];
|
|
$id=$this->record('time',$d)->assertOk()->json('id'); $this->login('reviewer'); $this->verifyEntry($id,'refused')->assertOk();$this->login('technician');
|
|
$next=$this->record('time',array_replace($d,['previous_id'=>$id,'version'=>2,'ends_at'=>'2026-10-26T10:00:00+02:00','correction_reason'=>'Corrigir hora final conforme evidência.']))->assertOk()->json('id');
|
|
$this->ledger()->assertJsonPath('orders.0.minutes_reported',60)->assertJsonPath('orders.0.minutes_verified',0);
|
|
$this->assertDatabaseHas('wo_entries',['id'=>$id,'minutes'=>120]);$this->assertDatabaseHas('wo_decisions',['entry_id'=>$id,'decision'=>'refused']);
|
|
$this->login('reviewer');$this->verifyEntry($id)->assertStatus(409);$this->verifyEntry($next,'acknowledged')->assertOk();
|
|
}
|
|
public function test_isolation_assignment_change_context_and_wrong_database(): void {
|
|
$this->openOrder(); $this->login();
|
|
foreach(['view','open','assign','authorize'] as $r) IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['sibling'],'workshop.'.$r,$this->f['siblingUnit']);
|
|
$old=$this->token;
|
|
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk()->json('context.token');
|
|
$this->record('assignment',['technician_id'=>$this->f['technician']->id])->assertForbidden();$this->ledger()->assertJsonPath('orders',[]);
|
|
$this->withHeader('X-SGF-Context',$old)->postJson('/api/workshop/orders/'.$this->order.'/entries',[])->assertStatus(409);
|
|
$this->login('technician'); DB::table('permission_grants')->where(['user_id'=>$this->f['technician']->id,'permission'=>'unit.view','unit_id'=>$this->f['unit']])->delete();
|
|
$this->getJson('/api/workshop')->assertForbidden();
|
|
config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);
|
|
try { WorkshopWorkflow::guard();$this->fail('Wrong database'); } catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}
|
|
}
|
|
public function test_two_technicians_can_work_simultaneously_but_not_one_technician_twice(): void {
|
|
$this->openOrder();
|
|
$d=['task_id'=>$this->task,'starts_at'=>'2026-10-26T09:00:00+02:00','ends_at'=>'2026-10-26T11:00:00+02:00','mode'=>'reported','time_type'=>'work','evidence'=>'Relato de dois técnicos distintos.'];
|
|
$this->record('time',$d)->assertOk();
|
|
foreach(['view','record'] as $right) IdentityFixture::grant($this->f['reviewer']->id,$this->f['tenant'],$this->f['company'],'workshop.'.$right,$this->f['unit']);
|
|
$this->login();$this->record('assignment',['technician_id'=>$this->f['reviewer']->id])->assertOk();
|
|
$this->login('technician');$this->record('time',$d)->assertForbidden();
|
|
$this->login('reviewer');$this->record('time',$d)->assertOk();$this->ledger()->assertJsonPath('orders.0.minutes_reported',240);
|
|
$this->record('time',$d)->assertStatus(409);
|
|
}
|
|
public function test_open_replay_and_material_correspondence_are_not_stock(): void {
|
|
$this->openOrder();
|
|
foreach(['required'=>'4','delivered'=>'3','consumed'=>'2','returned'=>'1'] as $event=>$q) $this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>$event,'quantity'=>$q,'owner'=>'client','unit'=>'UN','starts_at'=>now()->subMinute()->toIso8601String(),'evidence'=>'Material do cliente, observado apenas.'])->assertOk();
|
|
$this->ledger()->assertJsonPath('orders.0.materials.0.required','4')->assertJsonPath('orders.0.materials.0.consumed','2')->assertJsonPath('orders.0.materials.0.unexplained','0');
|
|
$this->assertDatabaseCount('sr_records',0);$this->assertDatabaseCount('si_invoices',0);
|
|
$this->record('material',['task_id'=>$this->task,'resource'=>'TEST-MAT','event'=>'consumed','quantity'=>'0','owner'=>'client','unit'=>'UN','starts_at'=>now()->toIso8601String(),'evidence'=>'Zero não é consumo.'])->assertUnprocessable();
|
|
}
|
|
public function test_order_creation_failure_is_atomic_and_replay_does_not_duplicate_equipment(): void {
|
|
$d=['reference'=>'OT-ATOMIC-OPEN','client_id'=>$this->f['client'],'equipment_identifier'=>'EQ-ATOMIC','equipment_description'=>'Equipamento sintético','request'=>'Pedido de teste','location'=>'Local de teste','evidence'=>'Evidência artificial.'];$key=(string)Str::uuid();
|
|
DB::unprepared("CREATE OR REPLACE FUNCTION wo_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'TEST_FAILURE'; END; $$; CREATE TRIGGER wo_failure BEFORE INSERT ON wo_outbox FOR EACH ROW EXECUTE FUNCTION wo_fail()");
|
|
$this->send('/orders',$d,$key)->assertStatus(500);$this->assertDatabaseCount('wo_orders',0);$this->assertDatabaseCount('wo_equipment',0);$this->assertDatabaseCount('wo_commands',0);
|
|
DB::unprepared('DROP TRIGGER wo_failure ON wo_outbox');$id=$this->send('/orders',$d,$key)->assertOk()->json('id');
|
|
$this->send('/orders',$d,$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);
|
|
$this->send('/orders',$d)->assertStatus(409);$this->assertDatabaseCount('wo_orders',1);$this->assertDatabaseCount('wo_equipment',1);
|
|
}
|
|
}
|