f = receiptFixture(); if (DB::getDriverName() === 'pgsql') { // Reports deliberately require their own read snapshot, outside the test wrapper. // RefreshDatabase detects the completed wrapper and remigrates the disposable DB. if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 1) { throw new LogicException('Relatórios PostgreSQL apenas na base descartável e transacção de teste esperadas.'); } DB::commit(); } $this->actingAs($this->f['issuer']); }); afterEach(function () { if (DB::getDriverName() === 'pgsql') { if (! app()->environment('testing') || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()) || DB::transactionLevel() !== 0) { throw new LogicException('Limpeza apenas do schema da base descartável fora de transacções.'); } // Committed fixtures require schema cleanup, including functions migrate:fresh leaves behind. DB::statement('DROP SCHEMA public CASCADE'); DB::statement('CREATE SCHEMA public'); } }); function reportCopy(Requisition $original, string $number, array $attributes = []): Requisition { $copy = $original->replicate(); $copy->forceFill(['number' => $number, ...$attributes])->save(); return $copy; } test('needed attention report filters all pages and CSV by native status and needed date without writes', function () { $this->freezeTime(); foreach (range(1, 27) as $index) { reportCopy($this->f['r'], 'ATTENTION-'.$index, ['status' => $index === 1 ? 'draft' : 'pending', 'needed_by' => today()->subDay()->toDateString()]); } foreach (['approved', 'rejected', 'cancelled'] as $status) { reportCopy($this->f['r'], 'CLOSED-'.$status, ['status' => $status, 'needed_by' => today()->subDay()->toDateString()]); } reportCopy($this->f['r'], 'NEEDED-TODAY', ['status' => 'pending', 'needed_by' => today()->toDateString()]); reportCopy($this->f['r'], 'NEEDED-FUTURE', ['status' => 'draft', 'needed_by' => today()->addDay()->toDateString()]); $before = []; foreach (['requisitions', 'approval_steps', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 27)->has('records.data', 25) ->where('insights.unapproved_past_needed_date', 27)->where('counts.pending', 26)->where('counts.draft', 1) ->where('records.data.0.needed_by', today()->subDay()->toDateString()) ->where('records.next_page_url', fn ($url) => str_contains($url, 'attention=unapproved_needed'))); $this->get('/relatorios?attention=unapproved_needed&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', 2)); $csv = $this->get('/relatorios/compras.csv?attention=unapproved_needed&page=2')->assertOk()->streamedContent(); expect($csv)->toContain('ATTENTION-1')->toContain('ATTENTION-27')->toContain('Data de necessidade')->not->toContain('CLOSED-')->not->toContain('NEEDED-TODAY')->not->toContain('NEEDED-FUTURE'); $this->get('/relatorios?attention=unapproved_needed&status=approved')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); $this->getJson('/relatorios?attention=unknown')->assertUnprocessable(); $this->getJson('/relatorios/compras.csv?attention=unknown')->assertUnprocessable(); foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } $this->travel(1)->days(); $this->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 28)); }); test('needed attention report rechecks stream access and preserves department boundaries', function () { $past = reportCopy($this->f['r'], 'OWN-ATTENTION', ['status' => 'pending', 'needed_by' => today()->subDay()->toDateString()]); $head = User::factory()->create(['tenant_id' => $past->tenant_id, 'department_id' => $past->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $otherDepartment = Department::query()->create(['tenant_id' => $past->tenant_id, 'code' => 'ATT-OTHER', 'name' => 'Outro departamento']); reportCopy($past, 'PRIVATE-OTHER-DEPARTMENT', ['department_id' => $otherDepartment->id]); $this->actingAs($head)->get('/relatorios?attention=unapproved_needed')->assertInertia(fn (Assert $p) => $p->where('records.total', 1)); expect($this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk()->streamedContent())->not->toContain('PRIVATE-OTHER-DEPARTMENT'); $response = $this->get('/relatorios/compras.csv?attention=unapproved_needed')->assertOk(); $head->forceFill(['purchasing_role' => PurchasingRole::Requester])->save(); $bufferLevel = ob_get_level(); try { $response->streamedContent(); test()->fail('Revoked report access must fail before streaming'); } catch (StreamedResponseException $error) { expect($error->getInnerException())->toBeInstanceOf(HttpException::class); expect($error->getInnerException()->getStatusCode())->toBe(403); } finally { while (ob_get_level() > $bufferLevel) { ob_end_clean(); } } }); test('approved attention reports reuse registered quote and visible order facts across every page and CSV', function () { foreach (range(1, 27) as $index) { reportCopy($this->f['r'], 'EMPTY-APPROVED-'.$index); } $withQuote = reportCopy($this->f['r'], 'REGISTERED-NO-ORDER'); $quote = Quotation::query()->where('requisition_id', $this->f['r']->id)->firstOrFail()->replicate(); $quote->requisition_id = $withQuote->id; $quote->reference = 'REGISTERED-EXPIRED'; $quote->valid_until = today()->subDay(); $quote->save(); reportCopy($this->f['r'], 'PENDING-NO-QUOTE', ['status' => 'pending']); $before = []; foreach (['requisitions', 'quotations', 'purchase_orders', 'budget_entries', 'stock_movements', 'audit_logs'] as $table) { $before[$table] = DB::table($table)->orderBy('id')->get()->toArray(); } $this->get('/relatorios')->assertInertia(fn (Assert $p) => $p->where('insights.approved.without_registered_quotes', 27)->where('insights.approved.without_visible_order', 28)); foreach (['approved_without_quotes' => 27, 'approved_without_order' => 28] as $attention => $total) { $base = '/relatorios?attention='.$attention; $this->get($base)->assertInertia(fn (Assert $p) => $p->where('records.total', $total)->has('records.data', 25)->where('counts.approved', $total) ->where('records.next_page_url', fn ($url) => str_contains($url, 'attention='.$attention))); $this->get($base.'&page=2')->assertInertia(fn (Assert $p) => $p->has('records.data', $total - 25)); $csv = $this->get('/relatorios/compras.csv?attention='.$attention.'&page=2')->assertOk()->streamedContent(); expect($csv)->toContain('EMPTY-APPROVED-1')->toContain('EMPTY-APPROVED-27')->not->toContain('PENDING-NO-QUOTE'); if ($attention === 'approved_without_quotes') { expect($csv)->not->toContain('REGISTERED-NO-ORDER'); } else { expect($csv)->toContain('REGISTERED-NO-ORDER'); } $this->get($base.'&status=pending')->assertInertia(fn (Assert $p) => $p->where('records.total', 0)); } foreach ($before as $table => $rows) { expect(DB::table($table)->orderBy('id')->get()->toArray())->toEqual($rows); } }); test('approved attention reports keep department and company scopes while finding registered facts', function () { $own = reportCopy($this->f['r'], 'OWN-EMPTY-APPROVED'); $department = Department::query()->create(['tenant_id' => $own->tenant_id, 'name' => 'Departamento reservado']); reportCopy($own, 'PRIVATE-OTHER-DEPT', ['department_id' => $department->id]); $head = User::factory()->create(['tenant_id' => $own->tenant_id, 'department_id' => $own->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $foreign = receiptFixture(); reportCopy($foreign['r'], 'PRIVATE-OTHER-COMPANY'); $this->actingAs($head); foreach (['approved_without_quotes', 'approved_without_order'] as $attention) { $this->get('/relatorios?attention='.$attention)->assertInertia(fn (Assert $p) => $p->where('records.total', 1)->where('records.data.0.id', $own->id)); $csv = $this->get('/relatorios/compras.csv?attention='.$attention)->assertOk()->streamedContent(); expect($csv)->toContain('OWN-EMPTY-APPROVED')->not->toContain('PRIVATE-OTHER-DEPT')->not->toContain('PRIVATE-OTHER-COMPANY'); } }); 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)->where('insights.processes', 207)->where('insights.approved.without_registered_quotes', 206)->where('insights.approved.without_visible_order', 206)); $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)->where('insights.processes', 1)->has('departments', 1)); $this->get('/relatorios?department_id='.$department->id)->assertInertia(fn (Assert $page) => $page->where('records.total', 0)->where('insights.processes', 0)->where('insights.pending.oldest', [])); $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('procurement reading distinguishes submission age need dates and incomplete evidence without business writes', function () { $this->travelTo(CarbonImmutable::parse('2026-10-30 12:00:00')); $audits = AuditLog::query()->count(); foreach (range(1, 7) as $day) { reportCopy($this->f['r'], 'PENDING-'.$day, ['status' => 'pending', 'submitted_at' => '2026-10-'.(30 - $day).' 12:00:00', 'needed_by' => '2026-10-29']); } reportCopy($this->f['r'], 'UNKNOWN-AGE', ['status' => 'pending', 'submitted_at' => null, 'needed_by' => '2026-10-30']); reportCopy($this->f['r'], 'FUTURE-AGE', ['status' => 'pending', 'submitted_at' => '2026-11-01 12:00:00', 'needed_by' => '2026-10-30']); reportCopy($this->f['r'], 'CANCELLED-PAST', ['status' => 'cancelled', 'needed_by' => '2026-01-01']); $this->get('/relatorios')->assertInertia(fn (Assert $page) => $page ->where('insights.processes', 11)->where('insights.pending.count', 9)->where('insights.pending.unknown_submission_age', 2) ->where('insights.unapproved_past_needed_date', 7)->has('insights.pending.oldest', 5) ->where('insights.pending.oldest.0.number', 'PENDING-7')->where('insights.pending.oldest.0.days_since_submission', 7) ->where('insights.approved.count', 1)->where('insights.approved.without_registered_quotes', 0)->where('insights.approved.without_visible_order', 0)); $this->get('/relatorios?search=PENDING-1')->assertInertia(fn (Assert $page) => $page->where('insights.processes', 1)->where('insights.pending.count', 1)); expect(AuditLog::query()->count())->toBe($audits); }); 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)); });