Prepare supplied financial directory with product coverage and native dossier continuity
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\FundingDossierPreparation;
|
||||
use App\Services\FundingProgrammeReferences;
|
||||
use App\Services\FundingProgrammes;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class FundingDirectoryTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private User $editor;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$identity = DB::selectOne('select current_database() as name,inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
config(['investor-path.enabled' => true, 'suitecrm.enabled' => false]);
|
||||
Http::preventStrayRequests();
|
||||
$this->editor = User::factory()->create();
|
||||
config(['portal.managers' => [$this->editor->email]]);
|
||||
$this->actingAs($this->editor);
|
||||
}
|
||||
|
||||
private function prepared(string $code): string
|
||||
{
|
||||
$references = app(FundingProgrammeReferences::class);
|
||||
$plan = $references->preview($this->editor, $code);
|
||||
|
||||
return $references->prepare($this->editor, $code, $plan['hash'])['id'];
|
||||
}
|
||||
|
||||
private function publish(string $id): array
|
||||
{
|
||||
$service = app(FundingProgrammes::class);
|
||||
$plan = $service->preview($this->editor, $id);
|
||||
$service->publish($this->editor, $id, ['version' => $plan['version'], 'hash' => $plan['hash'], 'confirm' => true, 'reason' => 'SYNTHETIC QA editorial publication; rolled back, not live content.']);
|
||||
|
||||
return $service->published($id);
|
||||
}
|
||||
|
||||
private function province(string $name): string
|
||||
{
|
||||
return DB::table('geo_locations')->where('location_type', 'province')->where('name', $name)->value('id');
|
||||
}
|
||||
|
||||
public function test_provided_content_is_queryable_with_sources_contacts_coverage_and_no_automatic_publication_or_duplicates(): void
|
||||
{
|
||||
$references = app(FundingProgrammeReferences::class);
|
||||
$service = app(FundingProgrammes::class);
|
||||
$before = $service->publicQuery()->count();
|
||||
$codes = ['bci', 'millennium-bim', 'absa', 'standard-bank', 'moza', 'bni', 'gapi', 'fecop', 'semeia', 'fcid-viii', 'fae'];
|
||||
$records = [];
|
||||
foreach ($codes as $code) {
|
||||
$id = $this->prepared($code);
|
||||
$this->assertSame($id, $this->prepared($code));
|
||||
$this->assertNull(DB::table('funding_programmes')->where('id', $id)->value('published_version_id'));
|
||||
$this->assertSame($before + count($records), $service->publicQuery()->count());
|
||||
$records[$code] = $this->publish($id);
|
||||
$this->assertNotEmpty($records[$code]['payload']['source_url']);
|
||||
}
|
||||
$this->assertSame($before + 11, $service->publicQuery()->count());
|
||||
$this->assertCount(2, $records['bni']['payload']['products']);
|
||||
$this->assertContains('Maputo Cidade', array_column($records['bni']['payload']['locations'][0]['territory_path'], 'name'));
|
||||
$this->assertSame('national', $records['bni']['payload']['products'][0]['coverage']);
|
||||
$this->assertCount(6, $records['fcid-viii']['payload']['territory_ids']);
|
||||
$this->assertSame('closed', $records['semeia']['payload']['windows'][0]['state']);
|
||||
$this->assertSame('unknown', $records['fae']['payload']['coverage']);
|
||||
$filters = ['territory' => $this->province('Cabo Delgado'), 'need' => 'investment'];
|
||||
$found = $service->catalogueQuery($filters)->pluck('p.code')->all();
|
||||
$this->assertContains('bci', $found);
|
||||
$this->assertContains('bni', $found);
|
||||
$this->assertNotContains('fcid-viii', $found);
|
||||
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($p) => $p->component('funding/index')->where('filters.need', 'investment'));
|
||||
$this->get('/fundos/'.$records['bni']['id'])->assertOk()->assertInertia(fn ($p) => $p->component('funding/show')->where('record.payload.contacts.1.value', '+258 21 498 581'));
|
||||
file_put_contents(base_path('outputs/funding-directory-20261110/content-qa.json'), json_encode(['synthetic_editorial_publication_rolled_back' => true, 'provided_source_sha256' => '065203aacfae766c62cf9c2c272ebacc61ff23bd4ff9e91535939fede87f7617', 'records' => $records, 'cabo_delgado_investment_results' => $found], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_location_and_need_must_match_the_same_product_and_unknown_is_explicit(): void
|
||||
{
|
||||
$service = app(FundingProgrammes::class);
|
||||
$draft = app(FundingProgrammeReferences::class)->preview($this->editor, 'bci')['draft'];
|
||||
$draft['institution_key'] = 'synthetic-same-product';
|
||||
$draft['title'] = 'SYNTHETIC QA — cross product scope';
|
||||
$draft['products'][0]['coverage'] = 'territorial';
|
||||
$draft['products'][0]['territory_ids'] = [$this->province('Maputo Província')];
|
||||
$draft['products'][0]['needs'] = ['investment'];
|
||||
$other = $draft['products'][0];
|
||||
$other['code'] = 'synthetic-working';
|
||||
$other['needs'] = ['working_capital'];
|
||||
$other['territory_ids'] = [$this->province('Nampula')];
|
||||
$draft['products'][] = $other;
|
||||
$id = $service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA cross product coverage fixture.', 'draft' => $draft]);
|
||||
$this->publish($id);
|
||||
$base = ['territory' => $this->province('Nampula')];
|
||||
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
|
||||
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'working_capital'])->where('p.id', $id)->exists());
|
||||
$draft['products'][0]['coverage'] = 'unknown';
|
||||
$draft['products'][0]['territory_ids'] = [];
|
||||
$draft['products'][0]['availability'] = 'unknown';
|
||||
$service->save($this->editor, $id, ['version' => 1, 'code' => 'synthetic-cross', 'reason' => 'SYNTHETIC QA unknown coverage, not absence.', 'draft' => $draft]);
|
||||
$this->publish($id);
|
||||
$this->assertTrue($service->catalogueQuery([...$base, 'need' => 'investment'])->where('p.id', $id)->exists());
|
||||
$this->assertFalse($service->catalogueQuery([...$base, 'need' => 'investment', 'certainty' => 'documented'])->where('p.id', $id)->exists());
|
||||
$this->expectException(ValidationException::class);
|
||||
$service->save($this->editor, null, ['version' => 0, 'code' => 'synthetic-duplicate', 'reason' => 'SYNTHETIC QA duplicate stable institution guard.', 'draft' => $draft]);
|
||||
}
|
||||
|
||||
public function test_selection_continues_in_existing_dossier_and_recovers_snapshot_without_sending_an_application(): void
|
||||
{
|
||||
$id = $this->prepared('bni');
|
||||
$record = $this->publish($id);
|
||||
$caseId = $this->postJson('/investimentos', ['title' => 'SYNTHETIC QA funding preparation', 'client_key' => (string) Str::uuid(), 'reuse_consent' => true])->assertCreated()->json('id');
|
||||
$context = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
|
||||
$this->get('/fundos?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/index')->where('items.data', fn ($rows) => collect($rows)->contains('id', $id)));
|
||||
$this->get('/fundos/'.$id.'?'.http_build_query(['context' => $caseId, 'territory' => $this->province('Nampula'), 'need' => 'investment']))->assertOk()->assertInertia(fn ($page) => $page->component('funding/show')->where('record.id', $id)->where('dossier.id', $caseId)->where('record.payload.contacts.1.value', '+258 21 498 581'));
|
||||
$body = ['context' => $caseId, 'version' => $context['version'], 'version_id' => $record['version_id'], 'product' => 'bni-oferta', 'need' => 'investment', 'territory' => $this->province('Nampula'), 'operation_key' => (string) Str::uuid(), 'consent' => true];
|
||||
$saved = $this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk()->json();
|
||||
$this->assertCount(1, $saved['selections']);
|
||||
$this->assertSame($record['version_id'], $saved['selections'][0]['version_id']);
|
||||
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertOk();
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'operation_key' => (string) Str::uuid()])->assertOk()->assertJsonCount(1, 'selections');
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'need' => 'equity'])->assertConflict();
|
||||
$this->postJson('/fundos/'.$id.'/dossier', [...$body, 'opportunity' => 'opportunity:not-in-this-dossier', 'operation_key' => (string) Str::uuid()])->assertUnprocessable();
|
||||
$this->assertSame(1, DB::table('desk_events')->where('case_id', $caseId)->where('action', 'funding_preparation_saved')->count());
|
||||
$this->getJson('/investimentos/'.$caseId)->assertOk()->assertJsonCount(1, 'funding_selections');
|
||||
$service = app(FundingProgrammes::class);
|
||||
$service->withdraw($this->editor, $id, $record['version'], 'SYNTHETIC QA withdrawal preserves consulted reference.');
|
||||
$recovered = app(FundingDossierPreparation::class)->context($this->editor, $caseId, null);
|
||||
$this->assertSame($saved['selections'], $recovered['selections']);
|
||||
$this->actingAs(User::factory()->create())->postJson('/fundos/'.$id.'/dossier', $body)->assertForbidden();
|
||||
$this->actingAs($this->editor);
|
||||
DB::table('desk_cases')->where('id', $caseId)->update(['closed_at' => now()]);
|
||||
$this->postJson('/fundos/'.$id.'/dossier', $body)->assertConflict();
|
||||
$this->assertCount(1, app(FundingDossierPreparation::class)->context($this->editor, $caseId, null)['selections']);
|
||||
file_put_contents(base_path('outputs/funding-directory-20261110/dossier-qa.json'), json_encode(['synthetic' => true, 'recovered' => $recovered, 'one_native_event' => true, 'foreign_actor_refused' => true, 'closed_dossier_read_only' => true, 'no_external_submission' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { compileFunction } from 'node:vm';
|
||||
import { createRequire } from 'node:module';
|
||||
import ts from 'typescript';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
const require = createRequire(import.meta.url);
|
||||
function load(file, mocks = {}) {
|
||||
const output = ts.transpileModule(
|
||||
fs.readFileSync(
|
||||
new URL('../../resources/js/' + file, import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
compileFunction(output, ['require', 'module', 'exports'])(
|
||||
(name) =>
|
||||
name in mocks
|
||||
? mocks[name]
|
||||
: name === '@/pages/investment/shared'
|
||||
? { card: 'card', input: 'input', button: 'button' }
|
||||
: require(name),
|
||||
module,
|
||||
module.exports,
|
||||
);
|
||||
return module.exports;
|
||||
}
|
||||
const source = {
|
||||
source_url: 'https://example.invalid/source',
|
||||
source_reference: 'SYNTHETIC QA source',
|
||||
checked_on: '2026-11-10',
|
||||
};
|
||||
const product = {
|
||||
...source,
|
||||
code: 'qa-investment',
|
||||
title: 'SYNTHETIC QA investment',
|
||||
instrument: 'Investment credit',
|
||||
needs: ['investment'],
|
||||
coverage: 'national',
|
||||
coverage_text: 'SYNTHETIC national coverage',
|
||||
territory_ids: [],
|
||||
conditions: '',
|
||||
amounts: '',
|
||||
availability: 'unknown',
|
||||
contact_url: 'https://example.invalid/contact',
|
||||
};
|
||||
const details = {
|
||||
institution_key: 'synthetic',
|
||||
institution_nature: 'bank',
|
||||
contacts: [
|
||||
{
|
||||
...source,
|
||||
kind: 'website',
|
||||
value: 'https://example.invalid/contact',
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
...source,
|
||||
role: 'headquarters',
|
||||
label: 'SYNTHETIC Maputo office',
|
||||
address: 'SYNTHETIC address',
|
||||
territory_id: 'qa-maputo',
|
||||
territory_path: [{ name: 'Maputo' }],
|
||||
},
|
||||
],
|
||||
products: [product],
|
||||
};
|
||||
test('actual institution view keeps physical address separate from product coverage and exposes unknown terms, source and contact', () => {
|
||||
const { InstitutionView } = load('components/funding-institution.tsx');
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(InstitutionView, { value: details }),
|
||||
);
|
||||
assert.match(html, /Sede: SYNTHETIC Maputo office/);
|
||||
assert.match(html, /Nacional, segundo a fonte/);
|
||||
assert.match(html, /Condições: Por confirmar/);
|
||||
assert.match(html, /Disponibilidade: Por confirmar/);
|
||||
assert.match(html, /https:\/\/example.invalid\/contact/);
|
||||
assert.match(html, /SYNTHETIC QA source/);
|
||||
assert.match(html, /não é elegibilidade confirmada/);
|
||||
});
|
||||
test('adding a physical location does not fill product financing coverage', () => {
|
||||
const { InstitutionFields } = load('components/funding-institution.tsx');
|
||||
let patch;
|
||||
const tree = InstitutionFields({
|
||||
value: details,
|
||||
change: (p) => (patch = p),
|
||||
territories: [],
|
||||
natures: { bank: 'Banco' },
|
||||
needs: { investment: 'Investimento' },
|
||||
});
|
||||
const nodes = flatten(tree);
|
||||
nodes
|
||||
.find(
|
||||
(n) => n.type === 'button' && text(n) === 'Acrescentar sede/balcão',
|
||||
)
|
||||
.props.onClick();
|
||||
assert.equal(patch.locations.length, 2);
|
||||
assert.equal(patch.locations[1].territory_id, '');
|
||||
assert.equal('products' in patch, false);
|
||||
assert.equal(details.products[0].coverage, 'national');
|
||||
});
|
||||
const flatten = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.flatMap(flatten)
|
||||
: node && typeof node === 'object'
|
||||
? [node, ...flatten(node.props?.children)]
|
||||
: [];
|
||||
const text = (node) =>
|
||||
Array.isArray(node)
|
||||
? node.map(text).join('')
|
||||
: node && typeof node === 'object'
|
||||
? text(node.props?.children)
|
||||
: String(node ?? '');
|
||||
test('actual dossier continuation freezes a lost response retry and retains dossier/opportunity and selected product', async () => {
|
||||
const states = [],
|
||||
refs = [];
|
||||
let stateIndex = 0,
|
||||
refIndex = 0;
|
||||
const calls = [];
|
||||
const hooks = {
|
||||
...React,
|
||||
useState(initial) {
|
||||
const i = stateIndex++;
|
||||
if (!(i in states)) states[i] = initial;
|
||||
return [states[i], (value) => (states[i] = value)];
|
||||
},
|
||||
useRef(initial) {
|
||||
const i = refIndex++;
|
||||
refs[i] ??= { current: initial };
|
||||
return refs[i];
|
||||
},
|
||||
};
|
||||
class Validation extends Error {}
|
||||
const { FundingDossierChoice } = load(
|
||||
'components/funding-dossier-choice.tsx',
|
||||
{
|
||||
react: hooks,
|
||||
'@inertiajs/react': { Link: 'a', router: {} },
|
||||
'@/lib/operation-key': { createOperationKey: () => 'qa-operation' },
|
||||
'@/lib/profile-json': {
|
||||
ProfileValidationError: Validation,
|
||||
profileJson: async (url, body) => {
|
||||
calls.push({ url, body: structuredClone(body) });
|
||||
if (calls.length === 1)
|
||||
throw new Error('SYNTHETIC lost response');
|
||||
return {
|
||||
href: '/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const props = {
|
||||
record: {
|
||||
id: 'qa-programme',
|
||||
version_id: 'qa-version',
|
||||
payload: { kind: 'institution', products: [product] },
|
||||
},
|
||||
dossier: {
|
||||
id: 'qa-dossier',
|
||||
version: 4,
|
||||
opportunity: 'qa-opportunity',
|
||||
closed: false,
|
||||
href: '/investimentos/qa-dossier',
|
||||
},
|
||||
selection: {
|
||||
need: 'investment',
|
||||
territory: 'qa-location',
|
||||
territory_label: 'SYNTHETIC Nampula',
|
||||
},
|
||||
needs: { investment: 'Investment' },
|
||||
destinations: [],
|
||||
};
|
||||
const render = () => {
|
||||
stateIndex = 0;
|
||||
refIndex = 0;
|
||||
return FundingDossierChoice(props);
|
||||
};
|
||||
let tree = render();
|
||||
const selects = flatten(tree).filter((n) => n.type === 'select');
|
||||
selects[1].props.onChange({ target: { value: product.code } });
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'input' && n.props.type === 'checkbox')
|
||||
.props.onChange({ target: { checked: true } });
|
||||
tree = render();
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'fieldset').props.disabled,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
text(flatten(tree).find((n) => n.type === 'button')),
|
||||
'Repetir a mesma intenção',
|
||||
);
|
||||
flatten(tree)
|
||||
.find((n) => n.type === 'button')
|
||||
.props.onClick();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls[1], calls[0]);
|
||||
assert.equal(calls[0].body.context, 'qa-dossier');
|
||||
assert.equal(calls[0].body.opportunity, 'qa-opportunity');
|
||||
assert.equal(calls[0].body.product, product.code);
|
||||
tree = render();
|
||||
assert.equal(
|
||||
flatten(tree).find((n) => n.type === 'a').props.href,
|
||||
'/investimentos/qa-dossier?opportunity=qa#financiamento',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user