teqo-backup/tests/unit/instagramContentPlan.unit.spec.ts
Francisco Solla 35eb2ed432 feat(C230): importação em lote do Instagram para a Central (CLI)
CLI operacional `content:instagram:import`: lê o feed oficial do perfil,
filtra a janela por dias (default 60), deduplica pela identidade do post e
processa cada novidade inline pelo pipeline C220 (download, transcrição,
catalogação); --publish publica ao final o que terminou pronto, mais antigo
primeiro, e revalida a Central pública. Guardas C155/C195/C231 (TEQO_ENV +
nome exato do banco, confirmação explícita, S3_* obrigatórias, dry-run padrão).
2026-09-29 09:51:46 -03:00

243 lines
8 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
formatInstagramContentReport,
instagramContentFeedIdentityUrls,
instagramContentReportStamp,
parseInstagramContentCliArgs,
planInstagramContentWindow,
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,
days: 60,
limit: null,
out: 'data/content-instagram',
help: false,
})
})
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('planInstagramContentWindow', () => {
const from = '2026-08-01T00:00:00.000Z'
const to = '2026-09-30T00:00:00.000Z'
it('keeps the window novelties, newest first, and classifies each link-only kind', () => {
const plan = planInstagramContentWindow({
posts: [
post('NOVO'),
post('CARROSSEL', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null }),
post('PROTEGIDO', { mediaUrl: null }),
],
existingSourceUrls: [],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(0)
expect(plan.outsideWindow).toBe(0)
expect(plan.malformed).toBe(0)
expect(plan.candidates).toEqual([
{
url: 'https://www.instagram.com/reel/NOVO/',
shortcode: 'NOVO',
linkOnlyReason: null,
timestamp: '2026-09-20T12:00:00.000Z',
mediaType: 'REEL',
},
expect.objectContaining({ shortcode: 'CARROSSEL', linkOnlyReason: 'carrossel' }),
expect.objectContaining({ shortcode: 'PROTEGIDO', linkOnlyReason: 'indisponivel' }),
])
})
it('drops what the Central already carries in ANY spelling and counts the rest', () => {
const plan = planInstagramContentWindow({
posts: [
post('JA', { permalink: 'https://www.instagram.com/reel/JA/' }),
post('FORA', { timestamp: '2026-07-01T00:00:00.000Z' }),
post('SEMID', { permalink: 'https://example.com/no-id/' }),
post('REPETIDA'),
post('REPETIDA', { permalink: 'https://www.instagram.com/p/REPETIDA/' }),
],
// The catalogue carries the same post under the `p` kind.
existingSourceUrls: ['https://www.instagram.com/p/JA/'],
from,
to,
})
expect(plan.found).toBe(3)
expect(plan.existingCount).toBe(1)
expect(plan.existingShortcodes).toEqual(['JA'])
expect(plan.outsideWindow).toBe(1)
expect(plan.malformed).toBe(1)
expect(plan.feedDuplicates).toBe(1)
expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['REPETIDA'])
})
})
describe('instagramContentFeedIdentityUrls', () => {
it('spells every canonical kind of each parseable post once', () => {
const urls = instagramContentFeedIdentityUrls([
post('A'),
post('B', { permalink: 'https://www.instagram.com/p/B/' }),
post('A', { permalink: 'https://www.instagram.com/p/A/' }),
post('X', { permalink: 'https://example.com/x/' }),
])
expect(urls).toEqual([
'https://www.instagram.com/p/A/',
'https://www.instagram.com/reel/A/',
'https://www.instagram.com/tv/A/',
'https://www.instagram.com/p/B/',
'https://www.instagram.com/reel/B/',
'https://www.instagram.com/tv/B/',
])
})
})
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' },
]),
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('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')
})
})