f = receiptFixture(); $this->reviewer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($this->f['issuer']); $this->data = ['supplier_id' => $this->f['order']->supplier_id, 'operation_key' => (string) Str::uuid(), 'payload' => [ 'title' => 'Avaliação qualitativa fictícia', 'nature' => 'goods', 'scope' => 'Recepção e conformidade dos artigos do teste isolado.', 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'order_ids' => [$this->f['order']->id], 'criteria' => [['label' => 'Conformidade da entrega', 'result' => 'observed', 'reason' => 'Requisitos da ordem conferidos no ensaio.', 'evidence' => 'Referência fictícia: recepção QA'], ['label' => 'Serviço pós-venda', 'result' => 'not_applicable', 'reason' => 'Não incluído neste âmbito de avaliação.', 'evidence' => '']], 'conclusion' => 'Conclusão qualitativa exclusivamente fictícia.', 'critical_occurrences' => '', 'critical_evidence' => '', ]]; }); function evaluationDraft(object $test): SupplierEvaluation { $test->post('/fornecedores/avaliacoes', $test->data)->assertRedirect(); return SupplierEvaluation::query()->sole(); } function submittedEvaluation(object $test): SupplierEvaluation { $e = evaluationDraft($test); $test->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertRedirect(); return $e->refresh(); } test('supplier evaluation is persisted recovered filtered and linked to its report', function () { $e = evaluationDraft($this); expect($e->status)->toBe('draft')->and($e->version)->toBe(1)->and($e->revision)->toBe(1); $this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('ready', true)->where('records.total', 1)); $this->get('/fornecedores/avaliacoes?status=approved')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); $this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('evaluation.payload.scope', $this->data['payload']['scope'])->where('evaluation.version', 1)->where('canEdit', true)->has('history.data', 1)); $this->get('/fornecedores/avaliacoes/'.$e->id.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('reportOnly', true)->where('canEdit', false)->where('evaluation.status', 'draft')); }); test('creation is idempotent and normalisation does not make JSON key order significant', function () { $e = evaluationDraft($this); $this->post('/fornecedores/avaliacoes', [...$this->data, 'payload' => array_reverse($this->data['payload'], true)])->assertRedirect('/fornecedores/avaliacoes/'.$e->id); expect(SupplierEvaluation::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_evaluation.created')->count())->toBe(1); $this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'title' => 'Other content for same key']])->assertConflict(); }); test('edits retain snapshots and stale writes cannot overwrite a newer draft', function () { $e = evaluationDraft($this); $url = '/fornecedores/avaliacoes/'.$e->id; $new = [...$this->data['payload'], 'scope' => 'Âmbito alterado deliberadamente no teste.']; $this->put($url, ['version' => 1, 'payload' => $new])->assertRedirect(); $this->putJson($url, ['version' => 1, 'payload' => $this->data['payload']])->assertUnprocessable()->assertJsonValidationErrors('version'); expect($e->refresh()->payload['scope'])->toBe($new['scope'])->and($e->version)->toBe(2); $event = AuditLog::query()->where('action', 'supplier_evaluation.updated')->sole(); expect($event->before['payload']['scope'])->toBe($this->data['payload']['scope'])->and($event->after['payload']['scope'])->toBe($new['scope']); }); test('every editor and submitter is excluded from approving that revision', function () { $e = evaluationDraft($this); $url = '/fornecedores/avaliacoes/'.$e->id; $new = [...$this->data['payload'], 'conclusion' => 'Alteração da outra pessoa que deve ficar identificada.']; $this->actingAs($this->reviewer)->put($url, ['version' => 1, 'payload' => $new])->assertRedirect(); $this->actingAs($this->f['issuer'])->put($url, ['version' => 2, 'payload' => $this->data['payload']])->assertRedirect(); $this->post($url.'/submit', ['version' => 3])->assertRedirect(); $this->post($url.'/approve', ['version' => 4, 'reason' => 'Tentativa de auto aprovação'])->assertForbidden(); $this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 4, 'reason' => 'Outro editor também participou'])->assertForbidden(); expect($e->refresh()->status)->toBe('review')->and($e->contributors)->toContain($this->f['issuer']->id, $this->reviewer->id); }); test('insufficient evidence remains explicit and needs correction before independent approval', function () { $this->data['payload']['criteria'][0]['result'] = 'insufficient'; $this->data['payload']['criteria'][0]['evidence'] = ''; $e = submittedEvaluation($this); $url = '/fornecedores/avaliacoes/'.$e->id; $this->actingAs($this->reviewer)->postJson($url.'/approve', ['version' => 2, 'reason' => 'Não se pode presumir evidência'])->assertUnprocessable(); $this->post($url.'/return', ['version' => 2, 'reason' => 'Completar a referência da recepção observada.'])->assertRedirect(); $corrected = $this->data['payload']; $corrected['criteria'][0] = ['label' => 'Conformidade da entrega', 'result' => 'observed', 'reason' => 'Requisitos efectivamente conferidos.', 'evidence' => 'Referência fictícia: recepção QA']; $this->actingAs($this->f['issuer'])->put($url, ['version' => 3, 'payload' => $corrected])->assertRedirect(); $this->post($url.'/submit', ['version' => 4])->assertRedirect(); $this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 5, 'reason' => 'Âmbito e evidências conferidos independentemente.'])->assertRedirect(); expect($e->refresh()->status)->toBe('approved')->and($e->version)->toBe(6); expect(AuditLog::query()->where('action', 'supplier_evaluation.return')->sole()->after['review_reason'])->toContain('Completar'); }); test('approved evaluation is immutable and a new revision preserves the prior approved report', function () { $e = submittedEvaluation($this); $url = '/fornecedores/avaliacoes/'.$e->id; $this->actingAs($this->reviewer)->post($url.'/approve', ['version' => 2, 'reason' => 'Evidências e âmbito conferidos independentemente.'])->assertRedirect(); $e->refresh(); $this->actingAs($this->f['issuer'])->put($url, ['version' => 3, 'payload' => $this->data['payload']])->assertConflict(); expect(fn () => DB::table('supplier_evaluations')->where('id', $e->id)->update(['status' => 'draft']))->toThrow(QueryException::class); expect(fn () => DB::table('supplier_evaluations')->where('id', $e->id)->delete())->toThrow(QueryException::class); $this->post($url.'/rever', ['version' => 3])->assertRedirect(); $child = SupplierEvaluation::query()->where('previous_id', $e->id)->sole(); $this->post($url.'/rever', ['version' => 3])->assertRedirect('/fornecedores/avaliacoes/'.$child->id); expect(SupplierEvaluation::query()->count())->toBe(2)->and($child->revision)->toBe(2)->and($child->status)->toBe('draft'); Supplier::query()->whereKey($e->supplier_id)->update(['name' => 'Nome posterior do cadastro']); $this->get($url.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('evaluation.status', 'approved')->where('evaluation.version', 3)->where('evaluation.supplier_snapshot.name', $e->supplier_snapshot['name'])->where('nextRevisionId', $child->id)); }); test('evaluation endpoints enforce company department profile and current-role access', function () { $e = evaluationDraft($this); $url = '/fornecedores/avaliacoes/'.$e->id; $department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Other department']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->get($url)->assertNotFound(); $this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); $this->actingAs($this->f['receiver'])->get($url)->assertForbidden(); $this->post('/fornecedores/avaliacoes', $this->data)->assertForbidden(); $other = receiptFixture(); $this->actingAs($other['issuer'])->get($url)->assertNotFound(); $this->actingAs($this->f['issuer']); DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']); $this->post($url.'/submit', ['version' => 1])->assertForbidden(); auth()->logout(); $this->get($url)->assertRedirect('/login'); }); test('forged metadata and incompatible orders or dates are refused without creating evaluations', function () { foreach ([['tenant_id' => 999], ['created_by' => 999], ['supplier_snapshot' => ['name' => 'Forged']]] as $forged) { $this->postJson('/fornecedores/avaliacoes', [...$this->data, ...$forged])->assertUnprocessable(); } $this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'period_end' => '2025-12-01']])->assertUnprocessable(); $this->postJson('/fornecedores/avaliacoes', [...$this->data, 'payload' => [...$this->data['payload'], 'order_ids' => [999999]]])->assertUnprocessable(); expect(SupplierEvaluation::query()->count())->toBe(0); }); test('missing critical evidence prevents submission and no supplier eligibility is changed', function () { $this->data['payload']['critical_occurrences'] = 'Ocorrência fictícia que exige referência.'; $e = evaluationDraft($this); $supplier = Supplier::query()->findOrFail($e->supplier_id)->getAttributes(); $this->postJson('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertUnprocessable()->assertJsonValidationErrors('payload.critical_evidence'); expect($e->refresh()->status)->toBe('draft')->and(Supplier::query()->findOrFail($e->supplier_id)->getAttributes())->toBe($supplier); }); test('a failed audit rolls the evaluation change back atomically', function () { $e = evaluationDraft($this); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure')); expect(fn () => app(SupplierEvaluationWorkflow::class)->save($this->f['issuer'], $e->id, 1, [...$this->data['payload'], 'conclusion' => 'This must be rolled back']))->toThrow(RuntimeException::class); expect($e->refresh()->version)->toBe(1)->and($e->payload['conclusion'])->toBe($this->data['payload']['conclusion']); }); test('a newly declared process conflict prevents review of the stored evaluation', function () { $e = submittedEvaluation($this); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício declarado após a submissão']); $this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa após declaração de conflito'])->assertForbidden(); expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2); }); function qualityEvaluationTasks(object $test, User $actor): array { $test->actingAs($actor); return array_values(array_filter(app(WorkflowTaskInbox::class)->build($actor, 'procurement'), fn ($row) => str_starts_with($row['id'], 'supplier-evaluation:'))); } test('quality task follows preparation independent review return and approval', function () { $e = evaluationDraft($this); expect(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1) ->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); $this->actingAs($this->f['issuer'])->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 1])->assertRedirect(); expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]); $tasks = qualityEvaluationTasks($this, $this->reviewer); expect($tasks)->toHaveCount(1)->and($tasks[0]['task'])->toBe('supplier_evaluation_review')->and($tasks[0]['due_on'])->toBeNull(); $this->get($tasks[0]['href'])->assertInertia(fn (Assert $p) => $p->where('canReview', true)); $this->post('/fornecedores/avaliacoes/'.$e->id.'/return', ['version' => 2, 'reason' => 'Conferir novamente o âmbito e as evidências.'])->assertRedirect(); expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toHaveCount(1); $this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 3])->assertRedirect(); $this->actingAs($this->reviewer)->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 4, 'reason' => 'Âmbito e evidências conferidos independentemente.'])->assertRedirect(); expect(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([])->and(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); $before = $e->refresh()->getAttributes(); qualityEvaluationTasks($this, $this->reviewer); expect($e->fresh()->getAttributes())->toBe($before); }); test('quality task excludes all contributors after shared editing and does not publish conclusions', function () { $e = evaluationDraft($this); $this->actingAs($this->reviewer)->put('/fornecedores/avaliacoes/'.$e->id, ['version' => 1, 'payload' => [...$this->data['payload'], 'scope' => 'Âmbito corrigido por outro preparador.']])->assertRedirect(); expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1); $this->post('/fornecedores/avaliacoes/'.$e->id.'/submit', ['version' => 2])->assertRedirect(); expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and(qualityEvaluationTasks($this, $this->f['issuer']))->toBe([]); $independent = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $tasks = qualityEvaluationTasks($this, $independent); expect($tasks)->toHaveCount(1)->and(array_key_exists('payload', $tasks[0]))->toBeFalse(); }); test('quality task excludes foreign company department and current process conflict', function () { $e = submittedEvaluation($this); $other = receiptFixture(); expect(qualityEvaluationTasks($this, $other['issuer']))->toBe([]); $dept = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro âmbito']); $head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $dept->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); expect(qualityEvaluationTasks($this, $head))->toBe([]); ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->reviewer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício no processo avaliado.']); expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); $this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false)); }); test('quality task keeps old pending evaluations visible through pagination without writing', function () { $e = submittedEvaluation($this); for ($i = 0; $i < 104; $i++) { $copy = $e->replicate(); $copy->operation_key = (string) Str::uuid(); $copy->save(); } $before = AuditLog::query()->count(); expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(105); $this->get('/requisicoes/tarefas?module=purchases&search=AVA-')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 25)->where('tasks.data.0.id', 'supplier-evaluation:'.$e->id.':review:2')); $this->get('/requisicoes/tarefas?module=purchases&search=AVA-&page=5')->assertInertia(fn (Assert $p) => $p->where('tasks.total', 105)->has('tasks.data', 5)); expect(AuditLog::query()->count())->toBe($before); }); test('quality task blocks maintenance writes and expired memberships using current authority', function () { $e = submittedEvaluation($this); supplyGrant($this->reviewer, ['finance']); DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([]); $this->get('/fornecedores/avaliacoes')->assertInertia(fn (Assert $p) => $p->where('canCreate', false)); $this->get('/fornecedores/avaliacoes/'.$e->id)->assertInertia(fn (Assert $p) => $p->where('canReview', false)); $this->post('/fornecedores/avaliacoes/'.$e->id.'/approve', ['version' => 2, 'reason' => 'Tentativa com acesso apenas para manutenção.'])->assertForbidden(); $this->post('/fornecedores/avaliacoes', [...$this->data, 'operation_key' => (string) Str::uuid()])->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->reviewer->id)->update(['expires_at' => now()->subMinute()]); expect(qualityEvaluationTasks($this, $this->reviewer))->toBe([])->and($e->refresh()->status)->toBe('review'); }); test('quality task and commands reject a revoked role even with a previously loaded actor', function () { $e = submittedEvaluation($this); expect(qualityEvaluationTasks($this, $this->reviewer))->toHaveCount(1); DB::table('users')->where('id', $this->reviewer->id)->update(['purchasing_role' => 'requester']); expect(qualityEvaluationTasks($this, $this->reviewer->fresh()))->toBe([]); $this->actingAs($this->reviewer); try { app(SupplierEvaluationWorkflow::class)->transition($this->reviewer, $e->id, 2, 'approve', 'Tentativa com instância anterior à revogação.'); $this->fail('O papel revogado não pode decidir.'); } catch (HttpException $exception) { expect($exception->getStatusCode())->toBe(403); } expect($e->refresh()->status)->toBe('review')->and($e->version)->toBe(2); });