fix(C220-FOLLOWUP): early-stop da janela de link do Instagram na Central

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.
This commit is contained in:
Francisco Solla 2026-09-24 22:28:09 -03:00
parent 767f86c455
commit b5c4e274a2
6 changed files with 242 additions and 9 deletions

View file

@ -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`.

View file

@ -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.

View file

@ -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')
}

View file

@ -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<Response>
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<unknown> => 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
}

View file

@ -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({

View file

@ -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