@@ -45,7 +45,7 @@ test('every purchasing profile can make a personal declaration with server contr
|
||||
expect($declaration->declared_at)->not->toBeNull();
|
||||
expect(AuditLog::query()->where('action', 'conflict.declared')->count())->toBe(1);
|
||||
$this->post('/conflitos', [...personalConflict($this->supplier->id), 'user_id' => $this->owner->id])->assertSessionHasErrors('user_id');
|
||||
})->with(PurchasingRole::cases());
|
||||
})->with(array_values(array_filter(PurchasingRole::cases(), fn (PurchasingRole $role) => $role !== PurchasingRole::Configured)));
|
||||
|
||||
test('consultation is limited to personal declarations except finance and administration oversight', function (PurchasingRole $role, int $expected) {
|
||||
$this->actingAs($this->owner)->post('/conflitos', personalConflict($this->supplier->id));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Services\EvaluationNavigation;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
test('local evaluation links send a guest to the existing password-free entry and retain the intended report', function () {
|
||||
app()->instance('env', 'payable-testing');
|
||||
$this->get('http://127.0.0.1:8005/relatorios?status=approved')->assertRedirect('/avaliar')
|
||||
->assertSessionHas('url.intended', 'http://127.0.0.1:8005/relatorios?status=approved');
|
||||
$this->get('http://127.0.0.1:8005/login')->assertRedirect('/avaliar');
|
||||
});
|
||||
|
||||
test('normal application still uses its credential login', function () {
|
||||
$this->get('/relatorios')->assertRedirect('/login');
|
||||
$this->get('/login')->assertOk();
|
||||
});
|
||||
|
||||
test('evaluation navigation requires both a local client and local host', function () {
|
||||
app()->instance('env', 'payable-testing');
|
||||
foreach ([['http://127.0.0.1:8005/relatorios', '127.0.0.1', true], ['http://compras.tempo.co.mz/relatorios', '127.0.0.1', false], ['http://localhost:8005/relatorios', '192.0.2.1', false]] as [$url, $ip, $allowed]) {
|
||||
$request = Request::create($url, 'GET', [], [], [], ['REMOTE_ADDR' => $ip]);
|
||||
expect(EvaluationNavigation::local($request))->toBe($allowed);
|
||||
}
|
||||
app()->instance('env', 'production');
|
||||
expect(EvaluationNavigation::local(Request::create('http://127.0.0.1:8005/relatorios', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1'])))->toBeFalse();
|
||||
});
|
||||
|
||||
test('evaluation return destination preserves local paths and filters but rejects external or malformed destinations', function ($intended, string $expected) {
|
||||
$request = Request::create('http://127.0.0.1:8005/avaliar/entrar', 'POST');
|
||||
expect(EvaluationNavigation::returnPath($intended, $request))->toBe($expected);
|
||||
})->with([
|
||||
['http://127.0.0.1:8005/relatorios?status=approved', '/relatorios?status=approved'],
|
||||
['/requisicoes/1/processo', '/requisicoes/1/processo'],
|
||||
[null, '/dashboard'],
|
||||
[['invalid'], '/dashboard'],
|
||||
['https://example.test/relatorios', '/dashboard'],
|
||||
['//example.test/relatorios', '/dashboard'],
|
||||
['http://127.0.0.1:8015/relatorios', '/dashboard'],
|
||||
['javascript:alert(1)', '/dashboard'],
|
||||
['/login', '/dashboard'],
|
||||
['/avaliar', '/dashboard'],
|
||||
["/relatorios\r\nLocation: https://example.test", '/dashboard'],
|
||||
['/\\example.test', '/dashboard'],
|
||||
]);
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Services\OrganizationAccess;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function organizationProfile(int $tenant, string $name, array $permissions): int
|
||||
{
|
||||
return DB::table('organization_profiles')->insertGetId(['tenant_id' => $tenant, 'name' => $name, 'permissions' => json_encode($permissions, JSON_THROW_ON_ERROR), 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
function organizationMember(int $tenant, int $user, int $profile): void
|
||||
{
|
||||
DB::table('organization_memberships')->insert(['tenant_id' => $tenant, 'user_id' => $user, 'profile_id' => $profile, 'maintenance' => false, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->admin = $this->f['issuer'];
|
||||
$this->second = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Requester]);
|
||||
$this->adminProfile = organizationProfile($this->f['tenant']->id, 'Direcção de sistemas', ['admin']);
|
||||
organizationMember($this->f['tenant']->id, $this->admin->id, $this->adminProfile);
|
||||
organizationMember($this->f['tenant']->id, $this->second->id, $this->adminProfile);
|
||||
DB::table('organization_access')->insert(['tenant_id' => $this->f['tenant']->id, 'active' => true, 'version' => 1, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->base = '/administracao/parametrizacao';
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
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']);
|
||||
$this->post('/empresa/iniciar', ['name' => 'Empresa de entrega QA', 'department' => 'Sede', 'first_limit' => '1000.00', 'second_limit' => '2000.00', 'includes_tax' => true, 'second_name' => 'Segundo administrador QA', 'second_email' => 'segundo-entrega@example.test', 'second_password' => 'QA-Password-123456', 'second_password_confirmation' => 'QA-Password-123456'])->assertRedirect();
|
||||
$new->refresh();
|
||||
$second = User::query()->where('email', 'segundo-entrega@example.test')->sole();
|
||||
expect($second->tenant_id)->toBe($new->tenant_id)->and(Hash::check('QA-Password-123456', $second->password))->toBeTrue();
|
||||
expect(app(OrganizationAccess::class)->permissions($new))->toBe(['admin'])->and(app(OrganizationAccess::class)->permissions($second))->toBe(['admin']);
|
||||
$this->actingAs($second)->get($this->base)->assertOk();
|
||||
});
|
||||
|
||||
test('both administrators can access parameters independently of the stored development role', function () {
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('active', true)->where('version', 1)->where('profiles.0.name', 'Direcção de sistemas')->has('users')->missing('users.0.password'));
|
||||
$this->actingAs($this->second)->get($this->base)->assertOk();
|
||||
$this->get('/empresa')->assertRedirect($this->base);
|
||||
expect($this->second->purchasing_role)->toBe(PurchasingRole::Requester);
|
||||
});
|
||||
|
||||
test('custom profile grants a real operational permission and the legacy profile grants nothing after activation', function () {
|
||||
$this->post($this->base.'/profile', ['version' => 1, 'name' => 'Conferência da empresa', 'permissions' => ['finance']])->assertRedirect();
|
||||
$profile = (int) DB::table('organization_profiles')->where('name', 'Conferência da empresa')->value('id');
|
||||
$data = ['version' => 2, 'name' => 'Utilizador configurado', 'email' => 'configurado@example.test', 'password' => 'QA-Password-123456', 'password_confirmation' => 'QA-Password-123456', 'department_id' => $this->f['r']->department_id, 'profile_ids' => [$profile], 'reason' => 'Atribuição aprovada pela empresa no ensaio.'];
|
||||
$this->post($this->base.'/user', $data)->assertRedirect();
|
||||
$user = User::query()->where('email', $data['email'])->sole();
|
||||
expect($user->purchasing_role)->toBe(PurchasingRole::Configured)->and($user->hasFunctions(['finance']))->toBeTrue()->and($user->hasFunctions(['admin']))->toBeFalse();
|
||||
$this->actingAs($user)->get('/facturas')->assertOk();
|
||||
$this->get('/requisicoes/criar')->assertForbidden();
|
||||
$this->get($this->base)->assertForbidden();
|
||||
$legacy = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($legacy)->get('/facturas')->assertForbidden();
|
||||
$this->get($this->base)->assertForbidden();
|
||||
});
|
||||
|
||||
test('two permanent administrators cannot be removed by assignment profile editing or expiry', function () {
|
||||
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [], 'maintenance' => false, 'reason' => 'Tentativa de retirar o segundo administrador.'])->assertUnprocessable()->assertJsonValidationErrors('administrators');
|
||||
$this->postJson($this->base.'/profile', ['version' => 1, 'id' => $this->adminProfile, 'name' => 'Sem poderes', 'permissions' => []])->assertUnprocessable()->assertJsonValidationErrors('administrators');
|
||||
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => false, 'expires_at' => now()->addDay()->toIso8601String(), 'reason' => 'Tentativa de tornar temporário o segundo administrador.'])->assertUnprocessable();
|
||||
expect(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1)->and(DB::table('organization_memberships')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('maintenance is named temporary read-only audited and expires without bypassing authentication', function () {
|
||||
$profile = organizationProfile($this->f['tenant']->id, 'Consulta TEMPO', ['finance']);
|
||||
$support = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$grant = ['version' => 1, 'user_id' => $support->id, 'profile_ids' => [$profile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Diagnóstico TEMPO identificado e autorizado no ensaio.'];
|
||||
$this->post($this->base.'/assign', $grant)->assertRedirect();
|
||||
$this->actingAs($support)->get('/facturas')->assertOk();
|
||||
expect(AuditLog::query()->where('action', 'maintenance.consulted')->count())->toBe(1);
|
||||
$this->post('/requisicoes', [])->assertForbidden();
|
||||
$this->get($this->base)->assertForbidden();
|
||||
$this->travel(2)->hours();
|
||||
$this->get('/facturas')->assertForbidden();
|
||||
auth()->logout();
|
||||
$this->get('/facturas')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('maintenance cannot receive administration and access can be revoked immediately', function () {
|
||||
$this->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Tentativa de conceder administração à manutenção.'])->assertUnprocessable();
|
||||
$profile = organizationProfile($this->f['tenant']->id, 'Compras da empresa', ['procurement']);
|
||||
organizationMember($this->f['tenant']->id, $this->f['receiver']->id, $profile);
|
||||
$this->actingAs($this->f['receiver'])->get('/fornecedores')->assertOk();
|
||||
$this->actingAs($this->admin)->post($this->base.'/assign', ['version' => 1, 'user_id' => $this->f['receiver']->id, 'profile_ids' => [], 'maintenance' => false, 'reason' => 'Revogação imediata da atribuição de teste.'])->assertRedirect();
|
||||
$this->actingAs($this->f['receiver'])->get('/fornecedores')->assertForbidden();
|
||||
});
|
||||
|
||||
test('foreign profiles users forged permissions and stale configuration cannot be used', function () {
|
||||
$other = receiptFixture();
|
||||
$foreign = organizationProfile($other['tenant']->id, 'Perfil alheio', ['admin']);
|
||||
$this->actingAs($this->admin)->postJson($this->base.'/assign', ['version' => 1, 'user_id' => $this->second->id, 'profile_ids' => [$foreign], 'maintenance' => false, 'reason' => 'Tentativa sobre um perfil de outra empresa.'])->assertUnprocessable();
|
||||
$this->post($this->base.'/assign', ['version' => 1, 'user_id' => $other['issuer']->id, 'profile_ids' => [$this->adminProfile], 'maintenance' => false, 'reason' => 'Tentativa sobre utilizador de outra empresa.'])->assertNotFound();
|
||||
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Forjado', 'permissions' => ['superuser']])->assertUnprocessable();
|
||||
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Forjado', 'permissions' => ['finance'], 'tenant_id' => $other['tenant']->id])->assertUnprocessable();
|
||||
$this->post($this->base.'/profile', ['version' => 1, 'name' => 'Perfil novo', 'permissions' => ['finance']])->assertRedirect();
|
||||
$this->postJson($this->base.'/profile', ['version' => 1, 'name' => 'Sobreposição', 'permissions' => ['finance']])->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
});
|
||||
|
||||
test('activation requires two assigned administrators and replaces the previous role authority', function () {
|
||||
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => false]);
|
||||
DB::table('tenants')->where('id', $this->f['tenant']->id)->update(['rules_provisional' => false]);
|
||||
DB::table('users')->where('id', $this->admin->id)->update(['purchasing_role' => 'admin']);
|
||||
DB::table('organization_memberships')->where('user_id', $this->second->id)->delete();
|
||||
$this->actingAs($this->admin->refresh())->postJson($this->base.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação da configuração escolhida pela empresa.'])->assertUnprocessable();
|
||||
organizationMember($this->f['tenant']->id, $this->second->id, $this->adminProfile);
|
||||
$this->post($this->base.'/activate', ['version' => 1, 'confirm' => true, 'reason' => 'Activação da configuração escolhida pela empresa.'])->assertRedirect();
|
||||
expect(app(OrganizationAccess::class)->active($this->f['tenant']->id))->toBeTrue();
|
||||
$this->actingAs($this->f['receiver'])->get('/recepcoes')->assertForbidden();
|
||||
});
|
||||
|
||||
test('configuration audit failure rolls a profile and its version back', function () {
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
||||
$this->withoutExceptionHandling();
|
||||
expect(fn () => $this->post($this->base.'/profile', ['version' => 1, 'name' => 'Não persistir', 'permissions' => ['finance']]))->toThrow(RuntimeException::class);
|
||||
expect(DB::table('organization_profiles')->where('name', 'Não persistir')->exists())->toBeFalse()->and(DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->value('version'))->toBe(1);
|
||||
});
|
||||
|
||||
test('company rules replace provisional defaults exactly and preserve existing submitted snapshots', function () {
|
||||
$before = $this->f['r']->refresh()->approval_snapshot;
|
||||
$this->post($this->base.'/rules', ['version' => 1, 'first_limit' => '1234.56', 'second_limit' => '9876.54', 'includes_tax' => false, 'reason' => 'Limites expressamente definidos pela empresa no ensaio.'])->assertRedirect();
|
||||
$company = $this->f['tenant']->refresh();
|
||||
expect($company->first_limit_minor)->toBe(123456)->and($company->second_limit_minor)->toBe(987654)->and($company->rules_provisional)->toBeFalse()->and($company->approval_includes_tax)->toBeFalse();
|
||||
expect($this->f['r']->refresh()->approval_snapshot)->toBe($before);
|
||||
$this->postJson($this->base.'/rules', ['version' => 2, 'first_limit' => '2000.00', 'second_limit' => '1000.00', 'includes_tax' => true, 'reason' => 'Intervalo incoerente deve ser recusado.'])->assertUnprocessable()->assertJsonValidationErrors('second_limit');
|
||||
});
|
||||
|
||||
test('maintenance profile cannot acquire administration by editing its permissions', function () {
|
||||
$profile = organizationProfile($this->f['tenant']->id, 'Diagnóstico temporário', ['finance']);
|
||||
$support = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id]);
|
||||
$this->post($this->base.'/assign', ['version' => 1, 'user_id' => $support->id, 'profile_ids' => [$profile], 'maintenance' => true, 'expires_at' => now()->addHour()->toIso8601String(), 'reason' => 'Acesso temporário nominativo para diagnóstico.'])->assertRedirect();
|
||||
$this->postJson($this->base.'/profile', ['version' => 2, 'id' => $profile, 'name' => 'Diagnóstico temporário', 'permissions' => ['finance', 'admin']])->assertUnprocessable()->assertJsonValidationErrors('permissions');
|
||||
expect($support->hasFunctions(['admin']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the product default seeder does not create demonstration accounts or change existing users', function () {
|
||||
$before = User::query()->orderBy('id')->get()->map(fn (User $u) => $u->getAttributes())->all();
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
expect(User::query()->orderBy('id')->get()->map(fn (User $u) => $u->getAttributes())->all())->toBe($before);
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a configured account has no fallback development permissions while activation is pending', function () {
|
||||
$staged = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Configured]);
|
||||
organizationMember($this->f['tenant']->id, $staged->id, $this->adminProfile);
|
||||
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => false]);
|
||||
$this->actingAs($staged)->get($this->base)->assertForbidden();
|
||||
$this->get('/requisicoes/criar')->assertForbidden();
|
||||
expect(app(OrganizationAccess::class)->canRequest($staged))->toBeFalse();
|
||||
DB::table('organization_access')->where('tenant_id', $this->f['tenant']->id)->update(['active' => true]);
|
||||
$this->get($this->base)->assertOk();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
require_once __DIR__.'/../Support/PaymentFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
});
|
||||
|
||||
function reportCopy(Requisition $original, string $number, array $attributes = []): Requisition
|
||||
{
|
||||
$copy = $original->replicate();
|
||||
$copy->forceFill(['number' => $number, ...$attributes])->save();
|
||||
|
||||
return $copy;
|
||||
}
|
||||
|
||||
test('report entry connects visible requests to the existing process and report without business writes', function () {
|
||||
$audits = AuditLog::query()->count();
|
||||
$this->get('/relatorios')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page
|
||||
->component('reports/procurement')->has('records.data', 1)
|
||||
->where('records.data.0.id', $this->f['r']->id)
|
||||
->where('records.data.0.total_minor', (string) $this->f['r']->total_minor)
|
||||
->where('records.data.0.order_number', $this->f['order']->number)
|
||||
->where('counts.approved', 1));
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertOk();
|
||||
$this->get('/requisicoes/'.$this->f['r']->id.'/relatorio')->assertOk();
|
||||
expect(AuditLog::query()->count())->toBe($audits);
|
||||
});
|
||||
|
||||
test('report dates include the whole final day and allow an end date without a start', function () {
|
||||
$this->f['r']->forceFill(['created_at' => '2026-01-02 23:59:59'])->save();
|
||||
reportCopy($this->f['r'], 'NEXT-DAY', ['created_at' => '2026-01-03 00:00:00', 'status' => 'draft']);
|
||||
$this->get('/relatorios?from=2026-01-02&to=2026-01-02&status=approved')->assertInertia(fn (Assert $page) => $page->where('records.total', 1)->where('counts.approved', 1)->where('filters.to', '2026-01-02'));
|
||||
$this->get('/relatorios?to=2026-01-02')->assertInertia(fn (Assert $page) => $page->where('records.total', 1));
|
||||
$csv = $this->get('/relatorios/compras.csv?from=2026-01-02&to=2026-01-02&status=approved')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain($this->f['r']->number)->not->toContain('NEXT-DAY')->toContain('2026-01-02');
|
||||
});
|
||||
|
||||
test('report counts and CSV use all filtered requests rather than the displayed page', function () {
|
||||
foreach (range(1, 206) as $index) {
|
||||
reportCopy($this->f['r'], 'REPORT-'.$index);
|
||||
}
|
||||
$this->get('/relatorios')->assertInertia(fn (Assert $page) => $page->has('records.data', 25)->where('records.total', 207)->where('counts.approved', 207));
|
||||
$csv = $this->get('/relatorios/compras.csv?page=2')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain($this->f['r']->number)->toContain('REPORT-206');
|
||||
$this->get('/relatorios?search=REPORT-2')->assertInertia(fn (Assert $page) => $page->where('records.total', 18));
|
||||
});
|
||||
|
||||
test('report filters and CSV cannot expose another department or company', function () {
|
||||
$own = $this->f;
|
||||
$other = receiptFixture();
|
||||
$other['r']->forceFill(['number' => 'OTHER-COMPANY-SECRET'])->save();
|
||||
$this->actingAs($own['issuer']);
|
||||
$department = Department::query()->create(['tenant_id' => $own['tenant']->id, 'name' => 'Other department']);
|
||||
reportCopy($own['r'], 'OTHER-DEPARTMENT-SECRET', ['department_id' => $department->id]);
|
||||
$head = User::factory()->create(['tenant_id' => $own['tenant']->id, 'department_id' => $own['r']->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get('/relatorios')->assertInertia(fn (Assert $page) => $page->where('records.total', 1)->has('departments', 1));
|
||||
$this->get('/relatorios?department_id='.$department->id)->assertInertia(fn (Assert $page) => $page->where('records.total', 0));
|
||||
$csv = $this->get('/relatorios/compras.csv')->assertOk()->streamedContent();
|
||||
expect($csv)->not->toContain('OTHER-DEPARTMENT-SECRET')->not->toContain('OTHER-COMPANY-SECRET');
|
||||
});
|
||||
|
||||
test('reports reload current access and protect both HTML and CSV from unauthorised profiles and guests', function () {
|
||||
$this->actingAs($this->f['receiver'])->get('/relatorios')->assertForbidden();
|
||||
$this->get('/relatorios/compras.csv')->assertForbidden();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
|
||||
$this->get('/relatorios')->assertForbidden();
|
||||
$this->get('/relatorios/compras.csv')->assertForbidden();
|
||||
auth()->logout();
|
||||
$this->get('/relatorios')->assertRedirect('/login');
|
||||
$this->get('/relatorios/compras.csv')->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('reports reject invalid filters on both the screen and export', function () {
|
||||
foreach (['status=unknown', 'from=invalid', 'from=2026-02-02&to=2026-01-01', 'department_id=-1'] as $filter) {
|
||||
$this->getJson('/relatorios?'.$filter)->assertUnprocessable();
|
||||
$this->getJson('/relatorios/compras.csv?'.$filter)->assertUnprocessable();
|
||||
}
|
||||
});
|
||||
|
||||
test('CSV preserves integer money and escapes formula-like user text', function () {
|
||||
$this->f['r']->forceFill(['number' => '=SYNTHETIC()', 'total_minor' => 9007199254740993])->save();
|
||||
$this->f['department']->forceFill(['name' => '+SYNTHETIC'])->save();
|
||||
$csv = $this->get('/relatorios/compras.csv')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain("'=SYNTHETIC()")->toContain("'+SYNTHETIC")->toContain('9007199254740993')->not->toContain('9.007');
|
||||
});
|
||||
|
||||
test('individual report follows receipts invoices and payments without exposing bank data or changing balances', function () {
|
||||
$f = paymentFixture();
|
||||
$this->actingAs($f['author']);
|
||||
$workflow = app(ExternalPaymentWorkflow::class);
|
||||
$payment = $workflow->register($f['author'], $f['invoice'], $f['payment_data']);
|
||||
$this->actingAs($f['validator']);
|
||||
$workflow->review($f['validator'], $payment, 'confirmed', 'Conferência independente de teste');
|
||||
$audits = AuditLog::query()->count();
|
||||
$balance = $workflow->outstanding($f['invoice']);
|
||||
$this->get('/requisicoes/'.$f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page
|
||||
->has('report.fulfilment.receipts', 1)->where('report.fulfilment.finance_visible', true)
|
||||
->where('report.fulfilment.invoices.0.number', $f['invoice']->number)
|
||||
->where('report.fulfilment.invoices.0.total_minor', '6000')
|
||||
->where('report.fulfilment.payments.0.amount_minor', '4000')
|
||||
->where('report.fulfilment.payments.0.status', 'confirmed')
|
||||
->missing('report.fulfilment.payments.0.bank_snapshot')
|
||||
->missing('report.fulfilment.payments.0.external_reference'));
|
||||
expect(AuditLog::query()->count())->toBe($audits)->and($workflow->outstanding($f['invoice']))->toBe($balance);
|
||||
$this->actingAs($f['issuer'])->get('/requisicoes/'.$f['r']->id.'/relatorio')->assertInertia(fn (Assert $page) => $page
|
||||
->has('report.fulfilment.receipts', 1)->where('report.fulfilment.finance_visible', false)
|
||||
->where('report.fulfilment.invoices', null)->where('report.fulfilment.payments', null));
|
||||
});
|
||||
@@ -63,8 +63,8 @@ beforeEach(function () {
|
||||
|
||||
test('an unassigned account can create an isolated company and becomes its administrator', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Nova empresa', 'department' => 'Geral'])->assertRedirect('/dashboard');
|
||||
expect($user->refresh()->purchasing_role)->toBe(PurchasingRole::Admin);
|
||||
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Nova empresa', 'department' => 'Geral', 'first_limit' => '1000.00', 'second_limit' => '2000.00', 'includes_tax' => true, 'second_name' => 'Segundo administrador QA', 'second_email' => 'segundo-admin-qa@example.test', 'second_password' => 'Example-password-234!', 'second_password_confirmation' => 'Example-password-234!'])->assertRedirect('/dashboard');
|
||||
expect($user->refresh()->hasFunctions(['admin']))->toBeTrue()->and($user->purchasing_role)->toBe(PurchasingRole::Configured);
|
||||
expect($user->tenant_id)->not->toBe($this->tenant->id);
|
||||
$this->actingAs($user)->post('/empresa/iniciar', ['name' => 'Outra empresa', 'department' => 'Geral'])->assertForbidden();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\Department;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierEvaluationWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Department;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
function reportEvaluation(array $fixture, ?int $department = null, int $revision = 1, ?int $previous = null): SupplierEvaluation
|
||||
{
|
||||
return SupplierEvaluation::query()->create(['tenant_id' => $fixture['tenant']->id, 'supplier_id' => $fixture['order']->supplier_id, 'department_id' => $department ?? $fixture['r']->department_id,
|
||||
'created_by' => $fixture['issuer']->id, 'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('b', 64), 'status' => 'approved', 'contributors' => [$fixture['issuer']->id],
|
||||
'revision' => $revision, 'previous_id' => $previous, 'supplier_snapshot' => ['id' => $fixture['order']->supplier_id, 'name' => 'Fornecedor preservado QA', 'nuit' => '123456789'],
|
||||
'payload' => ['title' => 'Avaliação QA', 'order_ids' => [], 'criteria' => [], 'period_start' => null, 'period_end' => null]]);
|
||||
}
|
||||
|
||||
function reportMeasure(SupplierEvaluation $evaluation, User $responsible, array $extra = []): SupplierImprovement
|
||||
{
|
||||
return SupplierImprovement::query()->create([...['tenant_id' => $evaluation->tenant_id, 'supplier_evaluation_id' => $evaluation->id, 'created_by' => $responsible->id, 'responsible_id' => $responsible->id,
|
||||
'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('c', 64), 'title' => 'Medida QA', 'description' => 'Objectivo fictício para o relatório.', 'due_on' => '2026-10-29', 'contributors' => [$responsible->id]], ...$extra]);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->evaluation = reportEvaluation($this->f);
|
||||
$this->base = '/relatorios/medidas-fornecedores';
|
||||
$this->travelTo(now()->setDate(2026, 10, 30)->startOfDay());
|
||||
});
|
||||
|
||||
test('report paginates all authorized revisions with counts over the entire filtered set', function () {
|
||||
$next = reportEvaluation($this->f, null, 2, $this->evaluation->id);
|
||||
foreach (range(1, 27) as $i) {
|
||||
reportMeasure($i === 27 ? $next : $this->evaluation, $this->f['issuer'], ['title' => 'Medida '.$i, 'status' => $i === 1 ? 'closed' : 'open']);
|
||||
}
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->component('reports/supplier-improvements')->where('records.total', 27)->has('records.data', 25)->where('counts.open', 26)->where('counts.closed', 1)->where('overdue', 26)->has('suppliers', 1)->has('responsibles', 1)->missing('records.data.0.operation_key')->missing('records.data.0.creation_hash'));
|
||||
$this->get($this->base.'?page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', 2)->where('records.data.1.revision', 2)->where('records.total', 27));
|
||||
});
|
||||
|
||||
test('deadline boundaries exclude closed and today from overdue and include the full end date', function () {
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => 'Prazo ontem']);
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => 'Prazo hoje', 'due_on' => '2026-10-30', 'status' => 'review']);
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => 'Encerrada', 'due_on' => '2026-10-01', 'status' => 'closed', 'reviewed_at' => '2026-10-20 10:30:00']);
|
||||
$this->get($this->base.'?overdue=1')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('overdue', 1)->where('records.data.0.title', 'Prazo ontem')->where('records.data.0.overdue', true));
|
||||
$this->get($this->base.'?from=2026-10-30&to=2026-10-30')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.title', 'Prazo hoje')->where('records.data.0.overdue', false));
|
||||
$this->get($this->base.'?status=closed&overdue=1')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)->where('overdue', 0));
|
||||
expect($this->get($this->base.'.csv?status=closed')->assertOk()->streamedContent())->toContain('2026-10-20T10:30:00+00:00');
|
||||
});
|
||||
|
||||
test('supplier assignee state and title filters are combined and invalid ranges refused', function () {
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => 'Referência ESPECIAL', 'status' => 'returned']);
|
||||
$url = $this->base.'?supplier_id='.$this->evaluation->supplier_id.'&responsible_id='.$this->f['issuer']->id.'&status=returned&search='.urlencode('referência');
|
||||
$this->get($url)->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('counts.returned', 1));
|
||||
$this->get($url.'&from=2026-11-01')->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
$this->getJson($this->base.'?from=2026-11-01&to=2026-10-01')->assertUnprocessable()->assertJsonValidationErrors('to');
|
||||
$this->getJson($this->base.'?status=forged')->assertUnprocessable();
|
||||
$this->getJson($this->base.'.csv?overdue=0')->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('department and company restrictions apply to records counts choices and CSV', function () {
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => 'Visível QA']);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$otherEvaluation = reportEvaluation($this->f, $department->id);
|
||||
$otherResponsible = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Finance, 'name' => 'Responsável não exposto']);
|
||||
reportMeasure($otherEvaluation, $otherResponsible, ['title' => 'Oculta pelo departamento']);
|
||||
$foreign = receiptFixture();
|
||||
reportMeasure(reportEvaluation($foreign), $foreign['issuer'], ['title' => 'Oculta pela empresa']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->get($this->base)->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->has('responsibles', 1)->where('responsibles.0.id', $this->f['issuer']->id));
|
||||
$csv = $this->get($this->base.'.csv')->assertOk()->streamedContent();
|
||||
expect($csv)->toContain('Visível QA')->not->toContain('Oculta', 'Responsável não exposto');
|
||||
$this->get($this->base.'?responsible_id='.$otherResponsible->id)->assertInertia(fn (Assert $p) => $p->where('records.total', 0));
|
||||
});
|
||||
|
||||
test('guest forbidden roles and a stale actor role cannot access either representation', function () {
|
||||
foreach ([$this->base, $this->base.'.csv'] as $url) {
|
||||
$this->actingAs($this->f['receiver'])->get($url)->assertForbidden();
|
||||
}
|
||||
$this->actingAs($this->f['issuer']);
|
||||
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
|
||||
$this->get($this->base)->assertForbidden();
|
||||
$this->get($this->base.'.csv')->assertForbidden();
|
||||
auth()->logout();
|
||||
$this->get($this->base)->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('CSV exports beyond the current page in batches and protects formula-like text without writes', function () {
|
||||
foreach (range(1, 207) as $i) {
|
||||
reportMeasure($this->evaluation, $this->f['issuer'], ['title' => $i === 1 ? '=HYPERLINK("bad")' : 'Linha '.$i, 'result' => $i === 207 ? '+resultado final' : null, 'evidence' => $i === 207 ? '@referencia' : null]);
|
||||
}
|
||||
$supplier = Supplier::query()->findOrFail($this->evaluation->supplier_id)->getAttributes();
|
||||
$evaluation = $this->evaluation->refresh()->getAttributes();
|
||||
$audits = AuditLog::query()->count();
|
||||
$response = $this->get($this->base.'.csv?page=2&status=open')->assertOk();
|
||||
$csv = $response->streamedContent();
|
||||
$rows = array_map(fn ($line) => str_getcsv($line, ';', '"', ''), explode("\n", trim($csv)));
|
||||
expect($rows)->toHaveCount(210)->and($rows[3][4])->toBe('\'=HYPERLINK("bad")')->and($rows[209][4])->toBe('Linha 207')->and($rows[209][11])->toBe('\'+resultado final')->and($rows[209][12])->toBe('\'@referencia');
|
||||
expect($rows[209][16])->toEndWith('/fornecedores/avaliacoes/'.$this->evaluation->id.'/relatorio#medida-207');
|
||||
expect(Supplier::query()->findOrFail($this->evaluation->supplier_id)->getAttributes())->toBe($supplier)->and($this->evaluation->refresh()->getAttributes())->toBe($evaluation)->and(AuditLog::query()->count())->toBe($audits);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\Department;
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierImprovement;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierImprovementWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = receiptFixture();
|
||||
$this->planner = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->closer = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $this->f['r']->department_id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->evaluation = SupplierEvaluation::query()->create(['tenant_id' => $this->f['tenant']->id, 'supplier_id' => $this->f['order']->supplier_id, 'department_id' => $this->f['r']->department_id,
|
||||
'created_by' => $this->f['issuer']->id, 'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('a', 64), 'status' => 'approved', 'contributors' => [$this->f['issuer']->id],
|
||||
'supplier_snapshot' => ['id' => $this->f['order']->supplier_id, 'name' => 'Fornecedor QA', 'nuit' => '123456789'],
|
||||
'payload' => ['title' => 'Avaliação qualitativa QA', 'nature' => 'goods', 'scope' => 'Âmbito fictício', 'order_ids' => [$this->f['order']->id], 'criteria' => [], 'period_start' => '2026-01-01', 'period_end' => '2026-01-31', 'conclusion' => 'Conclusão QA', 'critical_occurrences' => '', 'critical_evidence' => '']]);
|
||||
$this->url = '/fornecedores/avaliacoes/'.$this->evaluation->id.'/medidas';
|
||||
$this->data = ['title' => 'Melhorar identificação dos volumes', 'description' => 'Identificar os volumes segundo a referência da ordem.', 'responsible_id' => $this->f['issuer']->id, 'due_on' => '2026-01-31', 'operation_key' => (string) Str::uuid()];
|
||||
$this->execution = ['result' => 'Identificação do exemplo conferida.', 'evidence' => 'Referência fictícia de recepção QA.'];
|
||||
$this->actingAs($this->planner);
|
||||
});
|
||||
|
||||
function improvementDraft(object $test): SupplierImprovement
|
||||
{
|
||||
$test->post($test->url, $test->data)->assertRedirect();
|
||||
|
||||
return SupplierImprovement::query()->sole();
|
||||
}
|
||||
|
||||
function improvementInReview(object $test): SupplierImprovement
|
||||
{
|
||||
$measure = improvementDraft($test);
|
||||
$test->actingAs($test->f['issuer'])->post($test->url.'/'.$measure->id.'/submit', ['version' => 1, ...$test->execution])->assertRedirect();
|
||||
|
||||
return $measure->refresh();
|
||||
}
|
||||
|
||||
test('improvements are linked to the exact approved evaluation and included in its report', function () {
|
||||
$before = $this->evaluation->refresh()->getAttributes();
|
||||
$m = improvementDraft($this);
|
||||
$this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.canCreate', true)->has('improvements.records', 1)->where('improvements.records.0.canPlan', true)->where('improvements.records.0.canExecute', false));
|
||||
$this->actingAs($this->f['issuer'])->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertInertia(fn (Assert $p) => $p->where('improvements.records.0.canExecute', true));
|
||||
$this->get('/fornecedores/avaliacoes/'.$this->evaluation->id.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('improvements.canCreate', false)->where('improvements.records.0.canPlan', false)->where('improvements.records.0.canExecute', false)->where('improvements.records.0.title', $m->title));
|
||||
expect($this->evaluation->refresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('creation retry is idempotent and reusing the operation with another plan is refused', function () {
|
||||
improvementDraft($this);
|
||||
$this->post($this->url, $this->data)->assertRedirect();
|
||||
$this->postJson($this->url, [...$this->data, 'title' => 'Outra medida no mesmo pedido'])->assertConflict();
|
||||
expect(SupplierImprovement::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_improvement.create')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('only the responsible person can report execution and cannot submit without evidence', function () {
|
||||
$m = improvementDraft($this);
|
||||
$url = $this->url.'/'.$m->id;
|
||||
$this->post($url.'/progress', ['version' => 1, ...$this->execution])->assertForbidden();
|
||||
$this->actingAs($this->f['issuer'])->postJson($url.'/submit', ['version' => 1, 'result' => 'Resultado sem referência'])->assertUnprocessable()->assertJsonValidationErrors('evidence');
|
||||
expect($m->refresh()->status)->toBe('open')->and($m->version)->toBe(1);
|
||||
});
|
||||
|
||||
test('creator and all responsible people are excluded from independent closure', function () {
|
||||
$m = improvementInReview($this);
|
||||
$url = $this->url.'/'.$m->id;
|
||||
$this->post($url.'/close', ['version' => 2, 'reason' => 'Tentativa do próprio responsável'])->assertForbidden();
|
||||
$this->actingAs($this->planner)->post($url.'/close', ['version' => 2, 'reason' => 'Tentativa do autor do plano'])->assertForbidden();
|
||||
$this->actingAs($this->closer)->post($url.'/close', ['version' => 2, 'reason' => 'Resultado e evidência conferidos independentemente.'])->assertRedirect();
|
||||
expect($m->refresh()->status)->toBe('closed')->and($m->reviewed_by)->toBe($this->closer->id);
|
||||
});
|
||||
|
||||
test('return correction and closure retain the separate decisions and evidence history', function () {
|
||||
$m = improvementInReview($this);
|
||||
$url = $this->url.'/'.$m->id;
|
||||
$this->actingAs($this->closer)->post($url.'/return', ['version' => 2, 'reason' => 'Acrescentar referência documental mais precisa.'])->assertRedirect();
|
||||
$corrected = [...$this->execution, 'evidence' => 'Referência QA com identificação suplementar.'];
|
||||
$this->actingAs($this->f['issuer'])->post($url.'/progress', ['version' => 3, ...$corrected])->assertRedirect();
|
||||
$this->post($url.'/submit', ['version' => 4, ...$corrected])->assertRedirect();
|
||||
$this->actingAs($this->closer)->post($url.'/close', ['version' => 5, 'reason' => 'Referência corrigida conferida independentemente.'])->assertRedirect();
|
||||
expect($m->refresh()->status)->toBe('closed')->and($m->version)->toBe(6);
|
||||
expect(AuditLog::query()->where('action', 'supplier_improvement.return')->sole()->after['review_reason'])->toContain('mais precisa');
|
||||
expect(AuditLog::query()->where('action', 'supplier_improvement.progress')->sole()->before['evidence'])->toBe($this->execution['evidence']);
|
||||
});
|
||||
|
||||
test('replanning preserves the old deadline responsible and justification and stale writes fail', function () {
|
||||
$m = improvementDraft($this);
|
||||
$plan = ['version' => 1, 'title' => $m->title, 'description' => $m->description, 'responsible_id' => $this->closer->id, 'due_on' => '2026-02-28', 'reason' => 'Reagendar e transferir a execução de forma documentada.'];
|
||||
$url = $this->url.'/'.$m->id;
|
||||
$this->post($url.'/plan', $plan)->assertRedirect();
|
||||
$this->postJson($url.'/plan', $plan)->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
$this->actingAs($this->f['issuer'])->post($url.'/submit', ['version' => 2, ...$this->execution])->assertForbidden();
|
||||
$event = AuditLog::query()->where('action', 'supplier_improvement.plan')->sole();
|
||||
expect($event->before['due_on'])->toBe('2026-01-31')->and($event->before['responsible_id'])->toBe($this->f['issuer']->id)->and($event->after['change_reason'])->toBe($plan['reason']);
|
||||
expect($m->refresh()->contributors)->toContain($this->f['issuer']->id, $this->planner->id, $this->closer->id);
|
||||
$this->actingAs($this->closer)->post($url.'/submit', ['version' => 2, ...$this->execution])->assertRedirect();
|
||||
$this->post($url.'/close', ['version' => 3, 'reason' => 'Novo responsável também não encerra sozinho'])->assertForbidden();
|
||||
});
|
||||
|
||||
test('closed measures cannot be changed or deleted even by raw SQL', function () {
|
||||
$m = improvementInReview($this);
|
||||
$this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Conferência independente do exemplo QA.'])->assertRedirect();
|
||||
$this->post($this->url.'/'.$m->id.'/return', ['version' => 3, 'reason' => 'Tentativa posterior ao encerramento'])->assertConflict();
|
||||
expect(fn () => DB::table('supplier_improvements')->where('id', $m->id)->update(['status' => 'open']))->toThrow(QueryException::class);
|
||||
expect(fn () => DB::table('supplier_improvements')->where('id', $m->id)->delete())->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('company department current role and evaluation relationship are enforced', function () {
|
||||
$m = improvementDraft($this);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->post($this->url, $this->data)->assertNotFound();
|
||||
$this->get('/fornecedores/avaliacoes/'.$this->evaluation->id)->assertNotFound();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['issuer'])->post($this->url, $this->data)->assertNotFound();
|
||||
$this->actingAs($this->planner)->postJson($this->url, [...$this->data, 'operation_key' => (string) Str::uuid(), 'responsible_id' => $other['issuer']->id])->assertUnprocessable();
|
||||
$this->post($this->url.'/999999/plan', ['version' => 1, ...array_diff_key($this->data, ['operation_key' => true]), 'reason' => 'Tentativa sobre uma medida inexistente'])->assertNotFound();
|
||||
DB::table('users')->where('id', $this->planner->id)->update(['purchasing_role' => 'requester']);
|
||||
$this->post($this->url, $this->data)->assertForbidden();
|
||||
expect($m->refresh()->version)->toBe(1);
|
||||
});
|
||||
|
||||
test('process conflicts are rechecked for the acting user and the assignee', function () {
|
||||
$m = improvementInReview($this);
|
||||
ConflictDeclaration::query()->create(['tenant_id' => $this->f['tenant']->id, 'user_id' => $this->closer->id, 'requisition_id' => $this->f['r']->id, 'has_conflict' => true, 'known_since' => now()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício após o pedido de encerramento']);
|
||||
$this->actingAs($this->closer)->post($this->url.'/'.$m->id.'/close', ['version' => 2, 'reason' => 'Tentativa com conflito de processo'])->assertForbidden();
|
||||
$this->actingAs($this->planner)->postJson($this->url, [...$this->data, 'operation_key' => (string) Str::uuid(), 'responsible_id' => $this->closer->id])->assertUnprocessable()->assertJsonValidationErrors('responsible_id');
|
||||
});
|
||||
|
||||
test('unapproved evaluations and forged metadata cannot receive measures', function () {
|
||||
$draft = $this->evaluation->replicate();
|
||||
$draft->operation_key = (string) Str::uuid();
|
||||
$draft->status = 'draft';
|
||||
$draft->save();
|
||||
$this->post('/fornecedores/avaliacoes/'.$draft->id.'/medidas', $this->data)->assertConflict();
|
||||
foreach (['status' => 'closed', 'tenant_id' => 999, 'created_by' => 999, 'reviewed_by' => 999] as $field => $value) {
|
||||
$this->postJson($this->url, [...$this->data, $field => $value])->assertUnprocessable();
|
||||
}
|
||||
expect(SupplierImprovement::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('audit failure rolls a measure and its progress back without changing the evaluation', function () {
|
||||
$m = improvementDraft($this);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
||||
expect(fn () => app(SupplierImprovementWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $m->id, 'progress', ['version' => 1, ...$this->execution]))->toThrow(RuntimeException::class);
|
||||
expect($m->refresh()->version)->toBe(1)->and($m->result)->toBeNull()->and($this->evaluation->refresh()->status)->toBe('approved');
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\Department;
|
||||
use App\Models\SupplierEvaluation;
|
||||
use App\Models\SupplierResponse;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierResponseWorkflow;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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->evaluation = SupplierEvaluation::query()->create(['tenant_id' => $this->f['tenant']->id, 'supplier_id' => $this->f['order']->supplier_id, 'department_id' => $this->f['r']->department_id, 'created_by' => $this->f['issuer']->id,
|
||||
'operation_key' => (string) Str::uuid(), 'creation_hash' => str_repeat('d', 64), 'status' => 'approved', 'contributors' => [$this->f['issuer']->id],
|
||||
'supplier_snapshot' => ['id' => $this->f['order']->supplier_id, 'name' => 'Fornecedor QA', 'nuit' => '123456789'],
|
||||
'payload' => ['title' => 'Avaliação aprovada QA', 'nature' => 'goods', 'scope' => 'Âmbito fictício', 'order_ids' => [$this->f['order']->id], 'criteria' => [], 'period_start' => null, 'period_end' => null, 'conclusion' => 'Conclusão QA', 'critical_occurrences' => '', 'critical_evidence' => '']]);
|
||||
$this->base = '/fornecedores/avaliacoes/'.$this->evaluation->id;
|
||||
$this->url = $this->base.'/contraditorios';
|
||||
$this->payload = ['title' => 'Argumentos sobre a recepção QA', 'received_on' => '2026-01-15', 'source_reference' => 'Documento fictício QA-CTR-1', 'statement' => 'O fornecedor apresenta argumentos fictícios e uma referência de evidência.'];
|
||||
$this->data = ['operation_key' => (string) Str::uuid(), 'payload' => $this->payload];
|
||||
$this->decision = ['outcome' => 'accepted', 'reason' => 'Argumentos e referências do exemplo conferidos por pessoa independente.', 'follow_up' => 'Preparar uma nova revisão para conferir o âmbito afectado.', 'response_reference' => 'Minuta fictícia QA-RESPOSTA-1'];
|
||||
});
|
||||
|
||||
function responseDraft(object $test): SupplierResponse
|
||||
{
|
||||
$test->post($test->url, $test->data)->assertRedirect();
|
||||
|
||||
return SupplierResponse::query()->sole();
|
||||
}
|
||||
|
||||
function responseInReview(object $test): SupplierResponse
|
||||
{
|
||||
$record = responseDraft($test);
|
||||
$test->post($test->url.'/'.$record->id.'/submit', ['version' => 1])->assertRedirect();
|
||||
|
||||
return $record->refresh();
|
||||
}
|
||||
|
||||
test('contradictory statement is stored recovered and shown in the dated evaluation report', function () {
|
||||
$before = $this->evaluation->refresh()->getAttributes();
|
||||
$record = responseDraft($this);
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->where('responses.ready', true)->where('responses.canCreate', true)->where('responses.records.0.payload.statement', $this->payload['statement'])->where('responses.records.0.canEdit', true)->has('responses.history.data', 1));
|
||||
$this->get($this->base.'/relatorio')->assertInertia(fn (Assert $p) => $p->where('responses.canCreate', false)->where('responses.records.0.canEdit', false)->where('responses.records.0.canReview', false)->where('responses.records.0.id', $record->id));
|
||||
expect($this->evaluation->refresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
|
||||
test('creation is canonical and idempotent while conflicting reuse is refused', function () {
|
||||
responseDraft($this);
|
||||
$this->post($this->url, [...$this->data, 'payload' => array_reverse($this->payload, true)])->assertRedirect();
|
||||
$this->postJson($this->url, [...$this->data, 'payload' => [...$this->payload, 'title' => 'Conteúdo diferente']])->assertConflict();
|
||||
expect(SupplierResponse::query()->count())->toBe(1)->and(AuditLog::query()->where('action', 'supplier_response.create')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('incomplete and future receipt remain drafts and cannot be submitted', function () {
|
||||
$this->data['payload'] = ['title' => 'Rascunho incompleto QA'];
|
||||
$record = responseDraft($this);
|
||||
$this->postJson($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertUnprocessable()->assertJsonValidationErrors(['payload.received_on', 'payload.source_reference', 'payload.statement']);
|
||||
$future = [...$this->payload, 'received_on' => today()->addDay()->toDateString()];
|
||||
$this->post($this->url.'/'.$record->id.'/save', ['version' => 1, 'payload' => $future])->assertRedirect();
|
||||
$this->postJson($this->url.'/'.$record->id.'/submit', ['version' => 2])->assertUnprocessable()->assertJsonValidationErrors('payload.received_on');
|
||||
expect($record->refresh()->status)->toBe('draft');
|
||||
});
|
||||
|
||||
test('all editors and submitters are excluded from deciding the response', function () {
|
||||
$record = responseDraft($this);
|
||||
$url = $this->url.'/'.$record->id;
|
||||
$this->actingAs($this->reviewer)->post($url.'/save', ['version' => 1, 'payload' => [...$this->payload, 'statement' => 'Argumentos corrigidos por outra pessoa.']])->assertRedirect();
|
||||
$this->actingAs($this->f['issuer'])->post($url.'/submit', ['version' => 2])->assertRedirect();
|
||||
$this->post($url.'/decide', ['version' => 3, ...$this->decision])->assertForbidden();
|
||||
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 3, ...$this->decision])->assertForbidden();
|
||||
expect($record->refresh()->status)->toBe('review');
|
||||
});
|
||||
|
||||
test('return correction and independent decision preserve the original arguments and separate conclusions', function () {
|
||||
$record = responseInReview($this);
|
||||
$url = $this->url.'/'.$record->id;
|
||||
$this->actingAs($this->reviewer)->post($url.'/return', ['version' => 2, 'reason' => 'Completar a referência do documento recebido.'])->assertRedirect();
|
||||
$corrected = [...$this->payload, 'source_reference' => 'Documento QA-CTR-1, anexo fictício A'];
|
||||
$this->actingAs($this->f['issuer'])->post($url.'/save', ['version' => 3, 'payload' => $corrected])->assertRedirect();
|
||||
$this->post($url.'/submit', ['version' => 4])->assertRedirect();
|
||||
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 5, ...$this->decision])->assertRedirect();
|
||||
expect($record->refresh()->status)->toBe('decided')->and($record->version)->toBe(6)->and($record->decision['follow_up'])->toBe($this->decision['follow_up']);
|
||||
expect(AuditLog::query()->where('action', 'supplier_response.save')->sole()->before['payload']['source_reference'])->toBe($this->payload['source_reference']);
|
||||
expect(AuditLog::query()->where('action', 'supplier_response.return')->sole()->after['review_reason'])->toContain('Completar');
|
||||
});
|
||||
|
||||
test('accepting arguments requires documented follow up and rejection requires a reason', function () {
|
||||
$record = responseInReview($this);
|
||||
$url = $this->url.'/'.$record->id;
|
||||
$this->actingAs($this->reviewer);
|
||||
foreach (['accepted', 'partially_accepted'] as $outcome) {
|
||||
$this->postJson($url.'/decide', ['version' => 2, ...$this->decision, 'outcome' => $outcome, 'follow_up' => ''])->assertUnprocessable()->assertJsonValidationErrors('follow_up');
|
||||
}
|
||||
$this->postJson($url.'/decide', ['version' => 2, 'outcome' => 'not_accepted', 'reason' => ''])->assertUnprocessable()->assertJsonValidationErrors('reason');
|
||||
$this->post($url.'/decide', ['version' => 2, 'outcome' => 'not_accepted', 'reason' => 'Argumentos não sustentados pela evidência recebida.'])->assertRedirect();
|
||||
expect($record->refresh()->decision['outcome'])->toBe('not_accepted');
|
||||
});
|
||||
|
||||
test('stale edits and raw SQL modification of a final conclusion are refused', function () {
|
||||
$record = responseDraft($this);
|
||||
$url = $this->url.'/'.$record->id;
|
||||
$this->post($url.'/save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Assunto corrigido QA']])->assertRedirect();
|
||||
$this->postJson($url.'/save', ['version' => 1, 'payload' => $this->payload])->assertUnprocessable()->assertJsonValidationErrors('version');
|
||||
$this->post($url.'/submit', ['version' => 2])->assertRedirect();
|
||||
$this->actingAs($this->reviewer)->post($url.'/decide', ['version' => 3, ...$this->decision])->assertRedirect();
|
||||
$this->post($url.'/return', ['version' => 4, 'reason' => 'Tentativa sobre uma conclusão final'])->assertConflict();
|
||||
expect(fn () => DB::table('supplier_responses')->where('id', $record->id)->update(['status' => 'draft']))->toThrow(QueryException::class);
|
||||
expect(fn () => DB::table('supplier_responses')->where('id', $record->id)->delete())->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('company department role and exact parent relationship are protected', function () {
|
||||
$record = responseDraft($this);
|
||||
$department = Department::query()->create(['tenant_id' => $this->f['tenant']->id, 'name' => 'Outro departamento']);
|
||||
$head = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||
$this->actingAs($head)->post($this->url, $this->data)->assertNotFound();
|
||||
$this->get($this->base)->assertNotFound();
|
||||
$other = receiptFixture();
|
||||
$this->actingAs($other['issuer'])->post($this->url, $this->data)->assertNotFound();
|
||||
$this->actingAs($this->f['receiver'])->post($this->url, $this->data)->assertForbidden();
|
||||
$this->actingAs($this->f['issuer'])->post($this->url.'/99999/submit', ['version' => 1])->assertNotFound();
|
||||
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'requester']);
|
||||
$this->post($this->url.'/'.$record->id.'/submit', ['version' => 1])->assertForbidden();
|
||||
});
|
||||
|
||||
test('newly declared process conflict prevents an independent reviewer from concluding', function () {
|
||||
$record = responseInReview($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' => today()->toDateString(), 'declared_at' => now(), 'reason' => 'Conflito fictício declarado após submissão']);
|
||||
$this->actingAs($this->reviewer)->post($this->url.'/'.$record->id.'/decide', ['version' => 2, ...$this->decision])->assertForbidden();
|
||||
expect($record->refresh()->status)->toBe('review');
|
||||
});
|
||||
|
||||
test('forged metadata and unapproved evaluations cannot create response records', function () {
|
||||
foreach (['tenant_id' => 999, 'decision' => ['outcome' => 'accepted'], 'status' => 'decided', 'reviewed_by' => 999] as $key => $value) {
|
||||
$this->postJson($this->url, [...$this->data, $key => $value])->assertUnprocessable();
|
||||
}
|
||||
$draft = $this->evaluation->replicate();
|
||||
$draft->operation_key = (string) Str::uuid();
|
||||
$draft->status = 'draft';
|
||||
$draft->save();
|
||||
$this->post('/fornecedores/avaliacoes/'.$draft->id.'/contraditorios', $this->data)->assertConflict();
|
||||
expect(SupplierResponse::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('failed audit rolls the response edit back without changing the approved evaluation', function () {
|
||||
$record = responseDraft($this);
|
||||
$before = $this->evaluation->refresh()->getAttributes();
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->once()->andThrow(new RuntimeException('Injected audit failure'));
|
||||
expect(fn () => app(SupplierResponseWorkflow::class)->execute($this->f['issuer'], $this->evaluation->id, $record->id, 'save', ['version' => 1, 'payload' => [...$this->payload, 'title' => 'Não deve persistir']]))->toThrow(RuntimeException::class);
|
||||
expect($record->refresh()->version)->toBe(1)->and($record->payload['title'])->toBe($this->payload['title'])->and($this->evaluation->refresh()->getAttributes())->toBe($before);
|
||||
});
|
||||
Reference in New Issue
Block a user