@@ -8,9 +8,11 @@ use App\Services\RequisitionWorkflow;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/AiPlanFixture.php';
|
||||
|
||||
function organizationProfile(int $tenant, string $name, array $permissions): int
|
||||
{
|
||||
@@ -34,6 +36,101 @@ beforeEach(function () {
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('intelligence settings are independently configurable audited and scoped without rewriting processes', function () {
|
||||
$before = $this->f['r']->fresh()->toArray();
|
||||
$body = ['version' => 1, 'procurement_brief' => false, 'people_brief' => true, 'reason' => 'Apresentar sínteses apenas no percurso de Pessoas.'];
|
||||
$this->postJson($this->base.'/intelligence', $body)->assertRedirect();
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy)->toBe(['revision' => 1, 'configured' => true, 'procurement_brief' => false, 'people_brief' => true])
|
||||
->and($this->f['r']->fresh()->toArray())->toBe($before);
|
||||
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('intelligenceReady', true)->where('intelligencePolicy.procurement_brief', false));
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page->where('brief', null)->where('report.requisition.id', $this->f['r']->id));
|
||||
$this->postJson($this->base.'/intelligence', $body)->assertUnprocessable();
|
||||
$this->postJson($this->base.'/intelligence', [...$body, 'version' => 2, 'procurement_brief' => true, 'people_brief' => false])->assertRedirect();
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page->where('brief.method', 'procurement_evidence_brief_v1'));
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy['revision'])->toBe(2)
|
||||
->and(AuditLog::query()->where('action', 'organization.intelligence')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('intelligence configuration rejects unauthorised actors secret fields external activation and cross company targets', function () {
|
||||
$body = ['version' => 1, 'procurement_brief' => false, 'people_brief' => false, 'reason' => 'Configuração de sínteses da organização.'];
|
||||
foreach (['api_key' => 'DO-NOT-STORE', 'external_processing_enabled' => true, 'tenant_id' => $this->f['tenant']->id + 1, 'procurement_brief' => 'invalid'] as $key => $value) {
|
||||
$this->postJson($this->base.'/intelligence', [...$body, $key => $value])->assertUnprocessable();
|
||||
}
|
||||
$this->actingAs($this->f['receiver'])->postJson($this->base.'/intelligence', $body)->assertForbidden();
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
|
||||
->and(AuditLog::query()->where('action', 'organization.intelligence')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('intelligence policy and global version roll back when audit cannot be recorded', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->postJson($this->base.'/intelligence', ['version' => 1, 'procurement_brief' => false, 'people_brief' => false, 'reason' => 'Não persistir sem registo de auditoria.']))->toThrow(RuntimeException::class);
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
|
||||
->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
|
||||
});
|
||||
|
||||
test('AI plan is versioned audited and preserved when local summary choices change without activating a provider', function () {
|
||||
Http::fake();
|
||||
$before = $this->f['r']->fresh()->toArray();
|
||||
$this->postJson($this->base.'/intelligence', ['version' => 1, 'procurement_brief' => false, 'people_brief' => true, 'reason' => 'Ocultar a síntese local de Compras.'])->assertRedirect();
|
||||
$this->postJson($this->base.'/ai_plan', [...aiPlanInput(2), 'monthly_budget_usd' => '10'])->assertRedirect();
|
||||
$policy = $this->f['tenant']->fresh()->intelligence_policy;
|
||||
expect($policy['procurement_brief'])->toBeFalse()->and($policy['revision'])->toBe(2)
|
||||
->and($policy['ai_plan']['state'])->toBe('draft')->and($policy['ai_plan']['monthly_budget_usd'])->toBe('10.00');
|
||||
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('aiPlan.provider', 'openai')->where('aiPlan.state', 'draft'));
|
||||
$this->postJson($this->base.'/intelligence', ['version' => 3, 'procurement_brief' => true, 'people_brief' => false, 'reason' => 'Mudar apresentação sem apagar o plano.'])->assertRedirect();
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy['ai_plan'])->toBe($policy['ai_plan'])
|
||||
->and($this->f['r']->fresh()->toArray())->toBe($before)
|
||||
->and(AuditLog::query()->where('action', 'organization.ai_plan')->count())->toBe(1);
|
||||
$this->postJson($this->base.'/ai_plan', aiPlanInput(3))->assertUnprocessable();
|
||||
Http::assertNotSent(fn ($request) => ! in_array(parse_url($request->url(), PHP_URL_HOST), ['127.0.0.1', 'localhost'], true));
|
||||
});
|
||||
|
||||
test('AI plan rejects secret endpoints activation invalid scopes and unbounded or incomplete settings', function () {
|
||||
foreach ([['api_key' => 'NOT-A-SECRET'], ['endpoint' => 'https://invalid.example'], ['state' => 'active'], ['external_processing_enabled' => true],
|
||||
['provider' => 'unknown'], ['model' => 'https://invalid.example/model'], ['model' => 'sk-fictitious-not-a-real-key'], ['domains' => ['unknown']], ['data_scopes' => ['raw_candidate_cv']],
|
||||
['domains' => []], ['monthly_budget_usd' => '0'], ['monthly_requests' => 0], ['max_input_bytes' => 64001], ['max_output_tokens' => 4001]] as $bad) {
|
||||
$this->postJson($this->base.'/ai_plan', [...aiPlanInput(), ...$bad])->assertUnprocessable();
|
||||
}
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
|
||||
->and(AuditLog::query()->where('action', 'organization.ai_plan')->count())->toBe(0);
|
||||
$this->actingAs($this->f['receiver'])->postJson($this->base.'/ai_plan', aiPlanInput())->assertForbidden();
|
||||
});
|
||||
|
||||
test('AI plan rolls back with the shared configuration version when audit fails', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->postJson($this->base.'/ai_plan', aiPlanInput()))->toThrow(RuntimeException::class);
|
||||
expect($this->f['tenant']->fresh()->intelligence_policy)->toBeNull()
|
||||
->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
|
||||
});
|
||||
|
||||
test('tax configuration is versioned tenant scoped and never rewrites existing process lines', function () {
|
||||
$before = $this->f['r']->items()->get()->toArray();
|
||||
$this->postJson($this->base.'/tax', ['version' => 1, 'default_tax_rate' => '5.00', 'reason' => 'Enquadramento fiscal específico da empresa.'])->assertRedirect();
|
||||
expect($this->f['tenant']->fresh()->default_tax_rate)->toBe('5.00')
|
||||
->and($this->f['r']->items()->get()->toArray())->toBe($before);
|
||||
$this->get('/requisicoes/criar')->assertForbidden();
|
||||
$this->postJson($this->base.'/tax', ['version' => 1, 'default_tax_rate' => '16', 'reason' => 'Pedido com versão antiga não pode gravar.'])->assertUnprocessable();
|
||||
$this->postJson($this->base.'/tax', ['version' => 2, 'default_tax_rate' => '101', 'reason' => 'Taxa inválida não pode ser gravada.'])->assertUnprocessable();
|
||||
expect(AuditLog::query()->where('action', 'organization.tax')->count())->toBe(1);
|
||||
$this->actingAs($this->f['receiver'])->postJson($this->base.'/tax', ['version' => 2, 'default_tax_rate' => '16', 'reason' => 'Sem competência de administração.'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('comparison policy requires valid weights authority and current version without rewriting existing processes', function () {
|
||||
$body = ['version' => 1, 'minimum_proposals' => 2, 'minimum_evaluators' => 1, 'weights' => ['price' => 50, 'quality' => 20, 'delivery' => 10, 'history' => 10, 'payment' => 10], 'reason' => 'Regra comercial aprovada para novos processos.'];
|
||||
$this->postJson($this->base.'/comparison', [...$body, 'weights' => [...$body['weights'], 'price' => 49]])->assertUnprocessable();
|
||||
expect($this->f['tenant']->fresh()->comparison_policy)->toBeNull();
|
||||
$this->postJson($this->base.'/comparison', [...$body, 'minimum_evaluators' => 0])->assertUnprocessable();
|
||||
$this->postJson($this->base.'/comparison', [...$body, 'weights' => [...$body['weights'], 'other' => 0]])->assertUnprocessable();
|
||||
$this->postJson($this->base.'/comparison', $body)->assertRedirect();
|
||||
expect($this->f['tenant']->fresh()->comparison_policy['revision'])->toBe(1)->and($this->f['r']->fresh()->comparison_policy)->toBeNull();
|
||||
$this->postJson($this->base.'/comparison', $body)->assertUnprocessable();
|
||||
$this->get($this->base)->assertInertia(fn (Assert $page) => $page->where('comparisonPolicy.minimum_proposals', 2));
|
||||
expect(AuditLog::query()->where('action', 'organization.comparison')->count())->toBe(1);
|
||||
$this->actingAs($this->f['receiver'])->postJson($this->base.'/comparison', [...$body, 'version' => 2])->assertForbidden();
|
||||
});
|
||||
|
||||
test('setup requires a second named administrator and atomically gives both configured administration', function () {
|
||||
$new = User::factory()->create();
|
||||
$this->actingAs($new)->postJson('/empresa/iniciar', ['name' => 'Empresa de entrega QA', 'department' => 'Sede'])->assertUnprocessable()->assertJsonValidationErrors(['second_name', 'second_email', 'second_password']);
|
||||
|
||||
Reference in New Issue
Block a user