pesquisa avancada no portal

This commit is contained in:
2026-11-04 12:38:21 +02:00
parent bf2c054943
commit c5756c0282
15 changed files with 639 additions and 7 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Services\FederatedSearch;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
final class GlobalSearchTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
public function test_empty_and_overlong_queries(): void
{
$this->get('/pesquisa')->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/search')->where('results.total', 0));
$this->get('/pesquisa?q='.str_repeat('a', 121))->assertSessionHasErrors('q');
}
public function test_source_failure_recalculates_the_page_without_hiding_the_other_results(): void
{
$sources = [
'Documentos' => fn () => ['count' => 1, 'fetch' => fn () => throw new QueryException('testing', 'select 1', [], new \RuntimeException('Source unavailable'))],
'Páginas' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Como investir', 'url' => '/como-investir']], $offset, $limit)],
];
$result = app(FederatedSearch::class)->page($sources, 1);
$this->assertSame(1, $result['total']);
$this->assertSame(['Documentos'], $result['unavailable']);
$this->assertSame('Como investir', $result['data'][0]['title']);
}
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
{
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
$this->markTestSkipped('A prova com dados exige a base QA explicitamente seleccionada.');
}
$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);
$record = InvestmentPortfolioController::visible()->first();
if (! $record) {
$this->markTestSkipped('No published portfolio records in isolated QA.');
}
$term = mb_substr($record->title, 0, 15);
$this->get('/pesquisa?q='.urlencode($term))->assertOk()->assertInertia(fn (Assert $page) => $page
->component('portal/search')->where('query', $term)
->where('results.total', fn ($total) => $total >= 1));
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Tests\Unit;
use App\Services\FederatedSearch;
use PHPUnit\Framework\TestCase;
final class FederatedSearchTest extends TestCase
{
public function test_global_pagination_spans_sources_without_loading_the_complete_sets(): void
{
$calls = [];
$factory = function (string $prefix, int $count) use (&$calls) {
return function () use (&$calls, $prefix, $count) {
return ['count' => $count, 'fetch' => function ($offset, $limit) use (&$calls, $prefix, $count) {
$calls[] = [$prefix, $offset, $limit];
return array_map(fn ($i) => ['title' => $prefix.$i, 'url' => '/'.$prefix.$i], array_slice(range(1, $count), $offset, $limit));
}];
};
};
$sources = ['Páginas' => $factory('p', 3), 'Oportunidades' => $factory('o', 7), 'Documentos' => $factory('d', 2)];
$service = new FederatedSearch;
$first = $service->page($sources, 1);
$this->assertSame(['p1', 'p2', 'p3', 'o1', 'o2'], array_column($first['data'], 'title'));
$this->assertSame(['Páginas', 'Páginas', 'Páginas', 'Oportunidades', 'Oportunidades'], array_column($first['data'], 'category'));
$this->assertSame(12, $first['total']);
$this->assertSame(3, $first['last_page']);
$calls = [];
$second = $service->page($sources, 2);
$this->assertSame(['o3', 'o4', 'o5', 'o6', 'o7'], array_column($second['data'], 'title'));
$this->assertSame([['p', 0, 5], ['o', 0, 5], ['d', 0, 5], ['o', 5, 5]], $calls);
$third = $service->page($sources, 99);
$this->assertSame(3, $third['current_page']);
$this->assertSame(['d1', 'd2'], array_column($third['data'], 'title'));
}
public function test_more_relevant_titles_appear_first_across_sources(): void
{
$factories = [
'Serviços' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Informação', 'url' => '/info', 'score' => 10]], $offset, $limit)],
'Sectores' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Sectores', 'url' => '/sectores', 'score' => 100]], $offset, $limit)],
];
$result = (new FederatedSearch)->page($factories, 1, 5, fn ($item) => $item['score']);
$this->assertSame(['Sectores', 'Informação'], array_column($result['data'], 'title'));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\GlobalSearchController;
use App\Services\SearchTerms;
use Illuminate\Database\PostgresConnection;
use PHPUnit\Framework\TestCase;
final class SearchTermsTest extends TestCase
{
public function test_matches_all_words_in_any_order_without_case_or_accents(): void
{
$search = new SearchTerms;
$tokens = $search->tokens(' NAMPULA agronegócio Agronegócio ');
$this->assertSame(['nampula', 'agronegocio'], $tokens);
$this->assertTrue($search->matches('Agronegócio em Nampula', $tokens));
$this->assertFalse($search->matches('Agronegócio em Gaza', $tokens));
$this->assertSame([], $search->tokens(' '));
}
public function test_sql_matches_each_word_across_fields_with_bound_literal_patterns(): void
{
$connection = new PostgresConnection(fn () => throw new \LogicException('A database must not be opened.'));
$query = $connection->table('records');
$search = new SearchTerms;
$search->constrain($query, ['title', 'description'], $search->tokens('100% a_b'));
$sql = $query->toSql();
$this->assertStringContainsString('translate(lower(coalesce(title::text', $sql);
$this->assertStringContainsString(' or ', $sql);
$this->assertStringContainsString(' and ', $sql);
$this->assertSame(['%100!%%', '%100!%%', '%a!_b%', '%a!_b%'], $query->getBindings());
$this->assertStringNotContainsString('100%', $sql);
}
public function test_static_services_can_be_found_without_a_database(): void
{
$pages = (new \ReflectionMethod(GlobalSearchController::class, 'pages'))->invoke(new GlobalSearchController);
$search = new SearchTerms;
$matches = array_values(array_filter($pages, fn ($page) => $search->matches(implode(' ', $page), $search->tokens('FISCAL simulador'))));
$this->assertCount(1, $matches);
$this->assertSame('/simulador-fiscal', $matches[0]['url']);
}
public function test_spelling_variants_common_words_and_title_relevance(): void
{
$search = new SearchTerms;
$tokens = $search->tokens('setores de investimento');
$this->assertTrue($search->matches('Sectores de investimento', $tokens));
$this->assertFalse($search->matches('Sector de saúde', $tokens));
$this->assertTrue($search->matches('Projectos âncora', $search->tokens('projeto')));
$this->assertTrue($search->matches('Oportunidades de investimento em energia', $search->tokens('Como investir em energia?')));
$this->assertGreaterThan($search->score('Como investir', 'investir', ['investir']), $search->score('Investir', 'investir', ['investir']));
$this->assertSame(['de'], $search->tokens('de'));
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ const require=createRequire(import.meta.url);
const source=fs.readFileSync(new URL('../../resources/js/pages/portal/fiscal-forms.tsx',import.meta.url),'utf8');
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/layouts/portal-navigation'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/components/portal-menu'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation',()=>{
const html=renderToStaticMarkup(React.createElement(module.exports.default));
for(const label of ['Identificação e período','Nome ou designação social','NUIT','Período — ano e mês','Conferir e guardar','Guardar ficheiro para continuar','Recuperar ficheiro guardado','Confirmar recuperação']) {
+36
View File
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const source = fs.readFileSync(new URL('../../resources/js/pages/portal/search.tsx', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
const module = { exports: {} };
new Function('require', 'module', 'exports', compiled)(name => name === '@inertiajs/react' ? {
Head: () => null,
Link: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children),
} : name === 'lucide-react' ? { Search: () => null } : require(name), module, module.exports);
const Search = module.exports.default;
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
const results = { data: [{ title: 'Energia em Nampula', description: 'Projecto público', url: '/oportunidades/energia', category: 'Oportunidades' }], total: 12, current_page: 1, last_page: 3, per_page: 5, unavailable: [] };
const html = renderToStaticMarkup(React.createElement(Search, { query: 'energia', results }));
assert.match(html, /Oportunidades/);
assert.match(html, /Resultados 1–1 de 12/);
assert.match(html, /href="\/oportunidades\/energia"/);
assert.match(html, /q=energia&amp;page=2/);
assert.match(html, /aria-current="page"/);
assert.doesNotMatch(html, /emerald/);
});
test('makes source failures explicit without hiding other results', () => {
const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: ['Documentos e legislação'] };
const html = renderToStaticMarkup(React.createElement(Search, { query: 'terra', results }));
assert.match(html, /role="alert"/);
assert.match(html, /Não foi possível consultar: Documentos e legislação/);
assert.match(html, /Nenhum resultado nas fontes consultadas/);
});