fix(portal): preserve territorial totals and legacy redirect context

This commit is contained in:
2026-11-10 10:41:46 +02:00
parent 2209b83820
commit 377c40a21a
15 changed files with 686 additions and 20 deletions
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Opportunity;
use App\Services\InvestmentGeography;
use App\Services\SectorOrganization;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -20,8 +21,9 @@ class HomeController extends Controller
$locale = $validated['locale'] ?? 'pt';
$data = Cache::remember("portal_home_sector_organization_v1_{$locale}", now()->addMinutes(10), function () {
$data = Cache::remember("portal_home_territorial_v2_{$locale}", now()->addMinutes(10), function () {
$sectors = SectorOrganization::catalog();
$statistics = app(InvestmentGeography::class)->opportunityStatistics();
$featured = Opportunity::with('sector:id,name,slug')
->published()->where('is_featured', true)
@@ -48,9 +50,9 @@ class HomeController extends Controller
'url' => "/oportunidades/{$o->slug}",
])->values()->all(),
'stats' => [
'opportunities' => Opportunity::published()->count(),
'opportunities' => $statistics['opportunities'],
'sectors' => $sectors->count(),
'provinces' => Opportunity::published()->distinct('province')->count('province'),
'provinces' => $statistics['provinces'],
],
];
});
+2
View File
@@ -81,6 +81,8 @@ class AppServiceProvider extends ServiceProvider
Cache::forget('portal_home_v2_en');
Cache::forget('portal_home_sector_organization_v1_pt');
Cache::forget('portal_home_sector_organization_v1_en');
Cache::forget('portal_home_territorial_v2_pt');
Cache::forget('portal_home_territorial_v2_en');
};
$model::saved($invalidate);
$model::deleted($invalidate);
+47
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
@@ -12,6 +13,52 @@ final class InvestmentGeography
{
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
/** Public declared coverage; a territory join never multiplies the opportunity total.
*
* @return array{opportunities: int, provinces: int}
*/
public function opportunityStatistics(): array
{
$opportunities = Opportunity::published()->select(['id', 'primary_territory_id', 'province', 'geography_scope'])->toBase();
$references = $this->publicReferences()->select(['id', 'parent_id', 'name', 'location_type']);
$result = DB::selectOne('WITH RECURSIVE
published_opportunities AS ('.$opportunities->toSql().'),
public_references AS ('.$references->toSql().'),
declared_locations AS (
SELECT id AS opportunity_id, primary_territory_id AS location_id
FROM published_opportunities WHERE primary_territory_id IS NOT NULL
UNION
SELECT l.opportunity_id, l.geo_location_id FROM opportunity_locations l
JOIN published_opportunities o ON o.id=l.opportunity_id
),
territory_paths AS (
SELECT d.opportunity_id, d.location_id, g.id, g.parent_id, g.name, g.location_type,
ARRAY[g.id] AS visited, 1 AS depth
FROM declared_locations d JOIN public_references g ON g.id=d.location_id
UNION ALL
SELECT t.opportunity_id, t.location_id, g.id, g.parent_id, g.name, g.location_type,
t.visited||g.id, t.depth+1
FROM territory_paths t JOIN public_references g ON g.id=t.parent_id
WHERE t.depth<5 AND NOT g.id=ANY(t.visited)
),
covered_provinces AS (
SELECT t.name FROM territory_paths t WHERE t.location_type=\'province\'
AND EXISTS (SELECT 1 FROM territory_paths root
WHERE root.opportunity_id=t.opportunity_id AND root.location_id=t.location_id AND root.parent_id IS NULL)
UNION
SELECT g.name FROM public_references g JOIN published_opportunities o
ON lower(btrim(o.province))=lower(btrim(g.name))
WHERE g.location_type=\'province\' AND o.primary_territory_id IS NULL
AND COALESCE(o.geography_scope, \'unspecified\')<>\'national\'
AND NOT EXISTS (SELECT 1 FROM opportunity_locations l WHERE l.opportunity_id=o.id)
)
SELECT (SELECT count(DISTINCT id) FROM published_opportunities) AS opportunities,
(SELECT count(DISTINCT lower(btrim(name))) FROM covered_provinces) AS provinces',
[...$opportunities->getBindings(), ...$references->getBindings()]);
return ['opportunities' => (int) $result->opportunities, 'provinces' => (int) $result->provinces];
}
/** Resolve an old province-name search only when the public register has exactly one match.
* @param array<array-key, mixed> $filters
* @return array<array-key, mixed>
@@ -1,5 +1,23 @@
# Rastreabilidade, utilização e formação
## 10/11/2026 — preparação conjunta de contador e aliases
[Entrega conjunta](../v2/auditoria-portal/entrega-conjunta-contador-aliases-2026-11-10.md) preparada contra `main`/`origin/main` em `2209b838`, com patch único, reversão e manifesto. Oito casos novos/56 asserções conferem contexto sem base de dados nos dois aliases, GET/HEAD e com/sem barra final. Hashes das fontes e do build privado do contador continuam iguais ao recibo aceite; provas anteriores reutilizadas, não repetidas.
Permanecem separados: **instalação conjunta pendente**, **revisão visual pendente** e **melhorias da janela S08 por concluir**. Contexto preservado no redireccionamento não demonstra a funcionalidade da S08 nem encerra a revisão visual. Sem instalação, publicação ou alteração de dados/configurações.
## 10/11/2026 — contagem única e cobertura multiterritorial preparadas
O total conta cada oportunidade pública uma única vez, mesmo com vários territórios. O indicador de províncias conta localizações declaradas distintas: uma oportunidade em duas províncias corresponde a uma no total e duas na cobertura. Localização desconhecida mantém a oportunidade no total sem inventar província. Abrangência nacional e contexto de corredor não se convertem automaticamente em implantação.
[Código, critérios e provas](../v2/auditoria-portal/revisao-externa-2026-11-10.md): nove testes novos/23 asserções passaram; cache versionada, tipos, lint e compilação privada verificados. **O contador e os aliases anteriores estão preparados/testados, sem disponibilização no portal. Revisão visual e melhorias da janela S08 continuam pendentes, com provas aceites preservadas.** Nenhuma prova histórica do simulador/Balcão/aliases/S08 foi repetida ou tomada como conclusão nova. Sem dados de negócio alterados, migrations ou deploy.
## 10/11/2026 — entradas antigas do simulador e dos contactos
Os endereços `/simulador` e `/contactos`, também com barra final, passam a redireccionar permanentemente para `/simulador-fiscal` e `/apoio`. Os parâmetros de consulta são conservados; os formulários e permissões dos destinos existentes mantêm-se. O alias anterior `/Simulador` permanece disponível.
[Relatório da revisão externa](../v2/auditoria-portal/revisao-externa-2026-11-10.md): nove testes dirigidos/37 asserções passaram sem acesso à base ou email real. O teste do router não é uma demonstração visual ou HTTP ponta a ponta. A contagem multiterritorial e a revisão desktop/móvel continuam pendentes; não se alteraram dados nem regras fiscais. Reversão limitada ao ciclo de aliases em `routes/web.php`, sem rollback de schema.
## 02/11/2026 - reconciliacao das pre-condicoes da suite ampla
A classificacao das falhas foi confrontada com os testes e com a configuracao efectiva. Os quatro 403 do intake de negocios pertenciam ao percurso de listas legadas, mas herdavam a activacao de perfis do ambiente. Com variante legada explicita, passaram nove testes/248 assercoes do intake e das operacoes documentais reais; GD/WebP foi activado apenas pelo argumento PHP -d extension=gd, sem alterar o runtime partilhado. O proprio ExistingBusinessIntakeTest passou a declarar essa variante no setUp, conservando todas as recusas de isolamento, MFA, designacao, auto-aprovacao e estados. Verificacao sem flags externas de perfis: sete testes/154 assercoes passaram. Nenhuma politica do produto foi alargada.
@@ -1,3 +1,21 @@
## Entrega conjunta preparada em 10/11/2026
[Contador e aliases na mesma entrega](entrega-conjunta-contador-aliases-2026-11-10.md): base `main` e `origin/main` actualizadas e coincidentes em `2209b838`; pacote de fontes com manifesto, patch conjunto e inverso, conferidos num índice temporário. Oito verificações novas de contexto/rotas, 56 asserções; provas aceites do contador e aliases reutilizadas, sem repetir suites anteriores. Sem instalação, push, dados de negócio, configuração ou build servido alterados.
Saldo em três pontos independentes: **disponibilização conjunta pendente; revisão visual pendente; janela S08 como trabalho por concluir, sem conclusão comunicada**. A S08 não é encerrada pela compilação, pelos testes do contador, por uma query transportada ou pela futura revisão visual. Preservar as provas já aceites e os estados/critérios da matriz.
## Continuação de 10/11/2026 — contador preparado; disponibilização e saldo preservados
[Delta multiterritorial e provas](revisao-externa-2026-11-10.md): agregação SQL de IDs únicos de oportunidades públicas e províncias declaradas distintas, sem multiplicar o total pelas localizações. Uma oportunidade em duas províncias conta uma vez no total. Nova chave de cache, invalidadores existentes conservados e definição visível preparados. Nove testes novos/23 asserções passaram, além de PHPStan, Pint, tipos, lint e build em saída privada. Diagnóstico READ ONLY: seis oportunidades/cinco províncias; sem dados de negócio alterados. Cluster QA existente iniciado apenas para tabelas temporárias/rollback e reposto no estado parado; nenhuma base ou migration criada.
**Sem disponibilização no portal**, tanto do contador como dos aliases `/simulador/` e `/contactos/` já corrigidos/testados. **A revisão visual e as melhorias da janela S08 continuam no saldo, sem conclusão comunicada.** Preservar provas aceites e não confundir janela S08 com uma etapa homónima do workflow. A agregação preparada não encerra o P0 inteiro, a S08 ou requisitos territoriais transversais.
## Revisão externa inicial de 10/11/2026
[Reconciliação e primeiro delta P0](revisao-externa-2026-11-10.md): destino confirmado `portal-investidor`; inventário de 3.374 ficheiros e consulta da base configurada apenas em transacção READ ONLY. Aliases `/simulador` e `/contactos` acrescentados para os destinos existentes, com contexto conservado. Nove testes/37 asserções passaram sem base ou envio de email real. Seis IDs da matriz existente receberam somente fontes/evidências, com 3.555 linhas e todos os estados/critérios anteriores preservados.
Saldo P0: agregação multiterritorial e definição visível do contador, conferência de contactos de apoio/identidade já decidida e reprodução visual do cabeçalho em desktop/móvel. Navegador indisponível e HEAD HTTPS impedido pela verificação de revogação Schannel; router directo não substitui essas provas. Nenhuma migration, seed, concessão, parâmetro fiscal, publicação ou deploy. Não recriar simulador, Balcão, biblioteca, contas ou protocolos. Fases estruturais/fiscais seguem a reconciliação específica; nenhuma conclusão global presumida.
<!-- saldo-atual-20261018 -->
O [saldo atualizado em 18/10/2026](visao-consolidada-modulos.md) reconcilia os recibos mais recentes, preserva os fechos aceites e distingue o estado histórico do próximo recorte verificável. As previsões antigas abaixo não devem ser somadas como horas restantes.
<!-- /saldo-atual-20261018 -->
@@ -0,0 +1,50 @@
# Entrega conjunta do contador e dos aliases
## Resultado
Pacote de fontes preparado em 10/11/2026 para reunir o contador multiterritorial e os aliases `/simulador/` e `/contactos/`. Sem instalação, publicação, push ou alteração de dados/configurações. O pacote contém patch conjunto, patch inverso, fontes seleccionadas, manifesto de hashes, recibos e procedimento de aplicação/reversão futura.
Directório local: `outputs/entrega-conjunta-contador-aliases-20261110/`; arquivo com o mesmo nome e extensão `.zip`. O manifesto e `compatibilidade.json` identificam exactamente a base, a árvore candidata e os resultados da aplicação/reversão. É uma entrega de fontes; o build QA anterior é evidência e não um build de produção a instalar.
## Compatibilidade com main
A referência remota foi actualizada por `git fetch --no-tags origin main`. `main`, `origin/main` e `FETCH_HEAD` coincidem em `2209b8382019b0bbc2be57b3f6e665a3c94dd032`. A preparação não altera branches nem cria um commit em `main`.
O patch é verificado num índice Git temporário iniciado nessa base: `git apply --cached --check`, aplicação ao índice e comparação da árvore resultante com a candidata. O patch inverso é conferido e aplicado no mesmo índice; a árvore final deve ser exactamente a árvore da base. O índice de trabalho original é preservado por hash. Nenhuma destas operações instala os ficheiros na árvore de trabalho ou no portal.
Esta prova cobre a base exacta identificada. Se `main` avançar, é necessário voltar a conferir o delta afectado; não se declara compatibilidade com commits futuros. A lista de ficheiros é fechada e não inclui `.env`, credenciais, dumps, dependências, builds servidos ou alterações do SGF Tempo.
## Ajustes incluídos
- Total de oportunidades por ID público único; várias localizações não multiplicam a oportunidade. Províncias declaradas distintas a partir da referência principal e das adicionais, pelas regras públicas existentes.
- Chave de cache versionada, invalidação nos dois idiomas e definição do indicador junto da contagem. Sem limpeza da cache activa ou alteração da validade de dez minutos.
- Aliases minúsculos, com e sem barra final, para as rotas existentes `/simulador-fiscal` e `/apoio`, por 301. O alias histórico `/Simulador` e os destinos oficiais são preservados.
- Testes e registos necessários à entrega conjunta, incluindo a matriz e o manual existentes. Sem migrations, novas tabelas de negócio, direitos ou parâmetros fiscais.
## Contexto nos redireccionamentos
Oito casos novos passaram, com 56 asserções: GET e HEAD nos dois aliases, com e sem barra final. Os testes conferem 301, esquema e host, caminho canónico, nome da rota existente e equivalência dos parâmetros após codificação/descodificação.
O contexto inclui dossier, oportunidade, idioma, origem/território aninhados, retorno com query própria e texto com acentos e caracteres reservados. Um parâmetro `redirect` com URL externa permanece dado de consulta; não substitui o destino. O valor de etapa transportado dentro de um retorno é opaco: conservá-lo não demonstra a funcionalidade dessa etapa ou da janela S08.
Os testes executam a colecção de rotas e a resposta, com acesso à base proibido. Não são prova de navegador, middleware completo, nova autenticação, retomada de dossier ou apresentação visual do destino. Fragmentos do browser não são parâmetros enviados ao servidor e não foram ensaiados.
## Provas anteriores reutilizadas
O recibo `home-territorial-delivery.json` continua a corresponder, por SHA-256, aos sete ficheiros de fontes/testes do contador e ao manifesto do build privado anterior. Reutilizam-se os nove testes/23 asserções e as verificações de tipos, lint, PHPStan, Pint e compilação nesse âmbito. Não se repetiram a suite SQL, os testes aceites dos aliases, os ciclos do simulador/Balcão ou provas da S08. O teste novo passou Pint; `git diff --check` não detectou erros de whitespace no delta.
As provas históricas e os limites da leitura READ ONLY seis oportunidades/cinco províncias estão no [relatório anterior](revisao-externa-2026-11-10.md). Não há novo ensaio de dados de negócio, arranque de QA ou consulta à base nesta preparação.
## Saldo preservado
1. **Instalação/disponibilização conjunta:** pendente. O pacote preparado não torna contador ou aliases disponíveis no portal.
2. **Revisão visual:** pendente, com âmbito próprio de desktop/móvel e percursos afectados. Aplicação limpa do patch, testes de router e build não a encerram.
3. **Janela S08:** trabalho por concluir, sem conclusão comunicada. É uma pendência separada da revisão visual e não foi resolvida pelos testes do contador ou pelo transporte de contexto. As provas já aceites mantêm-se; não se atribui conteúdo à janela a partir de uma etapa homónima.
## Procedimento de entrega e reversão
Antes de uma futura aplicação, conferir a base e os hashes do manifesto, preservar alterações locais e efectuar a verificação do patch no destino correcto. A instalação exige o procedimento já aplicável ao portal e a autorização correspondente; não executar estes passos sobre dados activos por inferência desta preparação.
O pacote fornece um delta único para evitar instalar o contador sem os invalidadores/definição, ou distribuir os aliases sem o seu contexto e testes. O frontend exige o build do modo e ambiente de destino: não copiar o build QA com identificação de QA para produção. Não há migration ou comando SQL de instalação.
A reversão é delimitada aos ficheiros desse delta sobre a árvore candidata exacta. Conferir o patch inverso antes de o usar; não aplicar sobre edições posteriores nem repor o repositório inteiro. Nenhum rollback de base, limpeza de cache global, eliminação de dados, reset destrutivo ou reinstalação de dependências faz parte desta entrega.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
# Revisão das melhorias externas do InvesteMoz
## Continuação de 10/11/2026 — contador multiterritorial
**Estado: código preparado e testado; sem disponibilização no portal.** Os aliases `/simulador/` e `/contactos/` conservam a mesma situação de entrega anterior. A revisão visual e as melhorias da **janela S08**, cuja conclusão não foi comunicada, mantêm-se no saldo. Esta referência à janela S08 não lhe atribui por inferência o conteúdo da etapa de licenciamento com o mesmo identificador. Provas e aceitações anteriores permanecem preservadas.
### Comportamento corrigido
O total usa `COUNT(DISTINCT id)` sobre a selecção pública original de `Opportunity::published()`. Uma oportunidade com dois distritos na mesma província e outro território numa segunda província acrescenta **uma oportunidade e duas províncias**. Oportunidades sem localização continuam no total. A publicação, visibilidade, data, eliminação lógica e exclusão de exemplos são revalidadas pelo scope existente.
As províncias são resolvidas a partir da referência principal e de `opportunity_locations`, seguindo a hierarquia pública existente. Ligações repetidas e nomes provinciais equivalentes após normalização não aumentam a contagem; Maputo Cidade e Maputo Província permanecem distintos. Antepassados privados, removidos, indisponíveis, ciclos ou cadeias incompletas não geram cobertura. O nome provincial legado só serve como alternativa quando não existem referências canónicas e corresponde a uma província do catálogo público. Desconhecido não se converte num território inventado.
A definição é **províncias com localização declarada nas oportunidades públicas**, apresentada junto aos indicadores. Abrangência nacional, impacto e contexto de corredor não são automaticamente convertidos em implantação. É uma definição de apresentação deste indicador, não uma alteração de localizações, relações, filtros ou decisões organizacionais.
A agregação usa uma única instrução SQL com CTEs e mantém separados o universo de oportunidades e a cobertura territorial. Não há carregamento do universo em PHP nem consultas por oportunidade. A inspecção de schema do scope existente é preservada: observaram-se duas consultas para esta projecção, sem incluir o resto da homepage, middleware ou bootstrap. Isto não é prova de carga sustentada.
A cache da homepage passa a `portal_home_territorial_v2_{locale}`, conservando o prazo de dez minutos e os invalidadores de gravação/eliminação de sectores e oportunidades. A chave nova evita apresentar a contagem antiga após a futura disponibilização. As chaves históricas não foram apagadas no ambiente activo. A frescura dos restantes consumidores e os eventos de escrita existentes não foram redesenhados.
### Ficheiros deste delta
- `app/Services/InvestmentGeography.php`: agregação territorial reutilizando publicação e referências públicas existentes.
- `app/Http/Controllers/Api/V1/HomeController.php`: consumo das duas contagens e chave de cache versionada.
- `app/Providers/AppServiceProvider.php`: invalidação das duas variantes linguísticas da nova cache.
- `resources/js/components/portal-hero.tsx`: definição legível do indicador e da contagem única.
- `tests/Feature/HomeTerritorialStatisticsTest.php` e `tests/Unit/HomeTerritorialCacheTest.php`: regressões dirigidas novas.
- `tests/Feature/PublicPortalNavigationTest.php`: somente referências à chave de cache actualizadas; o ciclo histórico completo não foi repetido.
- Matriz, backlog e manual existentes: referências e saldo actualizados, sem substituir critérios ou estados aceites.
### Provas e preservação
Passaram **nove testes novos, com 23 asserções**: sete casos SQL com tabelas temporárias e dois casos de cache sem base de dados. Cobrem múltiplos territórios, ligações repetidas, publicação e visibilidade restrita, ausência de localização, âmbito nacional, contexto sem implantação, fallback legado, ancestrais privados/removidos/incompletos/cíclicos, Maputo Cidade/Província, catálogo vazio, 120 oportunidades, versão de cache e invalidação nos dois idiomas. O caso com 120 oportunidades mantém o mesmo número de consultas que o catálogo vazio.
O cluster QA existente `main-qa-20261104-pgdata:55441` estava parado: a primeira tentativa dos sete testes foi recusada por ausência de ligação, antes de criar fixtures ou efectuar asserções. Foi iniciado o mesmo cluster, sem nova base, porta, role ou migration. Os testes só admitem identidade QA efectiva, criam tabelas temporárias na ligação e terminam com rollback. Depois de confirmar ausência de outros clientes, o cluster foi encerrado normalmente, repondo o estado inicial parado. Não foi usado `RefreshDatabase`, nem reconstruído o schema ou reutilizada uma demonstração como fixture descartável.
Também passaram PHPStan nos dois consumidores PHP alterados, Pint nos ficheiros PHP do delta, TypeScript global e formatação/lint do componente. O build QA concluiu em saída privada `storage/app/private/external-portal-review-20261110/home-territorial-build`, sem trocar o build servido ou executar Wayfinder. O build emitiu avisos sobre fontes opcionais e chunks grandes; não se instalaram dependências nem se declarou resolvida a carga do portal.
Uma consulta de diagnóstico, **sem cache e em transacção READ ONLY**, observou seis oportunidades e cinco províncias no destino `investemoz:5432`: duas consultas, 134,07 ms nesta execução, rollback final e zero comandos de negócio. O recibo privado é `home-territorial-readonly.json`. Não é um ensaio de escrita nem transforma essa observação isolada numa garantia de desempenho.
Não foram repetidos os testes aceites dos aliases, do simulador, do Balcão ou da S08. Não houve migration, concessão, escrita em dados de negócio, operação fiscal, publicação ou deploy. A compilação e os testes não substituem a revisão visual pendente.
### Saldo e reversão deste delta
O contador multiterritorial fica preparado/testado; a disponibilização no portal continua pendente, juntamente com os aliases anteriores. **Revisão visual e melhorias da janela S08 continuam pendentes**, preservando as provas já aceites. Não se declara encerrado o P0 inteiro ou a S08.
Reverter apenas este delta: repor o contador e a chave anterior em `HomeController`, remover `opportunityStatistics()` e o import associado, retirar as duas novas invalidações e a explicação no hero, e repor as referências do teste histórico. Não repor ficheiros completos sobre alterações posteriores, não remover os aliases entregues anteriormente e não executar rollback de migrations. Não há dados a desfazer. A compilação privada não está ligada ao portal.
## Relatório inicial de 10/11/2026
## Resultado e âmbito
Em 10/11/2026, a directiva fornecida pela Tempo foi confrontada com o projecto `C:\laragon\www\portal-investidor`, destino confirmado pelo utilizador nesta conversa. Foram corrigidos dois aliases de navegação ausentes. A leitura do ambiente demonstra que os números do relatório externo não descrevem o estado actualmente configurado. A revisão visual, o contador multiterritorial e as fases funcionais seguintes continuam abertos; esta entrega não declara concluído o programa de melhorias.
O texto colado é o pedido de execução. O DOCX é uma avaliação externa: exemplos de SQL, Node/Express, React, seeds, contactos, benefícios, câmbio e integrações nele contidos não constituem comandos ou decisões aprovadas. Não foi executado código do documento.
## Fontes e preservação
- Directiva de Implementação Segura, versão 1.0, 08/11/2026, recebida como texto colado.
- `correecoes ao portal.docx`, no caminho indicado pelo utilizador. SHA-256 `db630ab937cc00686480aa02ef0156a8ac20edadf97306995277a5173d890f51`; 5.968 elementos `w:p` extraídos. Os números de parágrafo abaixo são posições XML, não páginas verificadas por renderização. A indicação de 141 páginas pertence ao pedido.
- `AGENTS.md` do InvesteMoz, backlog mestre, matriz existente e manual de rastreabilidade. As fontes oficiais já aceites pela Tempo conservam essa aceitação; não foi criada uma obrigação genérica de nova validação de autenticidade.
- Commit inicial `2209b8382019b0bbc2be57b3f6e665a3c94dd032`. O repositório do portal não apresentava alterações Git antes desta tarefa. O SGF Tempo e o seu trabalho não integrado não foram modificados.
O inventário privado contém caminhos, tamanhos e hashes de 3.374 ficheiros em `app`, `routes`, `resources`, `database`, `tests`, `config`, `bootstrap`, `deploy`, `tools` e `docs`. São 412 ficheiros em `app`, 789 em `resources`, 140 em `database`, 675 em `tests`, 648 em `tools` e 609 em `docs`, além dos restantes directórios. A leitura automatizada de declarações e hashes dá cobertura do inventário; não constitui prova funcional de todos esses ficheiros. Dependências instaladas e artefactos de execução não foram substituídos.
As evidências locais estão em `storage/app/private/external-portal-review-20261110/`: `inventory.json`, extração integral do DOCX, `runtime-readonly.json` e cópia do ficheiro de rotas anterior. A pasta é privada/ignorada pelo Git e não é um pacote de publicação.
## Arquitectura e ambientes encontrados
A aplicação existente usa Laravel, PHP, Inertia, React/TypeScript, Fortify e PostgreSQL/PostGIS. `composer.json`, `package.json` e os respectivos locks permanecem intactos. React já integra a arquitectura; a presença de React no documento não exige nem justifica reconstruir a interface. Não foi instalado um backend Node/Express.
As rotas incluem catálogo público, sectores, oportunidades, carteira, clusters/corredores, repositório, simulador, Balcão, perfis, gestão de conteúdos e outros módulos existentes. O inventário identifica 502 declarações literais de rotas; ciclos e composição dinâmica impedem interpretar esse número como total de endpoints efectivos.
O README de execução local descreve `investmoz_local_test:55441`. A configuração realmente carregada nesta sessão aponta para `investemoz:5432`, ambiente Laravel `local`, URL `https://portal-investidor.test` e debug activo. Assim, a base configurada foi tratada como protegida. Estes dados não demonstram a configuração do domínio público em produção.
A consulta foi executada numa transacção PostgreSQL com `SET TRANSACTION READ ONLY`, timeout de 15 segundos e rollback final. Foram observadas 296 tabelas fora dos esquemas de sistema, 88 registos de migrations e 105 ficheiros de migrations: 17 ficheiros não constam do registo consultado. A lista nominal está no inventário privado. Não foi executada qualquer migration, reparação ou instalação automática. Divergência de schema não equivale, por si só, à perda de uma funcionalidade anteriormente aceite.
O diagnóstico `portal:dev-check` passou os dez controlos apresentados: PHP, driver, chave, configuração PostgreSQL, pastas de execução, dependências frontend, ligação, PostGIS e tabelas principais. É um diagnóstico de arranque, não uma prova de produção ou dos percursos completos.
O `phpunit.xml` aponta para QA `investmoz_test:55441`; a classe base de Feature pode reconstruir o schema. Não se executou essa suite sobre a base configurada nem se reutilizou uma demonstração com dados como base descartável. Os testes desta entrega interceptam acesso à base e transporte de email.
## Compatibilidade das recomendações
As classificações seguintes referenciam os requisitos da matriz existente, sem criar outro backlog ou alterar aceitações anteriores.
### Usabilidade e navegação
1. **Adaptável — cabeçalho** (`AUD-LEG-02`, `AUD-LEG-07`; DOCX p. XML 7–14, 352–379 e 524–551). `portal-hero.tsx` contém uma cópia acessível, uma cópia invisível que reserva o espaço final e texto animado marcado `aria-hidden`. A repetição numa extração textual não demonstra repetição visual. Não foram removidas essas protecções nem acrescentado CSS com `!important`. Falta observar o resultado em desktop, móvel e movimento reduzido.
2. **Adaptável — indicador provincial** (`AUD-GEO-03`; p. XML 445–478 e 617–650). `HomeController` conta actualmente valores distintos de `opportunities.province` nas oportunidades públicas. A leitura encontrou seis oportunidades públicas, cinco valores distintos, seis referências territoriais principais e nenhuma localização adicional. Não reproduz o valor externo «1», nem justifica fixar «11». O código não agrega `opportunity_locations`: permanece uma lacuna concreta para oportunidades multiterritoriais. Reutilizar o contrato territorial de `InvestmentCatalogue::locationPresentation`, distinguindo implantação, declaração e contexto de corredor; avaliar o custo das consultas antes de substituir a agregação SQL por leituras por registo. Não foi introduzida uma implementação com consultas por oportunidade apenas para corrigir o número.
3. **Aplicável — aliases antigos** (`AUD-LEG-06`; p. XML 380–409 e 552–581). `/simulador-fiscal` e `/apoio` já são rotas oficiais; `/Simulador` também existe. Os aliases minúsculos `/simulador` e `/contactos`, com ou sem barra final, não eram encontrados pelo router. Foram acrescentados redireccionamentos 301 para os destinos oficiais, conservando os parâmetros de consulta. Não se criou outro simulador ou formulário.
4. **Já implementada em parte — contactos** (`AUD-HUMAN-01`; p. XML 92–109). Há email institucional configurado e o rodapé contém contactos. Os campos específicos de apoio estão vazios; por isso a página de apoio ainda apresenta a indicação de contactos directos não publicados. Não é ausência total de contactos no portal. Não se copiaram os endereços de exemplo do DOCX para a configuração de apoio.
5. **Dependente de validação específica — identidade e contactos de apoio** (`AUD-HUMAN-01`). `portal.operator_name` está vazio. A propriedade da Tempo está decidida em `AGENTS.md`; os contactos dedicados, horários e designação a apresentar devem reutilizar os dados e decisões existentes que lhes correspondam. Os números e morada já no rodapé não foram certificados de novo nem alterados nesta tarefa.
6. **Aplicável, por demonstrar — móvel e teclado** (`AUD-LEG-02`, `AUD-LEG-07`). Há componentes e provas históricas; falta conferir o delta actual em navegador. Não se repetiram suites históricas ou se declarou acessibilidade integral com base nos testes de rotas.
### Oportunidades e publicação
7. **Já implementada em parte — fichas e campos** (`MASTER-9dee08d0-0074`, requisitos de oportunidades existentes; p. XML 174–211, 697–1143). O modelo já tem montante/moeda, promotor, maturidade, localização e última validação; há gestão, recursos, revisão de origem e contratos canónicos. Comparar as variantes de licenciamento, fonte/confiança e referência temporal dentro desses contratos antes de criar campos ou tabelas.
8. **Já implementada — capacidade de destaque** (gestão de oportunidades existente). `is_featured`, filtragem por publicação e apresentação de até seis destaques já existem. A leitura encontrou três oportunidades públicas assinaladas. Não foram promovidos projectos para preencher o ecrã. A alegada secção vazia depende do ambiente e da apresentação efectiva.
9. **Rejeitada — preenchimento comercial inventado** (p. XML 190–211 e modelos SQL seguintes). Não converter montante desconhecido em «A negociar», CAPEX fictício, calendário, licença ou promotor inventados. Nenhum dado foi enriquecido automaticamente.
10. **Adaptável — origem, confiança e rastreabilidade**. Reutilizar revisão de origem, recursos, versões e auditoria existentes. Um novo campo só é necessário depois de localizar uma lacuna no contrato correspondente; não foram duplicadas entidades ou relações.
### Simulador e acervo jurídico
11. **Já implementada em parte — simulador e recuperação** (`AUD-SIM-01`, `AUD-SIM-02`). Existem serviços e testes fiscais/financeiros, rascunhos e consumidores documentais. A avaliação externa também reconhece a integração. Não se declarou ausente nem se executaram novamente todos os ciclos anteriores.
12. **Adaptável — regras versionadas e resultados explicáveis** (`AUD-SIM-02`; p. XML 1183–2533). A intenção é compatível; deve ser aplicada no motor existente, com âmbito, período e citação. As duas versões repetidas da proposta não são duas funcionalidades novas.
13. **Rejeitada como implementação directa — API Node/Express, novas tabelas fiscais e seeds** (p. XML 2547–4667). Os exemplos não foram instalados. Os blocos sucessivos de seed e correcção não constituem uma única especificação aprovada. As taxas, benefícios, elegibilidade e percentagens do documento não foram importados nem tomados como legislação vigente.
14. **Dependente de conferência específica — divergências fiscais** (`AUD-SIM-02`). Confrontar cada parâmetro que se pretenda alterar com o acervo oficial já aceite, identificando diploma/artigo, período e regra afectada. Não é necessário revalidar genericamente a credibilidade das fontes oficiais. Não foi emitida conclusão jurídica ou alterado cálculo fiscal nesta entrega.
15. **Já implementada em parte — biblioteca e fontes** (`AUD-LIB-01`, `AUD-LIB-02`). Há biblioteca, controlo de publicação, classificação, scan, importação e vínculos; publicação documental não activa um cálculo. A existência de código não certifica todos os documentos ou direitos de divulgação.
### Balcão e pré-dossiê
16. **Adaptável — pré-dossiê no mesmo Balcão** (`AUD-DESK-01`, `AUD-DESK-02`; p. XML 4692–5968). Existem dossier, pedidos, editor de serviços, contexto, histórico e regras de titularidade. Reutilizar esses recursos e os rascunhos existentes; o wizard de cinco passos do DOCX não substitui o percurso adaptativo aprovado pela Tempo.
17. **Adaptável, com saldo por demonstrar — pré-preenchimento, guardar/retomar e exportação**. Conferir o consumidor e a versão do serviço concretos. Não foi criado outro cadastro, protocolo, PDF, tabela ou conta. As provas anteriores de dossier/pedido permanecem válidas no seu âmbito.
18. **Rejeitada — equivalência a acto público**. O pré-dossiê não é aprovação, submissão oficial, certificação ou benefício concedido. Não foram acrescentados logótipos, parceiros, SLAs ou integração institucional presumida.
19. **Dependente de confirmação técnica/institucional — integração directa com APIEX**. Nenhuma ligação, credencial, API ou acordo foi inferido do documento.
20. **Rejeitada como taxa actual — fallback cambial fixo** (p. XML 5039–5104). Reutilizar fontes e mecanismos já existentes, com data/natureza da taxa; valores de recurso e resultados demonstrativos não foram activados.
### Segurança, desempenho e divulgação
21. **Já implementada em parte — validação, autorização e controlo de abuso**. Há middleware, perfis/MFA, políticas, validação no servidor, throttling e controlos documentais. Os quatro testes de contacto reutilizados verificam validação, transporte simulado, erro de envio e escape de conteúdo; não certificam a segurança integral do portal.
22. **Adaptável — imagens, SEO, sitemap, redes móveis e acessibilidade**. O cabeçalho já utiliza WebP e imagens móveis. Não se aplicou conversão indiscriminada de imagens nem um schema SEO sugerido sem conferir o seu significado. Core Web Vitals, carga sustentada e navegação visual actual não foram medidos.
23. **Dependente do ambiente — HTTPS e debug**. O ambiente local anuncia HTTPS e debug activo. O HEAD HTTPS não concluiu a negociação TLS porque Schannel não conseguiu verificar a revogação do certificado (`CRYPT_E_NO_REVOCATION_CHECK`). Não se desactivou essa verificação. Isto não demonstra uma falha de certificado do domínio público. HSTS/debug em produção exigem inspecção da configuração efectiva desse destino, sem deploy nesta tarefa.
24. **Rejeitada como instalação automática — reCAPTCHA, CDN ou transmissão externa de dados pessoais**. Não se adicionou fornecedor externo, tracking, upload ou integração para satisfazer uma recomendação genérica. Conferir primeiro as protecções e necessidades existentes.
## Alteração realizada
`routes/web.php`: três linhas acrescentam os dois aliases, com 301, resolução por nome das páginas existentes e conservação de query string. Um parâmetro chamado `redirect` mantém-se um parâmetro de dados e não escolhe o destino do redireccionamento.
`tests/Unit/PortalLegacyNavigationTest.php`: quatro variantes de caminho e um caso de conservação de contexto/destino. A ligação à base é proibida pelo mock. Os testes exercitam a colecção de rotas e a execução do redireccionamento; não executam o percurso completo através do middleware ou de um navegador.
A matriz existente recebe apenas referências e evidências nos IDs relacionados. Estados de aceitação, critérios e restantes células anteriores são preservados. O backlog e o manual existentes apontam para esta entrega.
## Verificações e limites
- Reprodução antes da correcção: cinco casos novos falharam por rota inexistente.
- Após a correcção: nove testes dirigidos passaram, com 37 asserções, incluindo os quatro testes de contacto já existentes. Transporte de email simulado; nenhum email real foi enviado.
- PHP: sintaxe de `routes/web.php` válida.
- Pint: teste novo aprovado. O ficheiro de rotas apresenta seis categorias de formatação já presentes na cópia obtida do commit inicial. Não se reformatou todo o ficheiro alheio ao delta.
- Consulta PostgreSQL: `transaction_read_only=on`, rollback final. Sem alterações de negócio, migrations, concessões, seeds, contas, configurações fiscais ou dados reais.
- Navegador: o acesso CUA devolveu indisponibilidade do host. Não há captura nova, prova visual ou revisão responsiva desta entrega.
- HTTP externo ao processo: as duas tentativas HEAD HTTPS foram impedidas pela verificação de revogação local. Não se afirma uma verificação HTTP ponta a ponta dos destinos.
## Reversão
Retirar apenas o ciclo dos aliases acrescentado após a rota `/Simulador`; a cópia original está no registo privado. O teste novo e estas referências documentais podem ser removidos individualmente. Não usar `git reset --hard`, não repor integralmente um ficheiro que entretanto receba alterações de terceiros e não executar rollback de migrations: nenhuma foi realizada. A reversão não exige alterar dados ou permissões.
## Continuação delimitada
Continuar o P0 pelo contador provincial: contrato de definição visível, agregação multiterritorial com respeito pela publicação e pelas fontes territoriais existentes, testes de duplicação/sem localização/contexto e conferência só de leitura. Não substituir o número por um total nacional fixo nem aceitar uma implementação com consultas por oportunidade sem avaliar o impacto. Retomar o cabeçalho e os percursos em desktop/móvel quando o acesso ao navegador estiver disponível.
As fases estruturais, fiscais e de pré-dossiê devem partir desta reconciliação e dos IDs existentes. A directiva pede apresentação do resultado da Fase 0/P0 antes dessas alterações; esta entrega apresenta os achados e o primeiro delta seguro, com o saldo P0 explícito. Não há deploy, publicação, aceitação ou conclusão global presumida.
+4
View File
@@ -335,6 +335,10 @@ export default function PortalHero({
</div>
))}
</dl>
<p className="mt-3 max-w-lg text-xs text-white/80">
Províncias com localização declarada nas oportunidades
públicas. Cada oportunidade conta uma vez no total.
</p>
</div>
</section>
);
+3
View File
@@ -80,6 +80,9 @@ foreach (['sectores' => 'sectors', 'como-investir' => 'procedures', 'legislacao'
Route::get('/'.$path, [PortalServicesController::class, 'page'])->defaults('page', $page)->name('services.'.$page);
}
Route::get('/Simulador', [PortalServicesController::class, 'page'])->defaults('page', 'simulator')->name('services.simulator.alias');
foreach (['simulador' => 'services.simulator', 'contactos' => 'services.support'] as $legacyPath => $destination) {
Route::get('/'.$legacyPath, fn (Request $request) => redirect()->to(route($destination, $request->query(), false), 301));
}
Route::get('/documentos/{id}', [PortalServicesController::class, 'showDocument'])->whereNumber('id')->name('services.document');
Route::get('/documentos/{id}/view', [PortalServicesController::class, 'viewDocument'])->whereNumber('id')->name('services.document.view');
Route::get('/documentos/{id}/download', [PortalServicesController::class, 'download'])->whereNumber('id')->name('services.download');
@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Services\InvestmentGeography;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
/** Temporary tables shadow QA tables on this connection only; no migrations or permanent fixture writes. */
class HomeTerritorialStatisticsTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (! IsolatedQaDatabase::allowsSchemaChanges()) {
throw new \LogicException('Explicit isolated QA identity required for temporary fixtures.');
}
DB::beginTransaction();
foreach ([
'opportunities' => 'id bigint, status text, published_at timestamptz, deleted_at timestamptz, owner_user_id bigint, workflow_context jsonb, primary_territory_id uuid, province text, geography_scope text',
'opportunity_locations' => 'opportunity_id bigint, geo_location_id uuid',
'geo_locations' => 'id uuid, parent_id uuid, name text, location_type text, deleted_at timestamptz, data_classification text, source_system text, external_id text',
'geo_territory_profiles' => 'geo_location_id uuid, source_id uuid, status text, valid_from date, valid_to date',
'geo_sources' => 'id uuid, status text',
'audit_events' => 'id bigint, source_system text, after_data jsonb, occurred_at timestamptz',
] as $table => $columns) {
DB::statement('CREATE TEMP TABLE '.$table.' ('.$columns.') ON COMMIT DROP');
}
}
protected function tearDown(): void
{
if (DB::transactionLevel() > 0) {
DB::rollBack();
}
parent::tearDown();
}
private function territory(string $name, string $type, ?string $parent = null, array $attributes = []): string
{
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(array_merge([
'id' => $id, 'parent_id' => $parent, 'name' => $name, 'location_type' => $type,
'deleted_at' => null, 'data_classification' => 'public', 'source_system' => 'cartografia-workbook',
'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id,
], $attributes));
return $id;
}
private function opportunity(int $id, array $attributes = []): void
{
DB::table('opportunities')->insert(array_merge([
'id' => $id, 'status' => 'published', 'published_at' => now()->subMinute(),
'deleted_at' => null, 'owner_user_id' => null, 'workflow_context' => null,
'primary_territory_id' => null, 'province' => null, 'geography_scope' => 'unspecified',
], $attributes));
}
private function statistics(): array
{
return app(InvestmentGeography::class)->opportunityStatistics();
}
public function test_multiple_territories_and_repeated_links_do_not_multiply_the_opportunity_total(): void
{
$country = $this->territory('QA country', 'country');
$first = $this->territory('QA province A', 'province', $country);
$other = $this->territory('QA province B', 'province', $country);
$district = $this->territory('QA district', 'district', $first);
$this->opportunity(1, ['primary_territory_id' => $district, 'province' => 'Stale declaration']);
foreach ([$district, $district, $first, $other, $other] as $id) {
DB::table('opportunity_locations')->insert(['opportunity_id' => 1, 'geo_location_id' => $id]);
}
$this->assertSame(['opportunities' => 1, 'provinces' => 2], $this->statistics());
$this->opportunity(2, ['primary_territory_id' => $first]);
$this->opportunity(3);
$this->assertSame(['opportunities' => 3, 'provinces' => 2], $this->statistics());
}
public function test_publication_guards_apply_to_both_counts(): void
{
$country = $this->territory('QA country', 'country');
$public = $this->territory('QA public province', 'province', $country);
$hidden = $this->territory('QA unpublished coverage', 'province', $country);
$this->opportunity(1, ['primary_territory_id' => $public]);
foreach ([
['status' => 'draft'], ['published_at' => now()->addDay()], ['published_at' => null],
['deleted_at' => now()], ['owner_user_id' => 9, 'workflow_context' => '{"publication":{"visibility":"restricted"}}'],
] as $index => $attributes) {
$this->opportunity($index + 2, ['primary_territory_id' => $hidden, ...$attributes]);
}
$this->opportunity(20, ['primary_territory_id' => $hidden]);
DB::table('audit_events')->insert(['id' => 1, 'source_system' => 'opportunity-origin-review', 'occurred_at' => now(),
'after_data' => '{"opportunity_id":20,"classification":"example"}']);
$this->assertSame(['opportunities' => 1, 'provinces' => 1], $this->statistics());
}
public function test_no_territory_and_national_scope_do_not_invent_provincial_coverage(): void
{
$country = $this->territory('QA country', 'country');
$this->territory('QA known province', 'province', $country);
$this->opportunity(1);
$this->opportunity(2, ['province' => 'QA known province', 'geography_scope' => 'national']);
$this->opportunity(3, ['workflow_context' => '{"corridor_agribusiness":{"corridor":"Corredor de Maputo"}}']);
$this->assertSame(['opportunities' => 3, 'provinces' => 0], $this->statistics());
}
public function test_legacy_names_require_a_public_province_and_cannot_override_canonical_links(): void
{
$country = $this->territory('QA country', 'country');
$first = $this->territory('QA province A', 'province', $country);
$this->territory('QA province B', 'province', $country);
$this->opportunity(1, ['province' => ' qa PROVINCE A ']);
$this->opportunity(2, ['province' => 'Unknown province']);
$this->opportunity(3, ['province' => ' ']);
$this->opportunity(4, ['province' => 'QA province B', 'primary_territory_id' => $first]);
$this->opportunity(5, ['province' => 'QA province B']);
DB::table('opportunity_locations')->insert(['opportunity_id' => 5, 'geo_location_id' => $first]);
$this->assertSame(['opportunities' => 5, 'provinces' => 1], $this->statistics());
}
public function test_private_removed_broken_and_cyclic_ancestry_are_not_counted(): void
{
$country = $this->territory('QA country', 'country');
$private = $this->territory('QA private', 'province', $country, ['data_classification' => 'restricted']);
$child = $this->territory('QA private child', 'district', $private);
$removed = $this->territory('QA removed', 'province', $country, ['deleted_at' => now()]);
$broken = $this->territory('QA broken', 'province', (string) Str::uuid());
$cycle = $this->territory('QA cycle', 'province');
DB::table('geo_locations')->where('id', $cycle)->update(['parent_id' => $cycle]);
foreach ([$private, $child, $removed, $broken, $cycle] as $index => $id) {
$this->opportunity($index + 1, ['primary_territory_id' => $id]);
}
$this->assertSame(['opportunities' => 5, 'provinces' => 0], $this->statistics());
}
public function test_city_and_province_are_distinct_but_duplicate_province_names_are_not_added(): void
{
$country = $this->territory('QA country', 'country');
foreach (['Maputo Cidade', 'Maputo Província', ' maputo cidade '] as $index => $name) {
$this->opportunity($index + 1, ['primary_territory_id' => $this->territory($name, 'province', $country)]);
}
$this->assertSame(['opportunities' => 3, 'provinces' => 2], $this->statistics());
}
public function test_empty_catalogue_returns_zeroes_and_query_count_does_not_grow_with_links(): void
{
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertSame(['opportunities' => 0, 'provinces' => 0], $this->statistics());
$emptyCount = count(DB::getQueryLog());
DB::disableQueryLog();
$country = $this->territory('QA country', 'country');
$province = $this->territory('QA province', 'province', $country);
for ($id = 1; $id <= 120; $id++) {
$this->opportunity($id, ['primary_territory_id' => $province]);
DB::table('opportunity_locations')->insert(['opportunity_id' => $id, 'geo_location_id' => $province]);
}
DB::enableQueryLog();
DB::flushQueryLog();
$this->assertSame(['opportunities' => 120, 'provinces' => 1], $this->statistics());
$this->assertSame($emptyCount, count(DB::getQueryLog()));
$this->assertLessThanOrEqual(2, $emptyCount);
DB::disableQueryLog();
}
}
+3 -3
View File
@@ -20,15 +20,15 @@ class PublicPortalNavigationTest extends TestCase
Opportunity::create(['sector_id' => $sector->id, 'reference' => 'CACHE-1', 'title' => 'Solar',
'slug' => 'solar', 'status' => 'published', 'published_at' => now()->subMinute(), 'is_featured' => true]);
$first = $this->getJson('/api/v1/home')->assertOk()->json();
$cached = Cache::get('portal_home_sector_organization_v1_pt');
$cached = Cache::get('portal_home_territorial_v2_pt');
$this->assertIsArray($cached['sectors']);
$this->assertIsArray($cached['featuredOpportunities']);
$restored = unserialize(serialize($cached), ['allowed_classes' => false]);
$this->assertSame($cached, $restored);
Cache::put('portal_home_sector_organization_v1_pt', $restored);
Cache::put('portal_home_territorial_v2_pt', $restored);
$this->assertSame($first, $this->getJson('/api/v1/home')->assertOk()->json());
$sector->update(['name' => 'Energia actualizada']);
$this->assertNull(Cache::get('portal_home_sector_organization_v1_pt'));
$this->assertNull(Cache::get('portal_home_territorial_v2_pt'));
$this->getJson('/api/v1/home')->assertJsonPath('data.sectors.0.name', 'Energia actualizada');
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\Api\V1\HomeController;
use App\Models\Opportunity;
use App\Models\Sector;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
class HomeTerritorialCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache.default' => 'array']);
DB::shouldReceive('connection')->never();
}
public function test_controller_reads_the_new_plain_data_cache_instead_of_the_legacy_metric(): void
{
$current = ['stats' => ['opportunities' => 1, 'provinces' => 2, 'sectors' => 1], 'sectors' => [], 'featuredOpportunities' => []];
Cache::put('portal_home_sector_organization_v1_pt', ['stats' => ['opportunities' => 99, 'provinces' => 1]]);
Cache::put('portal_home_territorial_v2_pt', unserialize(serialize($current), ['allowed_classes' => false]));
$response = app(HomeController::class)->show(Request::create('/api/v1/home?locale=pt'));
$this->assertSame($current, $response->getData(true)['data']);
}
public function test_existing_model_events_invalidate_both_locales_of_the_new_cache(): void
{
foreach ([Opportunity::class, Sector::class] as $model) {
foreach (['saved', 'deleted'] as $event) {
Cache::put('portal_home_territorial_v2_pt', ['stats' => []]);
Cache::put('portal_home_territorial_v2_en', ['stats' => []]);
Event::dispatch('eloquent.'.$event.': '.$model, [new $model]);
$this->assertNull(Cache::get('portal_home_territorial_v2_pt'));
$this->assertNull(Cache::get('portal_home_territorial_v2_en'));
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\DataProvider;
class JointDeliveryContextTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->never();
}
/** @return array<string, array{string, string, string, string}> */
public static function entries(): array
{
$entries = [];
foreach (['GET', 'HEAD'] as $method) {
foreach ([
'/simulador' => ['/simulador-fiscal', 'services.simulator'],
'/simulador/' => ['/simulador-fiscal', 'services.simulator'],
'/contactos' => ['/apoio', 'services.support'],
'/contactos/' => ['/apoio', 'services.support'],
] as $path => [$destination, $name]) {
$entries[$method.' '.$path] = [$method, $path, $destination, $name];
}
}
return $entries;
}
#[DataProvider('entries')]
public function test_both_aliases_preserve_context_and_resolve_the_existing_main_destination(string $method, string $path, string $destination, string $name): void
{
$query = [
'locale' => 'en', 'dossier' => 'qa-existing-dossier', 'opportunity_id' => '42',
'context' => ['territory_id' => 'qa-territory', 'origin' => 'carteira', 'return_to' => '/investidor/dossiers/qa-existing-dossier?stage=S08'],
'q' => 'Maputo & Zambézia / investimento',
'redirect' => 'https://example.invalid/untrusted-destination',
];
$request = Request::create('https://portal-investidor.test'.$path.'?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986), $method);
app()->instance('request', $request);
$routes = app('router')->getRoutes();
$response = $routes->match($request)->run();
$location = $response->headers->get('Location');
$this->assertSame(301, $response->getStatusCode());
$this->assertSame('https', parse_url($location, PHP_URL_SCHEME));
$this->assertSame('portal-investidor.test', parse_url($location, PHP_URL_HOST));
$this->assertSame($destination, parse_url($location, PHP_URL_PATH));
parse_str(parse_url($location, PHP_URL_QUERY), $actual);
$this->assertEquals($query, $actual);
$this->assertSame($name, $routes->match(Request::create($location, $method))->getName());
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Tests\Unit;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\DataProvider;
class PortalLegacyNavigationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->never();
}
public static function aliases(): array
{
return [
'simulator' => ['/simulador', '/simulador-fiscal'],
'simulator trailing slash' => ['/simulador/', '/simulador-fiscal'],
'contacts' => ['/contactos', '/apoio'],
'contacts trailing slash' => ['/contactos/', '/apoio'],
];
}
#[DataProvider('aliases')]
public function test_legacy_links_redirect_to_existing_pages_without_database_access(string $path, string $destination): void
{
$route = app('router')->getRoutes()->match(Request::create($path));
$response = $route->run();
$this->assertSame(301, $response->getStatusCode());
$this->assertSame($destination, parse_url($response->headers->get('Location'), PHP_URL_PATH));
}
public function test_simulator_redirect_preserves_context_without_accepting_an_external_destination(): void
{
$query = ['dossier' => 'qa-context', 'opportunity' => 'qa-opportunity', 'redirect' => 'https://example.invalid'];
$request = Request::create('/simulador?'.http_build_query($query));
app()->instance('request', $request);
$route = app('router')->getRoutes()->match($request);
$response = $route->run();
$location = $response->headers->get('Location');
$this->assertSame('/simulador-fiscal', parse_url($location, PHP_URL_PATH));
$this->assertSame($request->getHost(), parse_url($location, PHP_URL_HOST));
parse_str(parse_url($location, PHP_URL_QUERY), $actual);
$this->assertSame($query, $actual);
}
}