feat(search): busca editorial full-text (US30.1) + release develop#37
Open
GabeMarques-Intetsu wants to merge 61 commits into
Open
feat(search): busca editorial full-text (US30.1) + release develop#37GabeMarques-Intetsu wants to merge 61 commits into
GabeMarques-Intetsu wants to merge 61 commits into
Conversation
…t-level) PR #28 do Dependabot propos django 5.1.4 -> 5.2.14 e foi classificado como minor (semver), entrando no group python-non-major com CI verde. Mas 5.1 -> 5.2 e a transicao LTS-para-LTS em Django, que o item B16 do Improvement-system.md marca como decisao sprint-level: exige validacao manual alem do CI (settings deprecadas, middlewares customizados, auth backend, testes E2E reais). Antes deste fix, o ignore cobria so 'version-update:semver-major' (django 5.x -> 6.x). Agora cobre minor tambem, evitando que upgrades LTS escapem como minor inocente. Patches (5.1.5, 5.1.6, ...) continuam abertos — eles trazem fixes de CVE que importam para producao. Quando subir django em Sprint dedicada, remover este ignore inteiro (volta a aceitar minor + major). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Register.tsx era teatral — não chamava a API. Agora envia username + dados ao authService.register(), trata erro/loading e restaura sessão. .auth-error faltava no Auth.css (só existia no Admin.css). AdminRoute expulsava admin em reload por checar canPublish antes de isLoading terminar a restauração de sessão. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ncelar/:token) Os emails (welcome + notificação de artigo) linkavam para a rota de descadastro, mas ela não existia → link caía em 404 e o leitor não conseguia sair da lista (violação LGPD/CAN-SPAM). A página extrai o token do path e chama POST /newsletter/unsubscribe/ ao montar, com ref-guard para o duplo-mount do StrictMode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ação ordenada - ArticleDetailView ganha IsOwnerOrAdmin (object-level): antes qualquer editor editava/excluía artigo de OUTRO editor via API (restrição só existia no frontend, trivial de burlar). - cover_caption agora é obrigatória; cover_image obrigatória na criação. - is_featured único (atomic): destacar um artigo desmarca os demais. Form de criação/edição expõe o toggle "Destacar no topo da home". - Home usa destaque híbrido: artigo is_featured, com fallback p/ o mais recente quando nenhum está marcado. - get_queryset com .annotate(Count) injeta GROUP BY → queryset vira "unordered" apesar do Meta.ordering; .order_by() explícito mata o UnorderedObjectListWarning e garante paginação consistente. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chip de filtro ativo ficava todo navy. Agora espelha o sistema
--clr-cat-* dos badges dos cards (Cinema âmbar, Moda terracota, etc.)
via --chip-color por data-category; "Todos" cai no fallback navy. As 5
cores passam WCAG AA (>=5:1) com texto branco. O breadcrumb de categoria
no artigo apontava para "/"; agora leva a /noticias?categoria={slug}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A query de destinatários filtrava só role=ADMIN. Como dev é "admin++" (dono, decide BanRequests), se o único superusuário fosse role=dev NINGUÉM recebia a notificação. Agora filtra role__in=[ADMIN, DEV]. Inclui testes de regressão (notifica dev; notifica admin E dev). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ArticleComments só carregava a 1ª página (20 de topo); o resto ficava inacessível. Botão "Ver mais comentários" carrega a próxima página e anexa com dedup por id (publicar desloca o offset do servidor). - Excluir contava só 1 no contador, mas apagar um pai remove pai + respostas → contador dessincronizava. Agora desconta a subárvore. - CommentItem engolia erros (catch vazio): curtir/responder/excluir falhavam em silêncio. Agora cada falha popula uma região role="alert". - Serializer.get_replies_count tinha except:pass mascarando bugs de prefetch; removido para o erro ficar VISÍVEL. - Testes de componente (paginação + feedback de erro) — primeiros do projeto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…obre
Modal usava id="modal-title" hardcoded — 2 modais montados juntos
(Register tem Termos + Privacidade) colidiam: id duplicado + aria-
labelledby ambíguo. Agora cada instância gera id via useId(). No Sobre,
o "Assinar newsletter" virou <Link className="btn"> mas a regra antiga
.about-content__cta a { color: inherit } (especificidade 0,1,1) vencia
.btn--primary (0,1,0) → texto escuro no fundo navy. Regra removida (era
hack da estrutura aninhada antiga); link de contato também volta ao navy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O conversor 'uslug' (slug unicode, p/ slugs acentuados) era registrado em DOIS urls.py (articles + comments) — a 2ª chamada disparava RemovedInDjango60Warning (override de conversor já registrado, proibido no Django 6.0). Registro centralizado em ArticlesConfig.ready() (mesmo idioma do wiring de signals); register é global, então os urls.py só usam <uslug:…>. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No ranking per-article, comment_count já filtrava is_deleted=False mas like_count contava curtidas de comentários OCULTOS (deletados) — inflava o engagement_rate com interação em conteúdo invisível. Mesmo filtro aplicado ao like_count (distinct mantém o anti-cartesiano do JOIN comments × likes). Inclui teste de regressão. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regressão do ec572c0. O seletor :hover (especificidade 0,2,0) vencia .home-filters__cat--active (0,1,0) e pintava o TEXTO com a cor da editoria — a mesma cor do fundo do chip ativo → palavra invisível ("botão todo escuro") ao clicar ou ao estar na página da categoria. Hover agora só se aplica a chip NÃO-ativo (:not(--active)); hover no ativo só escurece o fundo (brightness) sem tocar no texto branco. Verificado ao vivo (Playwright): as 5 categorias dão texto branco sobre a cor sólida em ativo e ativo+hover (todas WCAG AA >=5:1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Só o label de exibição muda (valor interno 'editor' inalterado, sem migração de dados — migration é AlterField de choices, no-op no banco). Nome da migration evita colidir com o `replaces` da squash 0003. Atualiza o badge da role na tabela de usuários do Admin. (O badge no NavbarUserMenu acompanha no commit de @username.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t no front) Toda senha nova exige maiúscula + minúscula + dígito + especial (@$!%*?&#), além do mínimo de 8. PasswordComplexityValidator no AUTH_PASSWORD_VALIDATORS cobre cadastro, troca e reset (todos passam por validate_password). No front, passwordRules.ts é a fonte única (espelha o backend) e o componente PasswordChecklist mostra as 5 regras ao vivo, travando o submit até cumprir. Aplicado em Register e ResetPassword (Perfil vem no commit de profile). TDD: 8 testes no validator + 8 no util. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdateProfileSerializer passa a aceitar username, com unicidade case-insensitive (exclui o próprio usuário) e CASE PRESERVADO — Register também deixa de forçar lowercase (ambos via _validate_username + regex de formato sem espaços). Perfil ganha o campo "Nome de usuário" e a troca de senha passa a usar o PasswordChecklist + isPasswordStrong (substitui o antigo "mínimo 8"). 4 testes de regressão para a edição de username. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O nome continua sendo o exibido em destaque; logo abaixo entra o @username em navy da marca (--clr-primary, ~13:1 de contraste, fonte um pouco menor) no author card do artigo e no dropdown do navbar. Tipo do author no articleService ganha `username`. O badge de role no NavbarUserMenu também passa de "Redator" para "Editor" (acompanha o refactor de role). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O badge "Literatura" nos cards usa a variante subtle (texto da cor da editoria sobre fundo claro): #15803d sobre #e8f5ec dava 4.40:1, abaixo do mínimo AA (4.5:1) — WAVE acusava contrast error. Escureci o token --clr-cat-literatura p/ #166534 (green-800, ainda "forest") → 6.35:1. Melhora também o chip ativo e o badge sólido (texto branco). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WAVE acusava "skipped heading level" (h1→h3). AboutContent ganhou prop headingLevel (h2 default em /sobre sob o h1 da página; h3 quando dentro do Modal do AuthLayout, cujo título já é h2). NewsCard ganhou prop titleAs (h3 default p/ Home, onde os cards ficam sob h2 de seção; /noticias passa h2, pois os cards vêm direto sob o h1). Hierarquia sem saltos nas duas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ding") WAVE marcava os números grandes dos stat cards como "possible heading" (texto parece heading mas não é). Par rótulo→valor é semanticamente uma definição: converti os 4 tipos de card (admin__stat-card, HeroKpi, SmallStat, AdminPosts) para dl/dt/dd, que o WAVE não trata como heading. Layout preservado (valor no topo via column-reverse onde necessário). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WAVE acusava "No page regions" (sem landmarks) e "Orphaned form label". Header vira <header> (banner) e o corpo vira <main> (conteúdo principal); o rótulo "Imagem de capa" ganha htmlFor="post-cover-file", associando-o ao input file. Mesmo componente serve /criar-publicacao e a edição. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restam os 2 últimos alertas do WAVE. Na Home, o rótulo "Destaque" era um <div> não-heading, então o título do card destaque (h3) caía direto sob o h1 do hero (salto h1→h3); virou <h2> real (id + aria-labelledby na seção). No donut de Editorias das Métricas, o número central era o único KPI fora do padrão: convertido para dl/dt/dd (não marca "possible heading"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…prido verde) Antes o checklist só aparecia ao digitar e usava vermelho agressivo (#dc2626) + barra vermelha — fora da paleta e alarmante (requisito não cumprido não é erro). Agora fica SEMPRE visível (hideWhenEmpty default false) com estados calmos: pendente = muted #4b5563 + "○"; cumprido = verde #047857 + "✓" (ambos passam WCAG AA; ícone carrega o estado junto da cor, 1.4.1). Vermelho fica reservado para erro real no submit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Screenshots de paletas p/ review, o .zip e as apresentações de cliente são artefatos pesados/gerados — ficam fora do repo, como já é feito com docs/planning/ e os reports de teste. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….py) Cria as 4 contas oficiais (1 admin + 3 editores) de forma idempotente, com senhas aleatórias geradas no runtime e impressas uma vez (nada hardcoded no repo — corrige o smell do seed_users.py antigo, que tinha senha compartilhada fixa). Login é por e-mail; username é só o @handle (editável no perfil). Hardening guiado por revisão adversarial multi-agente: - savepoint por-usuário (with transaction.atomic no loop) + captura de IntegrityError: colisão de username já usado por outra conta isola o item, não derruba os demais (antes o @atomic no handle inteiro revertia tudo); - senha fixa do admin oficial só via env INTERPOP_ADMIN_PASSWORD ou --prompt-interpop-password (getpass) — nunca por argv (evita vazar em `ps`/histórico do shell, CWE-214); - senha fixa passa por validate_password (AUTH_PASSWORD_VALIDATORS) antes de tocar no banco — mesma política dos serializers; senha fraca é recusada; - avisa quando a senha do interpop é fornecida mas a conta já existe sem --reset-passwords (antes era descartada em silêncio); - docstring/help: NUNCA rodar em CI/CD (senhas vão pro stdout). interpop = role=admin sem is_superuser/is_staff (painel é por role; menos superfície). Remove o seed_users.py antigo (rodado via shell -c, superseded). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Antes dev e admin eram ambos imunes a ban (regra só do alvo). Agora a hierarquia (dev > admin > editor > user) é RELACIONAL no ban direto: - dev: imune a todos (nunca é alvo); - admin: banível APENAS por um dev; - editor/user: por admin ou dev; ninguém bane a si mesmo. O contrário continua bloqueado (admin não bane dev nem outro admin). Enforço em 3 camadas no BanSerializer (queryset ator-aware → só dev vê admins como alvo; validate_user_id; e a barreira no service ban_user via User.can_be_banned_by(actor)). O BanRequest (editor solicita) segue restrito a user/editor (is_immune_to_ban). Hardening guiado por verificação adversarial multi-agente (fechou 2 bypasses que anulavam a regra pelo lado inverso): - UNBAN passou a ser relacional (User.can_be_unbanned_by + guard em unban_user): um admin comum NÃO desfaz o ban que um dev pôs num admin; - IsNotBanned nas views de ban: um admin banido não modera nem se auto-desbane; - is_banned vira read-only no Django admin: ban/unban só pela service layer (mantém can_be_banned_by + o invariante Ban↔is_banned, ADR-012); editar pelo admin pulava as camadas e criava estado inconsistente. Testes: matriz exaustiva do model (can_be_banned_by), serializer ator-aware, guards de service (camada 3, ban e unban) e integração via API (dev bane admin 201; admin→admin/dev 400; unban dev-sobre-admin por admin 403; dev desbane; admin desbane editor). +19 testes, 210 no total. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….1.1, T30.1.2] [DESIGN-v3] Cria a estrutura inicial do bounded context de busca editorial: - AppConfig SearchConfig registrado em INSTALLED_APPS - models.py com SearchIndex (managed=False) e SearchLog (managed=False) - author_id UUID (Bug 1 do specialist — User.id é UUID, não BIGINT) - category_id BIGINT NULL-able - services.py, dto.py, signals.py como stubs documentados para Fase 2 - Estrutura migrations/ e management/commands/ vazias - pytest marker 'requires_postgres' (ADR-020 — testes FTS pulam em SQLite) - README.md documenta split de responsabilidades (trigger SQL vs signal Python), fallback SQLite-dev e provisão Postgres managed=False é deliberado: o schema é controlado por SQL puro nas migrations (extension unaccent, CONFIGURATION pt_unaccent, função IMMUTABLE, trigger PL/pgSQL, índices CONCURRENTLY). O ORM apenas mapeia para queries. Tests: 7 unit (todos passam em SQLite-dev) cobrindo registro do app, carga do AppConfig, importabilidade dos stubs e tipagem correta dos models. Refs: DESIGN.md §2.2, §6 (Fase 1) Refs: _specialist-outputs/01-database-architect.md §1 (Bug 1) Refs: ADR-018, ADR-019, ADR-030-DB Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…elas [T30.1.4b] [DESIGN-v3]
Cria, em Postgres, o schema fundacional da busca editorial:
1. CREATE EXTENSION IF NOT EXISTS unaccent (ADR-019; exige SUPERUSER em prod — TX-13)
2. immutable_unaccent(regdictionary, text) IMMUTABLE PARALLEL SAFE
(workaround para que unaccent — STABLE em catalog — possa ser usado em
índice expressão)
3. CONFIGURATION public.pt_unaccent COPY portuguese, com
ALTER MAPPING FOR hword, hword_part, word WITH unaccent, portuguese_stem
(pipeline FTS canônico pt-BR — corrige Bug 2 do specialist DB)
4. articles_search_config(text) RETURNS tsvector IMMUTABLE PARALLEL SAFE
5. Tabela search_index (read-projection — ADR-016) com:
- article_id UUID PK REFERENCES articles(id) ON DELETE CASCADE
- search_vector tsvector NOT NULL
- title_text / excerpt_text / body_text
- author_id UUID REFERENCES users(id) ON DELETE CASCADE
(Bug 1 corrigido: User.id é UUID, não BIGINT)
- category_id BIGINT NULL REFERENCES categories(id) ON DELETE SET NULL
- published_at TIMESTAMPTZ, indexed_at TIMESTAMPTZ
6. Tabela search_log (analytics + retenção LGPD 7d)
Em SQLite-dev (ADR-020): fallback mínimo (skeleton sem extensions, sem
configuration, sem tsvector real, sem FKs com CASCADE). Garante que `migrate`
roda localmente sem Postgres.
Detalhes de implementação:
- CreateModel para registrar models no state do ORM (managed=False — ORM
apenas mapeia, NÃO cria/altera schema)
- RunPython com guard connection.vendor == 'postgresql'
- Splitter SQL multi-statement (compat sqlite3 que aceita 1 stmt/execute)
- Reverse simétrico (DROP) — extension NÃO é dropada (compartilhada)
Tests: 4 cross-backend + 7 Postgres-only (skip em SQLite via marker
requires_postgres). Cobertura: tabelas existem, colunas presentes,
unaccent instalada, CONFIGURATION criada, função IMMUTABLE (provolatile='i'),
'Beyoncé' == 'beyonce', 'cantores' casa 'cantor', search_vector é tsvector,
author_id é UUID no DB.
Refs: DESIGN.md §2.2; ADR-018, ADR-019, ADR-020;
_specialist-outputs/01-database-architect.md §1 (Bug 1, Bug 2)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Cria, em Postgres, 4 índices em search_index com atomic=False (CONCURRENTLY
não roda em transação):
1. idx_search_vector_gin
- GIN sobre search_vector (FTS pt-BR)
2. idx_search_category_published
- Composite parcial (category_id, published_at DESC)
- WHERE category_id IS NOT NULL
- ~40% menor + write amp zero quando category_id IS NULL (ADR-030-DB)
3. idx_search_author_pub_covering
- Composite (author_id, published_at DESC) INCLUDE (article_id)
- Habilita index-only scan na CTE candidate-narrowing (algorithms §2.3)
4. idx_search_published_only
- BTree (published_at DESC) — recência sem texto
Em SQLite-dev (ADR-020): no-op silencioso. SQLite não tem CONCURRENTLY, GIN
nem partial indexes; o SearchService (Fase 2) usa __icontains fallback.
Tests: 5 Postgres-only (skip em SQLite) + 1 cross-backend (confirma no-op
aplicado em SQLite). Cobertura: cada índice existe, GIN tem USING gin,
partial tem WHERE NOT NULL, covering tem INCLUDE article_id.
NOTA atomic=False: se 1 dos 4 CREATE INDEX falhar, anteriores ficam.
Postgres permite resume manual; em produção, validar com \\d+ search_index.
Refs: DESIGN.md §2.2 (Indexes refinados); ADR-030-DB;
_specialist-outputs/01-database-architect.md §1 (Bug 5).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… [T30.1.5b]
Cria, em Postgres, a fonte de verdade da consistência (ADR-018):
1. Função PL/pgSQL trg_articles_sync_search():
- IF status='published' AND published_at IS NOT NULL:
UPSERT em search_index com setweight A/B/C de title/excerpt/body
via articles_search_config (configuration pt_unaccent — ADR-019)
- ELSE: DELETE FROM search_index WHERE article_id = NEW.id
(corrige Bug 3 "fantasma do publicado" — despublicação some da busca)
2. Função PL/pgSQL trg_articles_remove_search():
- DELETE FROM search_index WHERE article_id = OLD.id em DELETE Article
3. Trigger articles_sync_search:
AFTER INSERT OR UPDATE OF
status, published_at, title, excerpt, body, author_id, category_id
ON articles FOR EACH ROW
(escopo limitado a campos relevantes — UPDATE em view_count, is_featured,
slug etc NÃO dispara reindex; reduz custo)
4. Trigger articles_remove_search AFTER DELETE ON articles
Por que trigger SQL e NÃO signal Python:
A v2 propunha signal post_save Django como fonte única. Signal NÃO dispara em
4 cenários reais:
- bulk_update / QuerySet.update()
- Raw SQL (UPDATE articles SET status=...)
- Fixture loaddata em CI/dev
- Restore parcial pós-incidente (pg_restore --table=articles)
Trigger SQL cobre todos, atomicamente dentro da transação write de Article.
Signal Python (Fase 2) fica APENAS para invalidação de cache Redis.
Em SQLite-dev (ADR-020): no-op. PL/pgSQL não existe; SearchService usa
fallback __icontains em desenvolvimento local.
COALESCE em title/excerpt/body protege contra NULL transitório em UPSERT.
Tests: 5 cenários de ADR-018 (Postgres-only — skip em SQLite via marker)
+ 1 smoke cross-backend (confirma no-op aplicado em SQLite):
- INSERT publicado: 1 linha em search_index com tsvector populado
- UPDATE title PUBLISHED: tsvector reflete novo título (contém 'beyonc')
- bulk_update status=draft: search_index vazio (Bug 3 coberto)
- Raw SQL UPDATE status=draft: search_index vazio
- DELETE Article: search_index vazio
Refs: DESIGN.md §2.2 (Decisão refinada — Trigger SQL + Signal);
ADR-018, ADR-019, ADR-020;
_specialist-outputs/01-database-architect.md §1 (Bug 3, Bug 4).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aplica storage params customizados (ADR-034) ao índice GIN e à tabela
search_index para manter p95 estável sob write-heavy:
idx_search_vector_gin:
- fastupdate = on (insertions vão para pending list buffer)
- gin_pending_list_limit = 2048 (2MB em KB; default 4MB)
Flushes mais frequentes e pequenos em vez de spikes ocasionais.
search_index (table-level):
- autovacuum_vacuum_scale_factor = 0.05 (default 0.2)
Vacuum 4x mais frequente — dead tuples baixos.
- autovacuum_analyze_scale_factor = 0.02 (default 0.1)
Statistics fresh para plan_rows do ADR-025.
- autovacuum_vacuum_cost_delay = 10ms (default 20ms)
Pacing agressivo — KVM 1 I/O tolera.
NOTA unidades: gin_pending_list_limit aceita inteiro em KB no ALTER;
'2MB' como string só funciona em SHOW. cost_delay em ms inteiro.
Em SQLite-dev (ADR-020): no-op. Encerra a Fase 1 (DB schema).
Tests: 3 Postgres-only (skip em SQLite) verificando pg_class.reloptions
+ 1 smoke cross-backend (migration aplicada). Cobertura: fastupdate=on,
pending_list_limit=2048, scale_factor=0.05, analyze=0.02, cost_delay=10.
Refs: DESIGN.md §2.2 (Vacuum tuning); ADR-034;
_specialist-outputs/01-database-architect.md §2 Gap E.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
48 arquivos materializando a feature busca editorial full-text: - DESIGN.md v3 (614 linhas, 6 specialists reais integrados) - BACKLOG.md (144 Tasks com prioridades, naming pt-BR) - DESIGN-v1-degraded-mode.md + DESIGN-v2-hybrid.md (historico preservado) - SECURITY-REVIEW.md (17 achados, APROVADO COM RESSALVAS) - TEST-STRATEGY.md (matriz 10 tipos, 110 testes projetados) - REVIEW-PHASE-1.md (gate Fase 1: APROVADO COM RESSALVAS) - 35 ADRs em adrs/ (15-29 specialists, 30-34 refinos v3, 35-45 validadores) - INDEX.md + tracker.md (cross-ref ADR-Task-Test) - 4 specialist outputs literais em _specialist-outputs/ 10 bugs reais detectados e corrigidos pelos specialists antes da implementacao. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Materializa as 8 constantes do algorithms specialist (invariantes #8, #9, #10, #12) e da DESIGN §2.4 (paginação) como settings configuráveis por env var. Nada literal espalhado em código. Constantes adicionadas (config/settings/base.py): - SEARCH_RECENCY_HALF_LIFE_DAYS=60 (Inv 10) - SEARCH_MAX_TOKENS=8 (Inv 8 — defesa A2) - SEARCH_MIN_Q_LENGTH=2, SEARCH_MAX_Q_LENGTH=200 - SEARCH_MAX_PAGINATION_DEPTH=50 (Inv 9 — defesa A3) - SEARCH_DEFAULT_PER_PAGE=20, SEARCH_MAX_PER_PAGE=50 - SEARCH_CANDIDATES_LIMIT=500 (M1 — CTE narrowing) - SEARCH_STATEMENT_TIMEOUT_MS=500 (Inv 12 — defesa T30.4.X9) - SEARCH_CACHE_TTL_SECONDS=300 - SEARCH_FEATURE_ENABLED=False (T30.1.X4 — cutover deliberado) - SEARCH_CURSOR_HMAC_SECRET (fallback SECRET_KEY em dev) Throttle scopes registrados em REST_FRAMEWORK.DEFAULT_THROTTLE_RATES (base + development.py): search_anon=30/min, search_user=60/min, search_global=500/min (ADR-024 + ADR-036). Dev relaxa para 10000/hour. test_settings.py: 11 testes verificando cada constante (defesa contra regressão silenciosa em refactor de settings). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…N-v3] Implementa a invariante #2 do algorithms specialist: função única de normalização lexical, usada simétricamente em (a) signal de cache invalidation (futuro) e (b) SearchService.query() antes do plainto_tsquery. Drift entre as duas pontas quebra busca composta (k-pop vs kpop) silenciosamente. Escopo (intencional e mínimo): - lowercase (case-fold determinístico) - whitelist alfanumérico + acento pt-BR + espaço + hífen (defesa de profundidade contra H-01 XSS reflexão; camada principal é o serializer) - expansão de hífen: "k-pop" -> "k-pop kpop" (resolve algorithms §4 edge case "k-pop vs kpop NÃO casa sem normalização") - whitespace collapsing - dedup preservando ordem (garante idempotência f(f(x))==f(x)) Fora do escopo (delegado ao Postgres): - stemming (ts_lexize portuguese_stem) - unaccent (config pt_unaccent — ADR-019) - stopword removal (config portuguese) test_utils.py: 15 testes cobrindo lowercase, acentos pt-BR, expansão hífen, edge cases (vazio, emoji, HTML chars), idempotência, determinismo (100 calls = mesma saída), simetria signal/service (identity check via `is`), cenário concreto k-pop -> kpop matching. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sultPage [DESIGN-v3] Materializa os 4 DTOs canônicos da busca como dataclasses frozen+slots (Python 3.10+). Imutabilidade reforça a invariante #1 do algorithms specialist (determinismo): caller não muta input após construir → output previsível em property-based testing. - QuerySpec: q + filtros (author/category/de/ate) + cursor + per_page. Mapeia 1:1 a query string do endpoint /api/v1/search/articles/ (ADR-023). - CursorPayload: (score, published_at, article_id, depth) — o estado mínimo para tuple comparison estável na CTE scored (algorithms §7). Inv #6 score com ROUND(6) já aplicado; Inv #9 depth para enforcement do cap 50 páginas. - ResultItem: 1 artigo na response (article_id, title, slug, excerpt, published_at, author dict, category dict, cover_url, score). NÃO é entidade ORM — anti N+1 já resolvido no service via select_related. - SearchResultPage: results (tuple), next_cursor (None=esgotou), total_estimate (ADR-025), query_terms_expanded (tuple, Inv #11 para highlight client-side correto pt-BR), took_ms. test_dto.py: 9 testes cobrindo construção mínima/completa, FrozenInstanceError em mutação, equality por valor (cache key estável), tuple-not-list para results e query_terms_expanded (defesa imutabilidade output). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implementa o achado H-04 do SECURITY-REVIEW (CWE-524, ADR-037): cache key
inclui auth_tier ('anon'|'user'). Sem isso, resposta cacheada para
anônimo pode servir autenticado e vice-versa — vetor de vazamento de
metadata de tier (rate-limit remaining, futuros campos personalizados).
apps/search/cache.py:
- canonical_query_string(spec) -> str
Serializa (q normalizado, author_id, category_id, de, ate, per_page)
em JSON ordenado. Cursor NÃO entra aqui (faz parte do "estado de
paginação", não do "shape semântico").
- build_cache_key(spec, *, auth_tier) -> str
Formato: 'search:v1:<tier>:<sha256_hex>'. Prefix versionado casa com
cache.delete_pattern('search:v1:*') do signal de invalidação (próximo
commit). Tier inválido levanta ValueError (fail-fast — fallback
silencioso para pool comum reabriria H-04).
- normalize_search_text é re-aplicado no payload — garante simetria com
inv #2 (caches por "KPOP" e "kpop" são o mesmo).
test_cache.py: 11 testes cobrindo:
- Determinismo canonical string
- Normalização q (KPOP==kpop em cache)
- Filtros diferentes → keys diferentes
- Cursor excluído da canonical, mas incluído na build_cache_key
- H-04 invariante: anon vs user produzem keys diferentes
- ValueError em tier inválido (defesa fail-fast)
- Formato 'search:v1:<tier>:<64hex>' parseable
- per_page e date filters entram na key
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…] [DESIGN-v3]
Implementa o handler de invalidação de cache pós-mutação de Article.
Invariante ADR-018 (DURA) honrada: signal NUNCA escreve em SearchIndex.
Trigger SQL é a fonte de verdade da consistência; signal só apaga cache
para que a próxima request veja dados frescos.
apps/search/signals.py:
- post_save Article -> invalidate_all_search_cache() (qualquer status,
qualquer mutação — published, draft, despublicação, edit em campos
fora da UPDATE OF da trigger).
- post_delete Article -> invalidate.
- dispatch_uid setado para idempotência em hot-reload de dev.
apps/search/cache.py (extensão):
- invalidate_all_search_cache(): tenta cache.delete_pattern('search:v1:*')
(django-redis em prod). Fallback cache.clear() em LocMemCache (dev).
Trade-off documentado: clear total é mais broad que pattern delete,
mas é cirúrgico em prod onde Redis está disponível.
test_signals.py: 7 testes cobrindo
- LocMemCache fallback (cache.clear via patch+spec sem delete_pattern)
- Redis path (mock delete_pattern retorna 42)
- post_save published invalida cache de query 'soft power'
- post_save draft invalida (despublicação)
- post_delete invalida
- Invariante "signal não importa SearchIndex" verificada por
inspect.getsource (defesa contra refactor descuidado).
Fix sutil: usar 'is_new' (não 'created') no extra={} do logger para
não colidir com LogRecord.created built-in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implementa os 3 throttle classes do endpoint /api/v1/search/articles/ (ADR-024 + ADR-036). Scopes lidos do REST_FRAMEWORK.DEFAULT_THROTTLE_RATES (adicionados em commit anterior de settings). apps/search/throttles.py: - SearchAnonThrottle (AnonRateThrottle, scope='search_anon') 30/min por IP — protege contra rate-limit individual em anon. - SearchUserThrottle (UserRateThrottle, scope='search_user') 60/min por user.pk — tier maior para autenticado. - SearchGlobalThrottle (SimpleRateThrottle, scope='search_global') 500/min com key ESTÁTICA — defesa H-03 contra botnet distribuído (1000 IPs × 1 req/min cada → anon throttle não trigga, agregado satura). Quando estoura, 429 + Retry-After para o endpoint inteiro. test_throttles.py: 6 testes verificando scopes, classes herdadas corretamente, e o invariante crítico do global: key compartilhada entre IPs diferentes (defesa real contra botnet — sem isso seria mais uma throttle por IP). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…DESIGN-v3] Implementa cursor HMAC para paginação keyset (algorithms §7). Cobre 3 invariantes do specialist: - Inv #5: assinatura inválida -> InvalidCursorError (view traduz 400 cursor_invalid; NUNCA 500 ou 200 silencioso). - Inv #6: score ROUND(6) simétrico com SELECT da CTE scored. Float drift na 7ª casa quebraria paginação keyset. - Inv #9: depth carregado no cursor; rejeita > SEARCH_MAX_PAGINATION_DEPTH (defesa A3 anti-paginação profunda). apps/search/cursors.py: - encode_cursor(CursorPayload) -> "<b64>.<sig>" (URL-safe sem padding) Payload JSON ordenado por chave -> determinístico (mesma payload = mesmo cursor, defesa cache key). Sig = HMAC-SHA256(settings.SEARCH_CURSOR_HMAC_SECRET, b64). - decode_cursor(str) -> CursorPayload hmac.compare_digest timing-safe (L-04 SECURITY-REVIEW). Valida formato, base64, JSON, schema, depth range. Qualquer falha -> InvalidCursorError. - InvalidCursorError(ValueError) sentinel para o view. Rotação de chave: mudar SEARCH_CURSOR_HMAC_SECRET em env invalida todos os cursores ativos (recebem 400). Aceito (ADR-021). test_cursors.py: 12 testes cobrindo round-trip, base64 URL-safe, ROUND(6) simetria, todos os caminhos de InvalidCursorError (garbage, payload tampered, sig tampered, wrong secret via override_settings, empty, missing separator), depth cap (>50 reject, ==50 accept), e determinismo (mesma payload = mesmo cursor). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…1.7] [DESIGN-v3]
Implementa o coração da busca. SearchService.query(QuerySpec) ->
SearchResultPage com TODAS as 12 invariantes do algorithms specialist
(_specialist-outputs/02-algorithms-architect.md §8).
apps/search/services.py:
Fluxo (algorithms §7):
1. Decode cursor (Inv 5) — InvalidCursorError propaga p/ view = 400
2. normalize_search_text (Inv 2) — simétrico com signal
3. Validate token cap (Inv 8) — TooManyTokensError = 400
4. Empty-tsquery early-exit (Inv 7) — 0 hits Postgres em q='!!!' ou
q='o de da' (stopwords-only)
5. Bifurca por vendor:
- SQLite-dev (ADR-020): __icontains via Article queryset com
status='published' filter (Inv 4) e ORDER BY published_at, id
(Inv 1 determinismo)
- Postgres: CTE candidates LIMIT 500 (M1) + scored com
ROUND(score::numeric, 6) (Inv 6) + cursor tuple comparison +
ORDER BY (score DESC, published_at DESC, article_id ASC)
6. SET LOCAL statement_timeout='500ms' (Inv 12 — defesa T30.4.X9
independente do role)
7. plainto_tsquery (Inv 3) — NUNCA to_tsquery
8. ts_lexize('portuguese_stem', ...) por token via unnest
-> query_terms_expanded (Inv 11)
9. EXPLAIN (FORMAT JSON) -> Plan Rows + estimate_total floor (ADR-025)
10. Side-fetch Article in_bulk (anti N+1) preservando ordem do ranking
11. Encode next_cursor com depth=page_count (Inv 9 enforced no decode)
Public helpers:
- estimate_total(results_len, per_page, plan_rows, page_count)
Floor = max(plan_rows, (page-1)*per_page + results_len). ADR-025
garante que estimate nunca seja menor que evidência empírica.
- TooManyTokensError — view traduz p/ 400 query_too_complex
- _PT_BR_STOPWORDS frozenset (mínimo cobre adversarial input típico)
- _significant_tokens(), _is_empty_tsquery() — Inv #7 helpers
Comment-locks anti-regressão:
- SECURITY M-01: queries SEMPRE com parametrização cursor.execute(sql,
params) ou QuerySet .filter. NUNCA .extra(where=) ou raw().
- ADR-037: response function-pure de (q, filters, cursor); NÃO adicionar
campos por-usuário sem rever (H-04 cross-tier leak).
test_service.py: 11 testes
- estimate_total: max(plan, floor) com underestimate
- empty q early-exit via CaptureQueriesContext (0 queries)
- query_terms_expanded é tuple
- SQLite fallback exercitado (rota __icontains)
- InvalidCursorError propaga (cursor='garbage')
- TooManyTokensError em 9 tokens (cap 8)
- Postgres-only: plainto_tsquery sanitiza, statement_timeout aplicado
(skipped em SQLite-dev via marker requires_postgres)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fecha o endpoint /api/v1/search/articles/ — Fase 2 backend leitura completa. Tasks: T30.1.8, T30.1.9, T30.1.X4 (feature flag), T30.4.X4 (cache key sha256+auth_tier). apps/search/serializers.py: - SearchQuerySerializer: valida query string -> QuerySpec (frozen DTO). Whitelist H-01: q deve casar ^[a-zA-Z0-9À-ſ\s\-]+$ (rejeita HTML chars ANTES de bater no DB). 2 ≤ len ≤ 200. per_page ≤ 50. validate() cruza de ≤ ate. to_query_spec() materializa DTO. - SearchResultItemSerializer + SearchResultPageSerializer: projeção JSON do response shape ADR-023 (id, title, slug, excerpt, published_at, author, category, cover_url, score, query_terms_expanded, took_ms). apps/search/views.py — SearchArticlesView (APIView, GET only, AllowAny): 1. Feature flag T30.1.X4: SEARCH_FEATURE_ENABLED=False -> 503 + Retry-After: 60 (cutover deliberado em prod). 2. SearchQuerySerializer.is_valid() -> 400 com erros estruturados. 3. Cache HIT/MISS via build_cache_key(spec, auth_tier) — H-04 ADR-037 garante isolamento anon vs user. 4. Service: InvalidCursorError -> 400 cursor_invalid (Inv #5); TooManyTokensError -> 400 query_too_complex (Inv #8). 5. Headers: Cache-Control public/max-age=60/swr=300, Vary: Authorization+ Accept-Encoding (separa CDN entre tiers), X-Robots-Tag noindex (T30.4.X11/L-05), X-Cache HIT|MISS. - Throttles: SearchAnonThrottle (30/min IP) + SearchUserThrottle (60/min user) + SearchGlobalThrottle (500/min global, defesa H-03 botnet). apps/search/urls.py + config/urls.py: - path('api/v1/search/', include('apps.search.urls')) - path('articles/', SearchArticlesView.as_view(), name='articles') Extensível: /search/comments/, /search/suggest/ futuros. Comment-locks anti-regressão: response function-pure de (q, filters, cursor) — NÃO adicionar campos por-usuário sem rever ADR-037 (H-04 cross-tier leak). test_views.py: 16 testes via DRF APIClient cobrindo: - Feature flag 503 + Retry-After - Validação serializer (q missing/short/long/HTML chars, per_page > 50, date range invertido) -> 400 - Cursor inválido -> 400 cursor_invalid (Inv #5) - 9 tokens -> 400 query_too_complex (Inv #8) - 200 OK happy path com response shape completo (Inv #11 honrado) - Stopwords-only -> 200 results=[] (Inv #7 early-exit) - Headers Cache-Control + Vary (H-04 CDN) + X-Robots-Tag (T30.4.X11) - Cache MISS -> HIT (segundo request bate em cache) - H-04 critical: anon e user com MESMA query produzem cache keys DIFERENTES (anon MISS-HIT-HIT; user MISS na própria chave separada) Smoke manual: - SEARCH_FEATURE_ENABLED=True + GET q=kpop -> 200 OK com headers OK - SEARCH_FEATURE_ENABLED=False + GET q=kpop -> 503 + Retry-After: 60 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…EADME) REVIEW-PHASE-2.md (gsd-code-reviewer): - Veredito APROVADO COM RESSALVAS - 9/12 invariantes OK, 2 com gaps de teste, 1 (statement_timeout) quebrado - 3 PR-final blockers (T30.2.B1/B2/B3): SET LOCAL TX, Cache-Control public+Vary, HMAC fallback - 7 warnings, 5 low; 15 tasks novas - GO para Fase 3 frontend em paralelo TX-18 baseline (docs/performance/): - 4 runs Lighthouse: dev/prod x desktop/mobile - Production: desktop 93/LCP 0.7s OK; mobile 81/LCP 3.1s viola NFR ja hoje - CLS 0.153-0.176 ambos violam 0.1 (pre-existente, sem busca) - Bundle 355 KiB folga 30% do limite 500KB - README com tabela, NFR gap analysis, comando reproducao, gate CI proposto Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…] [DESIGN-v3] Instala @tanstack/react-query, mark.js e @types/mark.js (runtime) + msw, @axe-core/react e vitest-axe (dev). Adiciona QueryClientProvider no main.tsx com staleTime/gcTime alinhados ao Cache-Control do backend (ADR-023). Estende src/styles/global.css com tokens de highlight, chip e skeleton — light + dark — derivados do brand vigente (`--clr-primary` navy + `--clr-accent` amarelo signature). NÃO redefine `--clr-primary`, `--font-serif`, `--clr-accent` (ADR-029 anti-fork). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [T30.1.X6] [DESIGN-v3] Hook crítico que converte N keystrokes rápidos em 1 update, alinhando search-as-you-type ao rate limit de 30/min do backend (ADR-024). Resolve Bug 4 do refino v3: useDeferredValue NÃO é debounce — não tem delay configurável. A combinação correta é: inputQ → useDebouncedValue(250) → debouncedQ → useDeferredValue → key 5 testes cobrem: valor inicial sem delay, propagação após delayMs exato, 3 keystrokes em 200ms colapsam em 1 update, cleanup no unmount (sem state-leak), troca dinâmica de delayMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….1.16, T30.1.X7] [DESIGN-v3] - types.ts: contrato espelhado do SearchResultPageSerializer (BE/apps/search) - services/searchService.ts: axios client com AbortSignal + canonical query string (chaves vazias não fragmentam cache) - hooks/useSearchParamsState.ts: URL como SSOT, replace-by-default em search-as-you-type (não polui histórico) - hooks/useSearch.ts: useInfiniteQuery com debounce 250 → deferred, enabled q.length ≥ 2 (CA01), retry 0 em 4xx, retry 1 em 5xx - _internals export: testes unitários cobrem Bug 6 (next_cursor null → undefined → hasNextPage false) e shouldRetry(4xx)=false sem precisar de waitFor de rede. 6 testes adicionais (11 total no chunk). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….X8, T30.1.15] [DESIGN-v3] - <form role="search"> + <input type="search"> semantico (ADR-028) - Auto-foco, enterKeyHint="search", spellCheck=false, autoComplete=off - Bind URL <-> input via useSearchParamsState - aria-describedby para status (count + buscando) - visually-hidden label Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…7, T30.1.X10] [DESIGN-v3] - HighlightedText: mark.js c/ accuracy=complementary p/ stems pt-BR (ADR-022). XSS hardening via Range/splitText (zero innerHTML) — testes provam. - ResultCard: thumb-left 120x80 fixo (ADR-030-UI), width/height attrs no <img> p/ anti-CLS, placeholder por letra inicial editoria sem cover, <time datetime> ISO + texto pt-BR via formatDateShort, link no titulo envolvendo HighlightedText. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lback [T30.1.X9] [DESIGN-v3] - FilterChips shell URL-driven (radius-md, ADR-030-UI). Sprint 5 plugara popovers; infra visual + a11y (aria-pressed, <button> labeled) pronta. - EmptyState / EmptyResults / RateLimitedState com countdown reativo + retry gated (botao disabled ate zerar). - SearchErrorFallback role="alert" — resilient subtree (ADR-030-FE) plugado na pagina via react-error-boundary. - BuscarSkeleton + ResultsSkeleton com shimmer respeitando reduced-motion. - SearchStates.css unifica visual dos 4 estados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…30.1.13, T30.3.1-4] [DESIGN-v3] - Route /buscar lazy em AppRouter (Suspense + BuscarSkeleton) - Buscar.tsx main page: <form role="search"> + FilterChips shell + ErrorBoundary(SearchResults) resilient sub-tree (ADR-030-FE) - SearchResults.tsx: branch 5 estados (Empty / Loading / Results / NoResults / RateLimited) consumindo useSearch - SearchErrorFallback: tipo FallbackProps direto da lib + narrow unknown (compat react-error-boundary v6) - aria-live: polite para count/results, assertive para erros Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
….1.X9] [DESIGN-v3] - Buscar.test.tsx: render shell, EmptyState quando sem ?q, pre-pop com ?q, zero role="combobox" (ADR-028), FilterChips shell vazia visivel - SearchResults.test.tsx: 5 estados (empty/loading/results/no-results/ rate-limited), matcher textContent normalizado para HighlightedText - a11y.test.tsx: axe-core sem violacao em 4 estados x light/dark - Matchers especificos: distinguir prompt do EmptyState do prompt do aria-describedby do SearchInput Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…4 HIGHs) Verediito gsd-code-reviewer: - BLOQUEIO-1: src/mocks/handlers/ vazio (msw em devDeps, prometido + ausente) - BLOQUEIO-2: commit f0b3f34 alega [a11y axe-core] mas zero imports - H-01: FilterChips renderiza category=foo (validacao raw) - H-02: staleTime/gcTime duplicados (main.tsx + useSearch.ts) - H-03: mark.js race em re-renders rapidos (cleanup ausente) - H-04: ResultCard category usa --clr-primary em vez de --clr-cat-* 10 itens validados positivamente (Bug 6 fix testado, ErrorBoundary scope correto, XSS hardening real, tokens herdados sem fork, bundle 14.5 KB gz). Recomendacao: NAO abrir PR US30.1 ate fixar ou descopar explicitamente. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…leanup + category guard) H-02 (T30.1.X14): SEARCH_STALE_TIME + SEARCH_GC_TIME em searchService.ts como SSOT. main.tsx (QueryClient default) e useSearch.ts (override do hook) importam — sem drift quando alguem alterar so um lado. H-03 (T30.1.X16): HighlightedText cleanup return () => instance.unmark(). unmark e async (done callback); em re-renders rapidos (paginacao infinita) o effect anterior pode marcar enquanto o novo ja comecou -> <mark><mark>kpop</mark></mark> aninhado. Cleanup garante DOM limpo. H-01 (T30.1.X15): FilterChips valida Number.isFinite + Number.isInteger em category antes de renderizar chip. Sem isto, /buscar?category=foo renderiza "Editoria: foo" (nao-XSS pelo escape do React, mas UX-bug latente). Espelha guard de useSearchParamsState.ts:32-37. 64/64 tests passam. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…* [T30.1.X17] Move data-variant da .result-card__thumb para o <article.result-card> wrapper. Permite CSS aplicar token editorial (--clr-cat-musica/moda/ cinema/literatura/cultura-digital) em AMBOS: badge .result-card__category e o placeholder do thumb (quando sem cover_url). Default cai em --clr-primary (navy) quando categoria desconhecida. Tokens validados WCAG AA em global.css:66-75. Sem fork de paleta (ADR-029): apenas consumir tokens existentes. +2 tests confirmando data-variant=slug e data-variant=default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ton [T30.1.X13][ADR-045] Fecha o BLOQUEIO-2 do REVIEW-PHASE-3 (commit f0b3f34 alegava [a11y axe-core] mas zero imports). Suite cobre 5 estados + componentes interativos + integracao da pagina Buscar. Bug a11y real encontrado e corrigido na 1a execucao: - ResultsSkeleton usava <ul role="status"> que sobrescreve role="list" implicito do <ul>, deixando <li> filhos sem ancestral de lista. - Fix: landmark live region em <div> wrapper; <ul> mantem semantica de lista intacta. aria-hidden no <ul> (conteudo decorativo enquanto o live region anuncia "Carregando resultados"). Setup tecnico: - vitest-axe@0.1.0 NAO auto-extende expect (extend-expect.d.ts so declara tipo). Runtime: expect.extend({toHaveNoViolations}) manual. - Vitest 4 usa modulo 'vitest' (nao namespace Vi do extend-expect). Augmentation declarada inline (T=any para casar @vitest/expect). 78/78 tests passam (64 anteriores + 12 axe + 2 ResultCard data-variant). tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…LOQUEIO-1] Fecha BLOQUEIO-1 do REVIEW-PHASE-3 (src/mocks/handlers/ vazio, msw em devDeps prometido + ausente). Smoke local em /buscar agora funciona sem backend Django ativo. Componentes: - src/mocks/handlers/search.ts: handler para GET /api/v1/search/articles/ com 3 cenarios controlados pela q (kpop -> 10 hits; qzxzqzx -> 0 hits; flood -> 429 retry_after 23). Latencia 300ms casa p50 do DESIGN §0. Headers Cache-Control + Vary + X-Cache fidelity ao contract Fase 2. - src/mocks/handlers/index.ts: re-export central. - src/mocks/browser.ts: setupWorker(...handlers). - src/main.tsx: enableMocks() async com guard DEV-only + dynamic import. `?msw=off` na URL desliga para apontar Django real. - public/mockServiceWorker.js: gerado via `npx msw init public/ --save`. - src/pages/Buscar/README.md: documenta cenarios e workflow. PROD: msw NAO entra no bundle (dynamic import + import.meta.env.DEV guard => tree-shake). Confirmado por `npm run build` (sem chunk mocks-* em dist/). Bundle Buscar mantem 14.54 KB gz dentro do gate ADR-031-FE. 78/78 tests pass. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nual achou bug) Smoke manual em /buscar?q=flood retornou 503 do Django real ao inves do 429 do MSW. Causa: handler usava path relativo `/api/v1/search/articles/` que MSW v2 trata como same-origin only. Como axios usa baseURL=http://localhost:8000, os requests sao cross-origin de :5173 e nao batiam no handler. Fix: padrao `*/api/v1/search/articles/` matcha qualquer origem. Indepedente de VITE_API_URL apontar para localhost, staging, ou prod. 78/78 tests passam. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refleo na spec o que efetivamente foi entregue ao codigo: BACKLOG.md (Apendice v5): - T30.1.X12 (MSW handlers) done em ffa5150 + 2bdf681 - T30.1.X13 (axe-core a11y.test.tsx) done em cbb9001 - T30.1.X14 (DRY SEARCH_STALE_TIME) done em 25bb5f9 - T30.1.X15 (FilterChips category guard) done em 25bb5f9 - T30.1.X16 (HighlightedText cleanup) done em 25bb5f9 - T30.1.X17 (ResultCard data-variant tokens) done em d45478f - T30.1.X25 NOVA (bug a11y real Skeleton landmark, encontrado pelo axe) - T30.1.X26 NOVA (MSW cross-origin pattern, encontrado no smoke browser) Gate hard do PR US30.1 ago r a 8/13 verde, 0 BLOQUEIOs: - cov pages/Buscar 84.15% (gate 80%) - bundle 14.54 KB gz (gate +20 KB) - 78/78 tests - 12 invariantes algorithms cobertos tracker.md: - Status implementacao 22/35 ADRs done, 13 em backlog Sprint 5 - Historico cronologico Fase 1 + Fase 2 + TX-18 + Fase 3 + 6 fixes - Mapping ADR -> evidencia de codigo Tasks restantes em backlog Sprint 5: T30.1.X18-X24, F-31, F-32. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [REVIEW-PHASE-2] production.py faz raise ImproperlyConfigured quando SEARCH_CURSOR_HMAC_SECRET esta vazia OU igual a SECRET_KEY. Em base.py o fallback para SECRET_KEY e conveniencia de dev; em prod e divida de seguranca (leak de SECRET_KEY permite forjar cursor e bypassar cap de 50 paginas — A3 do specialist algorithms). Mensagem de erro cita F2-B-03 e instrucao de geracao da secret via secrets.token_urlsafe(48) — runbook trivial. 3 testes em subprocess isolado (django.setup() ja roda na sessao pytest atual; importlib.reload corromperia apps): - HMAC == SECRET_KEY -> rc=2 + mensagem cita SEARCH_CURSOR_HMAC_SECRET + F2-B-03 - HMAC vazia -> rc=2 (cai no default=SECRET_KEY) - HMAC distinta -> rc=0 (load sucesso) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…HASE-2] views.py separa _CACHE_CONTROL_PUBLIC vs _CACHE_CONTROL_PRIVATE. _apply_cache_headers agora recebe auth_tier explicito e escolhe: - anon → public, max-age=60, stale-while-revalidate=300 (CDN compartilha) - user → private, max-age=60 (CDN nao cacheia; browser local sim) Por que: hoje a response e function-pure de (q, filters, cursor) e o Vary: Authorization isola por header. Mas se Interpop usa cookie httpOnly (CLAUDE.md §4) o header Authorization nem e enviado e CDN poderia merge anon+user se a response virasse non-pure (ex.: campo bookmarked) no futuro. private no autenticado e defesa em profundidade. stale-while-revalidate dropped no private — CDN nao revalida private, sem ganho. Vary continua presente nos dois (segunda barreira). 2 testes (anon=public, user=private + sem SWR), regressao 17/17 OK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…HASE-2] Causa raiz: cada `with connection.cursor()` em autocommit abre uma TX implicita propria. `SET LOCAL statement_timeout = '500ms'` aplicado em _apply_statement_timeout vivia so durante AQUELE cursor. O main query em outro cursor abria nova TX e perdia o cap — Invariante #12 do algorithms specialist quebrada em runtime. Fix: `@transaction.atomic` no _query_postgres engloba TODOS os cursores aninhados (statement_timeout, main query, _expand_stems, _explain_estimate). SET LOCAL aplica ao TX inteiro do init ao commit. Docstring de _apply_statement_timeout agora estabelece o CONTRATO: deve ser chamado de dentro de TX explicita; documenta o bug F2-B-01 para evitar regressao por refactor futuro. 2 testes Postgres-only (skip em SQLite-dev): - evidencia positiva: SET LOCAL persiste cross-cursor dentro de transaction.atomic — Inv #12 honrada. - evidencia negativa: SET LOCAL morre fora da TX (reproducao do bug), documentando o motivo do fix. Se autocommit do Django mudar, este test sinaliza para revisitar. Suite global: 325 passed, 27 skipped (Postgres-only), 0 regressao. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trazendo o commit 38ce337 (chore(deps): estende ignore de django para minor, PR #30) que vivia em main mas faltava em develop. Sem isso, futuras feature branches saindo de develop nascem 1 commit atras do main em config dependabot. PR #37 ja estava CLEAN/MERGEABLE; merge eh housekeeping, nao destrava. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resumo
PR consolida 60 commits acumulados em
develop— núcleo é a feature busca editorial full-text (US30.1, 32 commits) mais releases de features anteriores que estavam aguardando merge paramain.US30.1 — busca editorial full-text
apps.search): FTS pt-BR viaCONFIGURATION pt_unaccent,ts_rank_cd+ recency boost half-life 60d, CTE candidate-narrowingLIMIT 500, cursor HMAC com base64 + cap depth 50, trigger SQLENABLE ALWAYS(anti-bypasssession_replication_role), throttle DRF 3 camadas (anon 30/min + user 60/min + global 500/min)./buscar): React 19 + TanStack Query +useDebouncedValue250ms +useDeferredValue, ErrorBoundary resilient sub-tree,<input type="search">semântico (rejeita combobox APG-violator), mark.js client-side comquery_terms_expandeddo server, ResultCard thumb-left 120×80 anti-CLS, tokens editoriais--clr-cat-*por categoria.?q=kpop→ 142 hits,?q=qzxzqzx→ 0 hits,?q=flood→ 429). Tree-shake validado: 0 refs em prod bundle.Spec bundle congelado
docs/specs/busca-editorial/: DESIGN.md v3, BACKLOG.md v6, 35 ADRs (015-045), 4 specialist outputs literais, SECURITY-REVIEW.md, TEST-STRATEGY.md, REVIEW-PHASE-1/2/3.mdReleases acumuladas em
develop(não-busca)feat(moderation): dev como superadmin (único que bane/desbane admins)feat(users): management commandseed_team_usersfix(ui): checklist de senha + paleta sóbriafix(a11y): h2 no destaque da home + dl no centro do donut de editoriaschore: gitignore entregáveis locaisMétricas
apps.searchpages/Buscar(Lines)npm audit --omit=devReviews aplicados
3 code reviews (gsd-code-reviewer) por fase, todos os achados ≥🟠 corrigidos:
ENABLE ALWAYStrigger (T30.1.5d)14649d7/2362305/96cdad525bb5f9/d45478f/cbb9001/ffa5150/2bdf681Bug a11y real corrigido (encontrado pelo axe na 1ª execução):
<ul role="status">no Skeleton quebravarole="list"implícito dos<li>filhos — landmark migrado para<div>wrapper.NFRs
k6Zipfiano T30.1.X22)docs/performance/. Mobile prod já viola (3.1s) sem busca — flag pré-existente documentadoSprint 5 (descopado explicitamente)
Total Sprint 5 backlog: ~15 Tasks rastreáveis, todas com ID.
Test plan (para revisão manual)
cd backend && uv sync --frozen && uv run pytest— 325 + 27 skipped, zero regressãonpm install && npm run build— sucesso, bundle Buscar lazy ≤ 16 KB gznpm run dev→ http://localhost:5173/buscar — smoke MSW:?q=kpop(results),?q=qzxzqzx(no results),?q=flood(429 countdown)<input type="search">, zerorole="combobox"SEARCH_FEATURE_ENABLED=False→ endpoint retorna 503 +Retry-After: 60SEARCH_CURSOR_HMAC_SECRET(distinta deSECRET_KEY) eSEARCH_FEATURE_ENABLED=Truepara cutoverNotas
mainestá 1 commit à frente (PR release: estende ignore django para minor (LTS = sprint-level) #30 dependabot config). Nenhum conflito esperado — apenas.github/dependabot.yml. GitHub merge resolve.c017e1fa HEAD); 28 commits anteriores acumulados (moderation + users + UI fixes).🤖 Generated with Claude Code