fix(portal): preserve territorial totals and legacy redirect context
This commit is contained in:
@@ -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'],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user