teqo-backup/tests/unit/instagramContentPlan.unit.spec.ts
Francisco Solla 3d269725f8 feat(C230): expurgo de peças fora do período eleitoral na Central
O rascunho pré-campanha que o --publish-existing ressuscita precisa sair
do catálogo de vez: o novo CLI content:instagram:prune faz hard delete da
peça e da mídia privada (linha, arquivo e still do C226), então uma
importação futura não a recria. Compartilha o mesmo núcleo de corte da
retirada (data civil na Bahia, peça sem data no feed nunca é tocada) e
espelha as guardas dos irmãos: --before obrigatório, TEQO_ENV casando o
nome exato do banco, CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 e recibos JSON.

Testes: planejador/parser/recibo em unit e guardas do CLI em subprocesso.
Runbook e changelog atualizados.
2026-10-01 02:29:19 -03:00

454 lines
16 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
contentPieceProfileFeedIdentityUrls,
planContentPieceProfileWindow,
} from '@/lib/contentPieceProfileWindow'
import {
formatInstagramContentPruneReport,
formatInstagramContentReport,
formatInstagramContentWithdrawReport,
instagramContentFeedIdentityUrls,
instagramContentPostDates,
instagramContentReportStamp,
parseInstagramContentCliArgs,
parseInstagramContentPruneCliArgs,
parseInstagramContentWithdrawCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentPrune,
planInstagramContentWindow,
planInstagramContentWithdraw,
shouldPublishImportedPiece,
summarizeInstagramContentResults,
} from '../../scripts/lib/instagramContentPlan.mjs'
// C230-followup — the import planner contract: the recency window filter, the
// identity dedupe (in both directions: the feed against the Central and the
// feed against itself), the link-only classification and the honest report.
// The feed is always a plain array; no network, no Payload.
const post = (
shortcode: string,
{
mediaType = 'REEL',
mediaUrl = `https://cdn.example/${shortcode}.mp4`,
timestamp = '2026-09-20T12:00:00.000Z',
permalink,
}: {
mediaType?: string
mediaUrl?: string | null
timestamp?: string
permalink?: string
} = {},
) => ({
id: shortcode,
permalink: permalink ?? `https://www.instagram.com/reel/${shortcode}/`,
mediaType,
mediaUrl,
timestamp,
})
describe('content:instagram:import arguments', () => {
it('defaults to the 60-day plan with no writes', () => {
expect(parseInstagramContentCliArgs([])).toEqual({
apply: false,
publish: false,
publishExisting: false,
days: 60,
limit: null,
out: 'data/content-instagram',
help: false,
})
})
it('accepts --publish-existing only with --apply', () => {
expect(parseInstagramContentCliArgs(['--apply', '--publish-existing'])).toMatchObject({
apply: true,
publishExisting: true,
})
expect(() => parseInstagramContentCliArgs(['--publish-existing'])).toThrow(/exige --apply/)
})
it('accepts the apply/publish pair and the window options', () => {
expect(
parseInstagramContentCliArgs([
'--apply',
'--publish',
'--days',
'45',
'--limit',
'3',
'--out',
'data/out',
]),
).toMatchObject({ apply: true, publish: true, days: 45, limit: 3, out: 'data/out' })
})
it('refuses --publish without --apply', () => {
expect(() => parseInstagramContentCliArgs(['--publish'])).toThrow(/exige --apply/)
})
it('refuses an unknown argument', () => {
expect(() => parseInstagramContentCliArgs(['--force'])).toThrow(/argumento desconhecido/)
})
it('refuses an out-of-range window, a zero limit and an escaping out dir', () => {
expect(() => parseInstagramContentCliArgs(['--days', '0'])).toThrow(/--days/)
expect(() => parseInstagramContentCliArgs(['--days', '366'])).toThrow(/--days/)
expect(() => parseInstagramContentCliArgs(['--limit', '0'])).toThrow(/--limit/)
expect(() => parseInstagramContentCliArgs(['--out', '../etc'])).toThrow(/--out/)
})
it('prints the help before any validation', () => {
expect(parseInstagramContentCliArgs(['--help', '--days', '0'])).toMatchObject({ help: true })
})
})
describe('planner re-exports (C235)', () => {
it('keeps the CLI names bound to the single owner in src/lib', () => {
// The window policy is pinned by its owner spec; here only the delegation
// contract of the CLI shell matters.
expect(planInstagramContentWindow).toBe(planContentPieceProfileWindow)
expect(instagramContentFeedIdentityUrls).toBe(contentPieceProfileFeedIdentityUrls)
})
})
describe('window draft publication (C230-followup)', () => {
it('dates the feed by shortcode, keeping the earliest publication', () => {
const dates = instagramContentPostDates([
post('A', { timestamp: '2026-09-02T12:00:00.000Z' }),
post('A', {
timestamp: '2026-09-01T12:00:00.000Z',
permalink: 'https://www.instagram.com/p/A/',
}),
post('X', { permalink: 'https://example.com/x/' }),
])
expect(dates.get('A')).toBe(Date.parse('2026-09-01T12:00:00.000Z'))
expect(dates.size).toBe(1)
})
it('orders the ready drafts oldest post first and names the undateable', () => {
const planned = planInstagramContentDraftsToPublish({
pieces: [
{ id: 1, title: 'A', sourceUrl: 'https://www.instagram.com/reel/A/' },
{ id: 2, title: 'B', sourceUrl: 'https://www.instagram.com/p/B/' },
{
id: 3,
title: 'D',
sourceUrl: 'https://www.instagram.com/reel/D/',
linkFailureReason: 'carrossel',
},
{ id: 4, title: 'C', sourceUrl: 'https://www.instagram.com/reel/NAO-ESTA/' },
],
posts: [
post('A', { timestamp: '2026-09-01T12:00:00.000Z' }),
post('B', { timestamp: '2026-08-20T12:00:00.000Z' }),
post('D', { timestamp: '2026-08-25T12:00:00.000Z' }),
],
})
expect(planned.ordered.map((piece) => piece.id)).toEqual([2, 3, 1])
expect(planned.ordered.map((piece) => piece.shortcode)).toEqual(['B', 'D', 'A'])
expect(planned.undateable.map((piece) => piece.id)).toEqual([4])
expect(planned.linkOnly).toBe(1)
})
})
describe('pre-campaign withdrawal (C230-followup)', () => {
const publishedPosts = [
post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }),
post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }),
post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }),
]
it('splits by the Bahia civil cutoff, oldest withdrawn first', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/' },
{ id: 2, title: 'DROP-LATE', sourceUrl: 'https://www.instagram.com/p/DROP-LATE/' },
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/' },
{ id: 5, title: 'SEM-URL', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
// 16/08 00:00 na Bahia = 03:00Z: o que veio antes sai, inclusive a noite do dia 15.
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
expect(plan.toWithdraw.map((piece) => piece.id)).toEqual([3, 2])
expect(plan.toWithdraw.map((piece) => piece.postDate.slice(0, 10))).toEqual([
'2026-07-31',
'2026-08-16',
])
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
})
it('refuses an impossible civil date', () => {
expect(() =>
planInstagramContentWithdraw({ pieces: [], posts: [], before: '2026-02-30' }),
).toThrow(/data inicial válida/)
})
it('parses the withdraw arguments with a required cutoff', () => {
expect(parseInstagramContentWithdrawCliArgs(['--before', '2026-08-16'])).toEqual({
apply: false,
before: '2026-08-16',
scanDays: 90,
out: 'data/content-instagram',
help: false,
})
expect(
parseInstagramContentWithdrawCliArgs([
'--before',
'2026-08-16',
'--apply',
'--scan-days',
'60',
]),
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 60 })
expect(() => parseInstagramContentWithdrawCliArgs([])).toThrow(/--before é obrigatório/)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '16/08/2026'])).toThrow(
/YYYY-MM-DD/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '2026-02-30'])).toThrow(
/data civil válida/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--force'])).toThrow(
/argumento desconhecido/,
)
expect(parseInstagramContentWithdrawCliArgs(['--help'])).toMatchObject({ help: true })
})
it('reports the cutoff, the period and the undateable pieces', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
const lines = formatInstagramContentWithdrawReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
before: '2026-08-16',
scanDays: 90,
feedCount: 3,
publishedCount: 2,
plan,
withdrawn: 1,
failures: [],
revalidation: { attempted: true, ok: true, reason: null },
reportPath: 'data/content-instagram/x-withdraw.json',
})
const text = lines.join('\n')
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)')
expect(text).toContain('anteriores ao corte: 1 · sem data no feed: 1')
expect(text).toContain('DROP-EARLY (2026-07-31)')
expect(text).toContain('? sem data: #4 SEM-DATA')
expect(text).toContain('retiradas: 1 · falhas: 0 · revalidação: ok')
expect(text).toContain('recibo: data/content-instagram/x-withdraw.json')
})
})
describe('pre-campaign pruning (C230-followup)', () => {
const cataloguePosts = [
post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }),
post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }),
post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }),
]
const catalogue = [
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/', status: 'rascunho' },
{
id: 2,
title: 'DROP-LATE',
sourceUrl: 'https://www.instagram.com/p/DROP-LATE/',
status: 'publicado',
},
{
id: 3,
title: 'DROP-EARLY',
sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/',
status: 'rascunho',
},
{
id: 4,
title: 'SEM-DATA',
sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/',
status: 'rascunho',
},
{ id: 5, title: 'SEM-URL', sourceUrl: null, status: 'rascunho' },
]
it('matches every piece before the cutoff, drafts included, sharing the withdraw split', () => {
const plan = planInstagramContentPrune({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
const withdrawn = planInstagramContentWithdraw({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
// 16/08 00:00 na Bahia = 03:00Z: a noite do dia 15 e tudo antes sai; peça
// sem data no feed nunca entra no conjunto (fail-closed).
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
expect(plan.toDelete.map((piece) => piece.id)).toEqual([3, 2])
expect(plan.toDelete.map((piece) => piece.postDate.slice(0, 10))).toEqual([
'2026-07-31',
'2026-08-16',
])
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
expect(plan.toDelete.map((piece) => piece.id)).toEqual(
withdrawn.toWithdraw.map((piece) => piece.id),
)
})
it('parses the prune arguments with the deep default scan', () => {
expect(parseInstagramContentPruneCliArgs(['--before', '2026-08-16'])).toEqual({
apply: false,
before: '2026-08-16',
scanDays: 365,
out: 'data/content-instagram',
help: false,
})
expect(
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--apply', '--scan-days', '90']),
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 90 })
expect(() => parseInstagramContentPruneCliArgs([])).toThrow(/--before é obrigatório/)
expect(() => parseInstagramContentPruneCliArgs(['--before', '16/08/2026'])).toThrow(
/YYYY-MM-DD/,
)
expect(() => parseInstagramContentPruneCliArgs(['--before', '2026-02-30'])).toThrow(
/data civil válida/,
)
expect(() =>
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days']),
).toThrow(/faltou valor/)
expect(() =>
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days', '0']),
).toThrow(/--scan-days/)
expect(() => parseInstagramContentPruneCliArgs(['--force'])).toThrow(/argumento desconhecido/)
expect(parseInstagramContentPruneCliArgs(['--help'])).toMatchObject({ help: true })
})
it('reports the cutoff, the period, the catalogue size and the undateable rows', () => {
const plan = planInstagramContentPrune({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
const lines = formatInstagramContentPruneReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
before: '2026-08-16',
scanDays: 365,
feedCount: 3,
piecesCount: 5,
plan,
deleted: 2,
failures: [],
revalidation: { attempted: true, ok: true, reason: null },
reportPath: 'data/content-instagram/x-prune.json',
})
const text = lines.join('\n')
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)')
expect(text).toContain('no catálogo: 5 · anteriores ao corte: 2 · sem data no feed: 2')
expect(text).toContain('DROP-EARLY (2026-07-31)')
expect(text).toContain('? sem data: #4 SEM-DATA')
expect(text).toContain('apagadas: 2 · falhas: 0 · revalidação: ok')
expect(text).toContain('recibo: data/content-instagram/x-prune.json')
})
})
describe('summaries and receipt', () => {
it('publishes only a ready piece when the run asked for it', () => {
expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true)
expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: false })).toBe(false)
expect(shouldPublishImportedPiece({ processingStatus: 'falhou', publish: true })).toBe(false)
})
it('aggregates the run with the provider cost', () => {
const summary = summarizeInstagramContentResults(
[
{
outcome: 'created',
processingStatus: 'pronto',
durationSeconds: 60,
published: true,
linkFailureReason: null,
},
{
outcome: 'created',
processingStatus: 'pronto',
durationSeconds: 30,
published: true,
linkFailureReason: 'carrossel',
},
{ outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg' },
{ outcome: 'existing' },
],
{ costPerMinuteUsd: 0.00045 },
)
expect(summary).toMatchObject({
attempted: 4,
created: 3,
existing: 1,
ready: 2,
failed: 1,
published: 2,
linkOnly: 1,
asrSeconds: 90,
})
expect(summary.asrCostUsd).toBeCloseTo(0.0007, 4)
})
it('reports the window, the plan and the named failures', () => {
const plan = planInstagramContentWindow({
posts: [post('A'), post('B', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null })],
existingSourceUrls: [],
from: '2026-08-01T00:00:00.000Z',
to: '2026-09-30T00:00:00.000Z',
})
const lines = formatInstagramContentReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
window: { days: 60, from: '2026-08-01T00:00:00.000Z', to: '2026-09-30T00:00:00.000Z' },
feedCount: 2,
plan,
summary: summarizeInstagramContentResults([
{ outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg ausente' },
]),
publishedExisting: 3,
failures: [{ shortcode: 'A', error: 'ffmpeg ausente' }],
candidatesRemaining: 1,
reportPath: 'data/content-instagram/x.json',
})
const text = lines.join('\n')
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
expect(text).toContain('janela: 60 dias')
expect(text).toContain('candidatos: 2 (peça-link previstos: 1)')
expect(text).toContain('falha A: ffmpeg ausente')
expect(text).toContain('peça-link B: carrossel')
expect(text).toContain('publicadas da janela (já importadas, rascunhos prontos): 3')
expect(text).toContain('restam 1 candidatos')
expect(text).toContain('recibo: data/content-instagram/x.json')
})
it('stamps the receipt with the run instant', () => {
expect(instagramContentReportStamp('2026-09-29T12:34:56.789Z')).toBe('2026-09-29T12-34-56-789Z')
})
})