From b5c4e274a213a375b4b5ae8669cc2d9dc7809a22 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 24 Sep 2026 22:28:09 -0300 Subject: [PATCH] fix(C220-FOLLOWUP): early-stop da janela de link do Instagram na Central MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O resolver pedia a janela de 500 (ate 10 paginas de cursor) antes de casar o shortcode; erro em pagina >=2 virava 'indisponivel' mesmo com o post na pagina 1. O dono da paginacao ganha shouldStopAt (board/sync intocados) e o resolver para na pagina que contem o post — a colagem tipica volta a custar uma chamada. --- ...026-09-24-instagram-link-sem-early-stop.md | 1 + ...026-09-24-instagram-link-sem-early-stop.md | 80 +++++++++++++++++++ src/utilities/content/contentPieceLink.ts | 22 ++++- src/utilities/socialFeed/instagramFeed.ts | 24 ++++-- tests/int/contentPiece.int.spec.ts | 68 ++++++++++++++++ tests/unit/instagramFeed.unit.spec.ts | 56 +++++++++++++ 6 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 docs/changelog/2026-09-24-instagram-link-sem-early-stop.md create mode 100644 docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md diff --git a/docs/changelog/2026-09-24-instagram-link-sem-early-stop.md b/docs/changelog/2026-09-24-instagram-link-sem-early-stop.md new file mode 100644 index 00000000..afea3cd6 --- /dev/null +++ b/docs/changelog/2026-09-24-instagram-link-sem-early-stop.md @@ -0,0 +1 @@ +**Instagram por link na Central volta a baixar e catalogar — early-stop da janela de 500 (2026-09-24):** o C220 prometia "janela de 500 mídias com early-stop; a chamada típica é 1", mas a implementação pedia as até 10 páginas de cursor ANTES de casar o shortcode: qualquer erro em página ≥2 (a doc oficial da edge `/{ig-user-id}/media` documenta paginação time-based) lançava e o resolver devolvia `indisponivel`, mesmo com o post recém-publicado já na página 1 — a peça-link voltava sem mídia/transcrição. O dono da paginação (`src/utilities/socialFeed/instagramFeed.ts`) ganha o opcional `shouldStopAt?: (post) => boolean` (checado após cada página; board/sync não passam — contrato de 1 página e tudo-ou-nada intocados) e o resolver (`src/utilities/content/contentPieceLink.ts`) passa `matchesInstagramShortcode(link.shortcode)`, reusado no match final: a colagem típica volta a custar UMA chamada de mídia e falha profunda nunca mais derruba um match de página 1. Sem migration/access/UI. Verificação: int de regressão com o `loadInstagramFeed` REAL e `fetchImpl` fake (`after=` → 400) — RED provado sem o fix (`indisponivel`) e verde com; unit do dono (para no match, continua quando o alvo está mais fundo); int contentPiece 38/38; `pnpm gate:fast` verde (unit full 4669). Post-mortem: `docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md`. diff --git a/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md b/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md new file mode 100644 index 00000000..62c48859 --- /dev/null +++ b/docs/postmortems/2026-09-24-instagram-link-sem-early-stop.md @@ -0,0 +1,80 @@ +# Post-mortem: Link do Instagram na Central não baixava a peça — janela de 500 sem early-stop + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Data do post-mortem | 2026-09-24 | +| Severidade | alta (fluxo editorial de produção prometido não funcionava; sem perda de dados) | +| Ambiente | prod (sintoma) + dev/worktree (diagnóstico e fix) | +| Issue(s) | sem Issue — o item anterior, C220 (#1302), já estava fechado/in-prod; o residual não gerou Issue (o post-mortem é o registro) | +| PR do fix | a preencher | +| Detectado por | humano (relato do usuário na sessão `/bug-fix` do worktree `fix/10`, 2026-09-24 ~22:09 BRT) | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Início provável | 2026-09-24 ~03:19 BRT | Commit `8f311831` "fix(C220): link do proprio Instagram baixa e cataloga a peca" (PR #1314) introduz a janela de 500 sem early-stop | +| C220 em produção | 2026-09-24 12:56–13:07 UTC | Deploy run 35966535233 (SHA `8f311831`), job "deploy production" | +| Detecção | 2026-09-24 ~22:09 BRT | Relato do humano na sessão `/bug-fix` | +| Correção mergeada | pendente | Em andamento neste PR (a preencher número) | +| Deploy | pendente | Merge em `main` dispara deploy; staging automático; produção só com approve humano no environment `production` (não feito ainda) | +| Verificado em prod | pendente | Aguardando confirmação do humano (nunca declarar corrigido em prod antes disso) | + +## O bug + +Na Central de Conteúdos (`/campanha/comunicacao/conteudos`), colar o link de uma publicação do próprio Instagram do deputado (@depjorgesolla) não baixava a mídia nem transcrevia/catalogava automaticamente: a peça ficava como peça-link. Afetava a assessoria de comunicação no fluxo editorial que o C220 prometia entregar. + +## Causa-raiz + +O design aprovado do C220 (`docs/plans/central-conteudos-link-instagram-impl.md`, D2 e §Riscos) prometia "janela de 500 mídias … early-stop no match; a chamada típica é 1" e que uma paginação time-based degradaria para a 1ª página sem quebra. A implementação não honrou o early-stop: + +1. O resolver chamava `loadInstagramFeed` com `maxResults=500` (`src/utilities/content/contentPieceLink.ts:178` pré-fix). +2. O match do shortcode só acontecia DEPOIS de andar a janela inteira — até 10 páginas via `paging.cursors.after` (`src/utilities/socialFeed/instagramFeed.ts:263-281`). +3. O contrato era tudo-ou-nada: erro em página ≥2 lançava (`instagramFeed.ts:273-275`) e o resolver capturava QUALQUER erro do feed como `indisponivel` (`contentPieceLink.ts:182-186`), descartando a página 1 já recebida. + +Resultado: o caso típico (post recém-publicado, na página 1) ficava refém de páginas profundas. A doc oficial da edge `/{ig-user-id}/media` no host Instagram Login documenta paginação TIME-BASED (`since`/`until`), e o próprio C212 registrou a paginação por cursor como "a confirmar na implementação". + +**Evidência de reprodução:** script com o `loadInstagramFeed` real e `fetchImpl` fake (página 1 com o alvo + `paging.cursors.after`; `after=` → HTTP 400); pré-fix lança `InstagramApiError` e o resolver devolve `linkFailureReason='indisponivel'` mesmo com o post na página 1. Coberto pelo teste int novo (`tests/int/contentPiece.int.spec.ts`, "stops at page 1 when the pasted post is there, even if deeper cursors fail") — vermelho sem o fix (`expected 'indisponivel' to be null`), verde com. + +**Por que os testes não pegaram:** o unit do feed usava cursor fabricado; os testes int do resolver injetavam `loadFeed` fake (nunca o dono real); o e2e do motivo gravava `linkFailureReason` direto no banco e não esperava o job. Nenhum teste exercia o `loadInstagramFeed` real através do resolver. + +**Observação honesta:** a API real com a credencial de produção não foi acessada (proibido); a forma exata da paginação da edge segue **não apurada**. A hipótese H2 (API omitir `media_url` em Reels com áudio licenciado, documentada no C212) permanece **não apurada** e pode afetar reels específicos — nesse caso o motivo honesto `indisponivel` continua correto, mas não há via oficial de baixar o arquivo. + +## Correção + +`src/utilities/socialFeed/instagramFeed.ts` ganhou o opcional `shouldStopAt?: (post) => boolean` em `LoadInstagramFeedArgs`, checado após cada página; board/sync não passam a opção, então o contrato de 1 página e o tudo-ou-nada ficam intocados. `src/utilities/content/contentPieceLink.ts` passa `shouldStopAt: matchesInstagramShortcode(link.shortcode)` (novo helper, reusado no match final). + +Efeito: a colagem típica volta a custar UMA chamada de mídia, e uma falha em página profunda nunca mais transforma um match de página 1 em `indisponivel`. Sem migration, sem mudança de access/UI. + +## Verificação + +- Teste de regressão: `tests/int/contentPiece.int.spec.ts` — "stops at page 1 when the pasted post is there, even if deeper cursors fail" — falha sem o fix com `indisponivel`, passa com +- Unit novo: `tests/unit/instagramFeed.unit.spec.ts` — "stops at the page that holds the searched post…" e "keeps walking when the searched post is deeper" — 23/23 +- Suíte: int contentPiece 38/38; `pnpm gate:fast` verde (lint/typecheck/unit full 4669; houve 1 flake de `contentPiecePeopleField.unit.spec.tsx` sob carga, que passou 2x em isolamento) +- CI: pendente no PR +- Prod: pendente da confirmação do humano + +## Prevenção + +| Estratégia | Custo | Estado | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | +| Teste de regressão no nível certo (int do resolver com o `loadInstagramFeed` REAL + `fetchImpl` fake, pinando 1 chamada e zero `after=`) | barata | implementada agora (mesmo PR) | +| Unit do dono: para no match e continua quando o alvo está mais fundo | barata | implementada agora (mesmo PR) | +| Best-effort no modo lookup (página ≥2 falha → devolver o já coletado em vez de `indisponivel`) | cara | documentada — distorce o vocabulário (`nao-encontrado` falso para post fundo); só com evidência de colagens antigas | +| Verificação viva/canário com credencial real (staging/prod) + runbook do token | cara | documentada — única verificação não-cega da forma da paginação na edge | +| Pesquisar/decidir o contrato time-based da edge (`since`/`until` × cursor `after` × `paging.next` sem vazar token) | cara | documentada — candidata a Issue | + +**Estratégia implementada:** os três testes acima (unit do dono + int do resolver com o feed real). + +**Estratégia documentada (cara):** as três acima. + +## Lições + +- Promessa de plano sem teste executável vira comentário: "early-stop em 1 chamada" era contrato do design e não tinha prova. +- O seam de injeção de dependência pode virar blindagem: testar o dublê e nunca o dono real esconde regressões no caminho de produção. +- "A confirmar na implementação" precisa de verificação viva antes de vender o comportamento como entregue. +- Degradação honesta prometida no plano precisa de teste no modo real de falha (página ≥2), não só no caminho feliz. diff --git a/src/utilities/content/contentPieceLink.ts b/src/utilities/content/contentPieceLink.ts index 7e7e1426..14d77fa0 100644 --- a/src/utilities/content/contentPieceLink.ts +++ b/src/utilities/content/contentPieceLink.ts @@ -65,6 +65,19 @@ type FeedLoader = typeof loadInstagramFeed */ export const CONTENT_PIECE_LINK_INSTAGRAM_WINDOW = 500 +/** + * The identity of a Graph API post for the pasted link: the shortcode (the + * kind is presentation, so a `/p/ABC/` paste matches a reel's permalink too). + * The same predicate drives the pagination early stop and the final match, so + * the walk never continues past the answer. + */ +const matchesInstagramShortcode = + (shortcode: string) => + (post: { permalink: string }): boolean => { + const parsed = parseContentPieceLink(post.permalink) + return parsed?.origin === 'instagram' && parsed.shortcode === shortcode + } + /** A hung Graph API must not hold the piece `processando` until the 1 h reaper. */ const CONTENT_PIECE_LINK_FEED_TIMEOUT_MS = 30_000 @@ -176,6 +189,10 @@ export const resolveContentPieceSource = async ({ accessToken: settings.instagramAccessToken as string, userId: settings.instagramUserId as string, maxResults: CONTENT_PIECE_LINK_INSTAGRAM_WINDOW, + // Early stop: the page that carries the pasted post ends the walk, so the + // typical paste costs ONE call and a failing deeper cursor can never turn + // a page-1 match into `indisponivel`. + shouldStopAt: matchesInstagramShortcode(link.shortcode), fetchImpl, signal: AbortSignal.timeout(CONTENT_PIECE_LINK_FEED_TIMEOUT_MS), }) @@ -194,10 +211,7 @@ export const resolveContentPieceSource = async ({ // The shortcode is the identity; the kind (`p` × `reel`) is presentation, so // a `/p/ABC/` paste matches a reel's permalink too. Matching inside the OWN // profile feed is also what guarantees a third-party link never downloads. - const matched = feed.posts.find((post) => { - const parsed = parseContentPieceLink(post.permalink) - return parsed?.origin === 'instagram' && parsed.shortcode === link.shortcode - }) + const matched = feed.posts.find(matchesInstagramShortcode(link.shortcode)) if (!matched) { return linkOnly('nao-encontrado') } diff --git a/src/utilities/socialFeed/instagramFeed.ts b/src/utilities/socialFeed/instagramFeed.ts index 74cdcbc4..ad926ca8 100644 --- a/src/utilities/socialFeed/instagramFeed.ts +++ b/src/utilities/socialFeed/instagramFeed.ts @@ -32,6 +32,15 @@ export type LoadInstagramFeedArgs = { accessToken: string userId: string maxResults: number + /** + * Lookup opt-in (C220): stop walking cursor pages as soon as this returns + * true for a post of the page just fetched. The caller still matches the post + * it wants in the returned feed — this only bounds the walk, so the typical + * pasted-link search costs ONE call and never depends on deeper pages. The + * board/sync omit it: their all-or-nothing contract and one-page default stay + * untouched. + */ + shouldStopAt?: (post: InstagramPost) => boolean fetchImpl?: (input: string, init?: RequestInit) => Promise baseUrl?: string /** Deadline for the Graph API calls (hook-triggered sync must not hang a save). */ @@ -234,16 +243,18 @@ const jsonFrom = async (response: Response): Promise => response.json() * of posts: up to `INSTAGRAM_MAX_RESULTS_CAP` per page, walking the * `paging.cursors.after` cursor while the window is not filled — so a caller * asking for 50 makes exactly one call (the board's contract is untouched) and - * a deeper window is an explicit opt-in. On any failure it attempts one token - * refresh (`refresh_access_token` — only mints/refreshes Instagram Login - * tokens; page tokens from Facebook Login error out and the caller falls back - * to the snapshot) and retries once. Throws when the retry also fails so the - * cached wrapper can fail closed. + * a deeper window is an explicit opt-in. The lookup opt-in (`shouldStopAt`) + * bounds the walk at the page that carries the wanted post. On any failure it + * attempts one token refresh (`refresh_access_token` — only mints/refreshes + * Instagram Login tokens; page tokens from Facebook Login error out and the + * caller falls back to the snapshot) and retries once. Throws when the retry + * also fails so the cached wrapper can fail closed. */ export const loadInstagramFeed = async ({ accessToken, userId, maxResults, + shouldStopAt, fetchImpl = fetch, baseUrl = INSTAGRAM_API_BASE_URL, signal, @@ -277,6 +288,9 @@ export const loadInstagramFeed = async ({ const parsed = parseInstagramMediaPage(await jsonFrom(mediaResponse)) posts.push(...parsed.posts) if (posts.length >= maxResults || !parsed.nextCursor) break + // Lookup mode: the wanted post already arrived; deeper pages are never + // requested (a failing cursor past the answer cannot poison the result). + if (shouldStopAt && parsed.posts.some(shouldStopAt)) break after = parsed.nextCursor } diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index a55e3501..65db643c 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -981,6 +981,74 @@ describe('content pieces (C211)', () => { expect(row.media).toBe(resolution.media?.id) }) + it('stops at page 1 when the pasted post is there, even if deeper cursors fail', async () => { + await setInstagramSettings(true) + const { piece } = await createPiece({ + title: 'Reel recém-publicado', + origin: 'instagram', + withMedia: false, + sourceUrl: 'https://www.instagram.com/reel/RECENTE1/', + }) + const calls: string[] = [] + + const resolution = await withTempDir((tempDir) => + resolveContentPieceSource({ + payload, + piece: pieceInput(piece), + tempDir, + // No injected `loadFeed`: the real pagination owner is under test. + fetchImpl: async (input) => { + calls.push(input) + if (input.includes('cdn.example')) { + return new Response(Buffer.from('reel-bytes'), { status: 200 }) + } + if (input.includes('/media')) { + // The deeper cursor is broken on this edge (C212); the page-1 + // match must never depend on asking it. + if (input.includes('after=')) { + return new Response( + JSON.stringify({ error: { message: 'cursor não suportado' } }), + { + status: 400, + }, + ) + } + return new Response( + JSON.stringify({ + data: [ + { + id: 'media-1', + caption: 'Legenda oficial', + media_type: 'REEL', + media_url: 'https://cdn.example/reel.mp4', + permalink: 'https://www.instagram.com/reel/RECENTE1/', + timestamp: '2026-09-01T10:00:00+00:00', + }, + ], + paging: { cursors: { after: 'CURSOR-1' } }, + }), + { status: 200 }, + ) + } + if (input.includes('/refresh_access_token')) { + return new Response(JSON.stringify({ access_token: 'refreshed-token' }), { + status: 200, + }) + } + return new Response(JSON.stringify({ username: 'depjorgesolla' }), { status: 200 }) + }, + }), + ) + + expect(resolution.linkFailureReason).toBeNull() + expect(resolution.media).toBeTruthy() + if (resolution.media) createdMediaIds.add(resolution.media.id) + // The typical paste costs one media call; the broken deeper cursor is + // never requested (a page-1 match cannot become `indisponivel`). + expect(calls.filter((call) => call.includes('/media'))).toHaveLength(1) + expect(calls.some((call) => call.includes('after='))).toBe(false) + }) + it('reports nao-encontrado when the post is not in the window', async () => { await setInstagramSettings(true) const { piece } = await createPiece({ diff --git a/tests/unit/instagramFeed.unit.spec.ts b/tests/unit/instagramFeed.unit.spec.ts index 9d1b65a0..c36ab579 100644 --- a/tests/unit/instagramFeed.unit.spec.ts +++ b/tests/unit/instagramFeed.unit.spec.ts @@ -339,6 +339,62 @@ describe('loadInstagramFeed', () => { expect(mediaCalls[1]).toContain('limit=3') }) + it('stops at the page that holds the searched post and never asks a deeper cursor', async () => { + calls.length = 0 + const result = await loadInstagramFeed({ + ...args, + maxResults: 500, + shouldStopAt: (post) => post.id === 'post2', + fetchImpl: async (input) => { + calls.push(input) + if (input.includes('/media')) { + // A deeper page exists and would fail loudly: the lookup must never ask. + if (input.includes('after=')) throw new Error('deeper page must not be requested') + return fakeResponse({ + data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')], + paging: { cursors: { after: 'cursor-1' } }, + }) + } + return fakeResponse({ username: 'depjorgesolla' }) + }, + }) + + expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2']) + const mediaCalls = calls.filter((call) => call.includes('/media')) + expect(mediaCalls).toHaveLength(1) + expect(calls.some((call) => call.includes('after='))).toBe(false) + }) + + it('keeps walking when the searched post is deeper in the window', async () => { + calls.length = 0 + const result = await loadInstagramFeed({ + ...args, + maxResults: 500, + shouldStopAt: (post) => post.id === 'post3', + fetchImpl: async (input) => { + calls.push(input) + if (input.includes('/media')) { + if (input.includes('after=cursor-1')) { + return fakeResponse({ + data: [MEDIA_ITEM('post3')], + paging: { cursors: { after: 'cursor-2' } }, + }) + } + return fakeResponse({ + data: [MEDIA_ITEM('post1'), MEDIA_ITEM('post2')], + paging: { cursors: { after: 'cursor-1' } }, + }) + } + return fakeResponse({ username: 'depjorgesolla' }) + }, + }) + + expect(result.posts.map((post) => post.id)).toEqual(['post1', 'post2', 'post3']) + const mediaCalls = calls.filter((call) => call.includes('/media')) + expect(mediaCalls).toHaveLength(2) + expect(calls.some((call) => call.includes('after=cursor-2'))).toBe(false) + }) + it('refreshes once and refetches the whole window when a later page fails', async () => { calls.length = 0 let mediaCalls = 0