mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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.
454 lines
16 KiB
TypeScript
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')
|
|
})
|
|
})
|