Files
SGF/scripts/payable-evidence-cases.php
T
2026-11-04 09:37:20 +02:00

115 lines
9.5 KiB
PHP

<?php
// Invoked only after the owned isolated database/role/cluster guards in the main harness.
use App\Models\User;
use App\Modules\Purchasing\SupplierPayables;
use App\Shared\Context\CompanyContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Tempo\Finance\Payables\{Payables, Workspace};
$f = json_decode(file_get_contents($s['root'].'/fixture.json'), true, 64, JSON_THROW_ON_ERROR);
$scope = $f['scope'];
config(['supplier_payable_history.synthetic_enabled' => true]);
$u = User::findOrFail($f['actors']['read']);
auth()->setUser($u);
app(CompanyContext::class)->selectFor($u->id, $scope['tenant_id'], $scope['company_id'], $scope['unit_id']);
$service = app(SupplierPayables::class);
$fingerprint = function (): array {
$result = [];
foreach (['pa_imported_sources', 'pa_source_histories', 'pa_sources', 'pa_proposals', 'pa_obligations', 'pa_history_imports', 'pa_imported_payments', 'pa_events', 'pa_access'] as $table) {
$result[$table] = DB::selectOne('SELECT count(*) AS n, md5(COALESCE(string_agg(row_to_json(t)::text, chr(10) ORDER BY t.id), \'\')) AS hash FROM '.$table.' t');
}
return json_decode(json_encode($result, JSON_THROW_ON_ERROR), true, 64, JSON_THROW_ON_ERROR);
};
$before = $fingerprint();
$cases = []; $checks = 0;
$assert = function (bool $value, string $message) use (&$checks): void { $checks++; if (! $value) throw new RuntimeException($message); };
$refused = function (callable $action, int $status) use ($assert): void {
try { $action(); } catch (HttpExceptionInterface $e) { $assert($e->getStatusCode() === $status, 'Unexpected refusal status'); return; }
throw new RuntimeException('Expected refusal absent');
};
$case = function (string $name, callable $test) use (&$cases, &$checks): void {
$start = $checks; DB::beginTransaction();
try { $test(); $cases[] = ['name' => $name, 'assertions' => $checks - $start, 'passed' => true]; }
finally { while (DB::transactionLevel() > 0) DB::rollBack(); }
echo 'Passed focused evidence case: '.$name."\n";
};
$case('original-source-outside-first-page-has-own-evidence', function () use ($service, $u, $f, $scope, $assert): void {
$first = $service->listing($u);
$assert(! in_array($f['source_id'], array_column($first['documents'], 'local_id'), true), 'Original must be outside first page');
$data = $service->listing($u, ['source' => $f['source_id']]);
$assert(count($data['documents']) === 1 && $data['documents'][0]['local_id'] === $f['source_id'], 'Exactly requested local source');
$assert($data['pagination']['documents']['total'] === 1 && $data['listing_query']['source'] === $f['source_id'], 'Focused count and query');
$assert(count($data['obligations']) === 1 && $data['obligations'][0]['balance_minor'] === 3000, 'Original balance retained');
$source = DB::table('pa_sources')->where($scope)->where('local_source_id', $f['source_id'])->value('id');
$assert($data['proposals']->every(fn ($p) => $p->source_id === $source) && $data['events']->every(fn ($e) => $e->source_id === $source), 'Only selected source evidence');
$assert($data['pagination']['events']['total'] === DB::table('pa_events')->where($scope)->where('source_id', $source)->count(), 'All own events counted');
});
$case('focus-survives-independent-evidence-pagination', function () use ($service, $u, $f, $scope, $assert): void {
$source = DB::table('pa_sources')->where($scope)->where('local_source_id', $f['source_id'])->value('id');
$proposal = (array) DB::table('pa_proposals')->where($scope)->where('source_id', $source)->orderByDesc('id')->first(); unset($proposal['id']);
for ($n = 1; $n <= 55; $n++) {
DB::table('pa_proposals')->insert(array_replace($proposal, ['capture_version' => $proposal['capture_version'] + $n]));
DB::table('pa_events')->insert($scope + ['source_id' => $source, 'actor_id' => $u->id, 'kind' => 'qa.evidence_only', 'payload' => '{}', 'created_at' => now()]);
}
$a = $service->listing($u, ['source' => $f['source_id']]);
$b = $service->listing($u, ['source' => $f['source_id'], 'proposal_page' => 2, 'event_page' => 2]);
$assert($a['documents'] === $b['documents'] && $a['obligations'] === $b['obligations'], 'Evidence pagination leaves document and balance fixed');
$assert(! array_intersect($a['proposals']->pluck('id')->all(), $b['proposals']->pluck('id')->all()), 'Disjoint proposal pages');
$assert(! array_intersect($a['events']->pluck('id')->all(), $b['events']->pluck('id')->all()), 'Disjoint event pages');
$html = Workspace::render($a, ['host' => 'QA', 'actor' => 'QA', 'organisation' => 'QA', 'api' => '/api/supplier-payables', 'csrf' => '', 'document_filter' => 'source']);
$assert(str_contains($html, 'name="source" value="'.$f['source_id'].'"'), 'Filter form retains exact source');
$links = []; preg_match_all('/href="([^"]+)"/', $html, $links);
$proposalFocus = false; $eventFocus = false;
foreach ($links[1] as $link) {
parse_str(parse_url(html_entity_decode($link), PHP_URL_QUERY) ?? '', $query);
if (($query['source'] ?? null) !== (string) $f['source_id']) continue;
$proposalFocus = $proposalFocus || ($query['proposal_page'] ?? null) === '2';
$eventFocus = $eventFocus || ($query['event_page'] ?? null) === '2';
}
$assert($proposalFocus && $eventFocus, 'Both evidence pagers retain exact source');
$assert(str_contains($html, 'Conferir evidências desta origem') && str_contains($html, 'Consultar todas as origens'), 'Entry and exit links');
$native = $a; unset($native['listing_query']['source']); $native['listing_query']['invoice'] = 77;
$html = Workspace::render($native, ['host' => 'QA Native', 'actor' => 'QA', 'organisation' => 'QA', 'api' => '/api/supplier-payables', 'csrf' => '', 'page_url' => '/obrigacoes-fornecedores']);
$assert(str_contains($html, 'name="invoice" value="77"') && ! str_contains($html, 'Conferir evidências desta origem'), 'Native invoice focus preserved without SGF source links');
});
$case('combined-filters-remain-intersections', function () use ($service, $u, $f, $assert): void {
foreach ([['q' => 'NO-SUCH-FOCUSED-NUMBER'], ['recognition' => 'unrecognised'], ['page' => 2]] as $filter) {
$data = $service->listing($u, ['source' => $f['source_id']] + $filter);
$assert(! $data['documents'] && ! $data['obligations'] && $data['events']->isEmpty() && $data['proposals']->isEmpty(), 'Filtered-out focus must not expose other evidence');
$assert($data['listing_query']['source'] === $f['source_id'], 'Empty focus retained');
}
});
$case('foreign-source-identities-and-invalid-input-refused', function () use ($service, $u, $f, $scope, $refused, $assert): void {
$original = (array) DB::table('pa_imported_sources')->where('id', $f['source_id'])->first(); unset($original['id']);
foreach (['tenant_id', 'company_id', 'unit_id'] as $field) {
$foreign = DB::table('pa_imported_sources')->insertGetId(array_replace($original, [$field => $scope[$field] + 100000]));
$refused(fn () => $service->listing($u, ['source' => $foreign]), 403);
}
$refused(fn () => $service->listing($u, ['source' => PHP_INT_MAX]), 403);
foreach ([0, -1, [], 'not-an-id', true, null] as $bad) $refused(fn () => $service->listing($u, ['source' => $bad]), 422);
DB::table('pa_access')->where($scope)->where(['user_id' => $u->id, 'action' => 'view'])->delete();
$refused(fn () => $service->listing($u, ['source' => $f['source_id']]), 403);
});
$case('current-source-integrity-and-command-revalidation', function () use ($service, $u, $scope, $assert, $refused): void {
$id = $service->listing($u)['documents'][0]['local_id'];
$data = $service->listing($u, ['source' => $id]);
$row = DB::table('pa_imported_sources')->where($scope)->where('id', $id)->first();
$doc = json_decode($row->payload, true, 64, JSON_THROW_ON_ERROR); $doc['number'] = 'EVIDENCE-CHANGED-AFTER-READ';
DB::table('pa_imported_sources')->where('id', $id)->update(['payload' => Payables::canonical($doc)]);
$refused(fn () => $service->listing($u, ['source' => $id]), 409);
DB::table('pa_imported_sources')->where('id', $id)->update(['receipt_hash' => hash('sha256', Payables::canonical($doc))]);
$current = $service->listing($u, ['source' => $id]);
$assert($current['obligations'][0]['balance_minor'] === null && $current['obligations'][0]['source_conflict'], 'Changed historical anchor leaves balance indeterminate');
DB::table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => 'prepare', 'person' => 'sgf-history-person-'.$u->id]);
try { $service->engine($u)->command('prepare_history', $id, 'Focused source changed after read.', (string) Str::uuid()); }
catch (DomainException $e) { $assert(str_contains($e->getMessage(), 'Recibo histórico antigo'), 'Command revalidates current history'); return; }
throw new RuntimeException('Command incorrectly reused focused snapshot');
});
$after = $fingerprint();
$assert($before === $after, 'Financial rows changed after rollback-only tests');
$report = ['increment' => 'SGF-PAYABLE-EVIDENCE-FOCUS-SIM-v1', 'passed' => true, 'cases' => $cases, 'assertions' => $checks, 'financial_rows_preserved' => true, 'rollback_only_test_rows' => true, 'before_after_fingerprint' => hash('sha256', Payables::canonical($before)), 'visual_review' => false];
file_put_contents($s['root'].'/evidence-cases.json', json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['cases' => count($cases), 'assertions' => $checks, 'passed' => true])."\n";