Files
compras/tests/Feature/ProposalOpeningTest.php
T

134 lines
7.8 KiB
PHP

<?php
use App\Enums\PurchasingRole;
use App\Models\AuditLog;
use App\Models\BudgetEntry;
use App\Models\Department;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\Supplier;
use App\Models\Tenant;
use App\Models\User;
use App\Services\ProposalOpeningWorkflow;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
require_once __DIR__.'/../Support/ProposalOpeningFixture.php';
beforeEach(function () {
$this->f = proposalOpeningFixture();
$this->url = '/requisicoes/'.$this->f['r']->id.'/abertura';
$this->actingAs($this->f['issuer']);
});
function recordOpening(object $test, array $changes = []): ProposalOpening
{
$test->post($test->url, [...$test->f['data'], ...$changes])->assertSessionHasNoErrors();
return ProposalOpening::query()->latest('revision')->firstOrFail();
}
test('opening is an optional documentary snapshot without approval or financial effects', function () {
$opening = recordOpening($this);
expect($opening->revision)->toBe(1)->and($opening->snapshot['proposals'][0]['total_minor'])->toBe(10000)
->and($opening->snapshot['proposals'][0])->not->toHaveKey('evaluations')->and(BudgetEntry::query()->count())->toBe(0)
->and($this->f['r']->refresh()->status)->toBe('approved')->and(PurchaseAward::query()->count())->toBe(0);
$this->get($this->url)->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->component('purchases/opening')->where('sourceChanged', false)->where('latestId', $opening->id));
$this->get('/requisicoes/'.$this->f['r']->id.'/processo')->assertInertia(fn (Assert $page) => $page->where('documents', fn ($documents) => collect($documents)->contains('kind', 'Abertura')));
});
test('opening corrections append a new version and refuse stale predecessor ids', function () {
$first = recordOpening($this);
$second = recordOpening($this, ['previous_id' => $first->id, 'notes' => 'Nova observação sem apagar o passado']);
expect($second->revision)->toBe(2)->and($first->refresh()->notes)->toBe('Observações fictícias');
$this->post($this->url, [...$this->f['data'], 'previous_id' => $first->id])->assertSessionHasErrors('previous_id');
expect(ProposalOpening::query()->count())->toBe(2);
});
test('opening refuses a proposal basis changed after the form was loaded', function () {
$this->f['quote']->update(['reference' => 'Referência actualizada']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('basis_hash');
expect(ProposalOpening::query()->count())->toBe(0);
});
test('printed acta preserves supplier and price snapshots after source changes', function () {
$opening = recordOpening($this);
$name = $opening->snapshot['proposals'][0]['supplier']['name'];
Supplier::query()->whereKey($this->f['quote']->supplier_id)->update(['name' => 'Nome posterior fictício']);
$this->f['quote']->update(['total_minor' => 20000]);
$this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('sourceChanged', true));
$this->get('/actas/'.$opening->id.'/imprimir')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertSee($name)->assertDontSee('Nome posterior fictício')->assertSee('100,00')->assertSee('não valida assinaturas');
});
test('opening stores local session time as UTC and prints the declared timezone', function () {
$opening = recordOpening($this);
expect($opening->opened_at)->toBe($this->f['service_data']['opened_at'])->and($opening->timezone)->toBe('Africa/Johannesburg');
$this->get('/actas/'.$opening->id.'/imprimir')->assertSee('Africa/Johannesburg');
$this->post($this->url, [...$this->f['data'], 'previous_id' => $opening->id, 'opened_at' => now()->addHour()->timezone('Africa/Johannesburg')->format('Y-m-d\TH:i')])->assertSessionHasErrors('opened_at');
});
test('opening validates deadline confirmation and declared participants', function () {
$this->post($this->url, [...$this->f['data'], 'confirmed' => false])->assertSessionHasErrors('confirmed');
$this->post($this->url, [...$this->f['data'], 'submission_deadline' => now()->addDay()->timezone('Africa/Johannesburg')->format('Y-m-d\TH:i')])->assertSessionHasErrors('submission_deadline');
$this->post($this->url, [...$this->f['data'], 'participants' => []])->assertSessionHasErrors('participants');
$this->post($this->url, [...$this->f['data'], 'snapshot' => ['signed' => true]])->assertSessionHasErrors('snapshot');
});
test('opening cannot rewrite a process after an award or record a draft request', function () {
$this->f['r']->update(['status' => 'draft']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('opening');
$this->f['r']->update(['status' => 'approved']);
PurchaseAward::query()->create($this->f['award_attributes']);
$this->post($this->url, $this->f['data'])->assertSessionHasErrors('opening');
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening is restricted by role department company and authentication including print', function () {
$opening = recordOpening($this);
$print = '/actas/'.$opening->id.'/imprimir';
$this->actingAs($this->f['receiver'])->get($this->url)->assertForbidden();
$this->get($print)->assertForbidden();
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
$this->actingAs($finance)->get($this->url)->assertOk();
$this->get($print)->assertOk();
$this->post($this->url, [...$this->f['data'], 'previous_id' => $opening->id])->assertForbidden();
$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)->get($print)->assertNotFound();
$other = Tenant::query()->create(['name' => 'Outra empresa fictícia']);
$this->actingAs(User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]))->get($print)->assertNotFound();
auth()->logout();
$this->get($print)->assertRedirect('/login');
});
test('opening rejects revoked procurement roles and never accepts foreign proposals', function () {
$original = $this->f;
$foreign = proposalOpeningFixture();
$this->actingAs($original['issuer']);
$this->post($this->url, [...$original['data'], 'quotation_ids' => [$foreign['quote']->id]])->assertSessionHasErrors('quotation_ids');
DB::table('users')->where('id', $original['issuer']->id)->update(['purchasing_role' => 'finance']);
$this->post($this->url, $original['data'])->assertForbidden();
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening audit failure rolls back the entire immutable document', function () {
AuditLog::creating(function (AuditLog $event) {
if ($event->action === 'opening.recorded') {
throw new RuntimeException('Falha fictícia de auditoria');
}
});
try {
expect(fn () => app(ProposalOpeningWorkflow::class)->record($this->f['issuer'], $this->f['r'], $this->f['service_data']))->toThrow(RuntimeException::class);
} finally {
AuditLog::flushEventListeners();
}
expect(ProposalOpening::query()->count())->toBe(0);
});
test('opening documents are immutable and user text is escaped for printing', function () {
$opening = recordOpening($this, ['notes' => '<script>alert("ficticio")</script>']);
$this->get('/actas/'.$opening->id.'/imprimir')->assertDontSee('<script>', false)->assertSee('&lt;script&gt;', false);
expect(fn () => $opening->update(['notes' => 'Alteração proibida']))->toThrow(LogicException::class);
expect(fn () => $opening->delete())->toThrow(LogicException::class);
});