teqo/tests/unit/wpArticles.unit.spec.ts
Francisco Solla 45e29a7785
All checks were successful
Agent PR Ready + auto-merge / ready-automerge (pull_request) Has been skipped
CI (PR) / docs-guards (pull_request) Successful in 7s
CI (PR) / plans-only-closes (pull_request) Successful in 8s
CI (PR) / scope (pull_request) Successful in 51s
CI (PR) / lint (pull_request) Successful in 1m4s
CI (PR) / format (pull_request) Successful in 1m14s
CI (PR) / typecheck (pull_request) Successful in 39s
CI (PR) / knip (pull_request) Successful in 40s
CI (PR) / cycles (pull_request) Successful in 39s
CI (PR) / unit (pull_request) Successful in 1m0s
CI (PR) / int (pull_request) Successful in 2m39s
CI (PR) / build (pull_request) Successful in 4m2s
CI (PR) / e2e (2) (pull_request) Successful in 6m2s
CI / e2e (1) (push) Successful in 6m39s
CI (PR) / e2e (1) (pull_request) Successful in 7m26s
CI (PR) / checks (pull_request) Successful in 7s
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Has been skipped
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 19s
Agent pool supervisor / tick (pull_request) Successful in 42s
CI / int (push) Successful in 2m52s
CI / static (push) Successful in 2m57s
CI / build (push) Successful in 3m52s
CI / e2e (2) (push) Successful in 5m34s
CI / checks (push) Successful in 2s
CI / deploy homeserver (teqo-1313) (push) Successful in 8m27s
feat(OPS52-media): recupera os objetos de media de produção do WordPress para o bucket (pnpm media:recover)
- scripts/recover-media.mjs: CLI de reconciliação (reconcile/--dry-run/--verify)
  — lê rows de media + posts.coverImage, resolve a capa com a mesma lógica do
  seed (featured media, senão 1ª imagem inline), PutObject com key = filename;
  DB nunca escrito; fail-closed (S3_* completas, ALLOW_REMOTE_DB, echo do alvo,
  timeouts curtos, covers só do origin WP e image/*)
- scripts/lib/wpArticles.mjs: fetch ao vivo do WordPress extraído do seed
  (sem twin), helpers puros de resolução + row→cover mapping (unit-testados)
- seed-posts.mjs: importa o fetch da lib (comportamento idêntico) e usa
  resolveCoverSource em processContent
- runbook de execução no homeserver (impl plan), nota no AGENTS.md, changelog
Closes #10
2026-08-18 08:20:45 -03:00

175 lines
5.8 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
inlineCoverUrl,
resolveCoverDownloadUrl,
resolveCoverSource,
resolveMediaCoverSources,
slugFromFilename,
stripHtml,
} from '../../scripts/lib/wpArticles.mjs'
describe('inlineCoverUrl (OPS52-media — first inline image of the article body)', () => {
it('returns the src of the first <img>', () => {
const html = '<p>dek</p><figure><img src="https://jorgesolla.com.br/cover.jpg"></figure>'
expect(inlineCoverUrl(html)).toBe('https://jorgesolla.com.br/cover.jpg')
})
it('skips earlier paragraphs without images', () => {
const html =
'<p>texto sem imagem</p><p>mais texto</p><figure><img src="/rel.jpg" alt="x"></figure>'
expect(inlineCoverUrl(html)).toBe('/rel.jpg')
})
it('returns null when the body has no image', () => {
expect(inlineCoverUrl('<p>só texto</p>')).toBeNull()
expect(inlineCoverUrl('')).toBeNull()
})
it('keeps relative URLs as-is (same contract as the seed)', () => {
expect(inlineCoverUrl('<img src="/uploads/foo.webp">')).toBe('/uploads/foo.webp')
})
})
describe('resolveCoverSource (OPS52-media — seed-identical cover resolution)', () => {
it('prefers the REST featured media URL', () => {
const article = {
slug: 'x',
title: 'x',
date: null,
html: '<p>sem img</p>',
coverUrl: 'https://jorgesolla.com.br/featured.jpg',
coverAlt: null,
}
expect(resolveCoverSource(article)).toBe('https://jorgesolla.com.br/featured.jpg')
})
it('falls back to the first inline image when there is no featured media', () => {
const article = {
slug: 'x',
title: 'x',
date: null,
html: '<figure><img src="https://jorgesolla.com.br/inline.png"></figure>',
coverUrl: null,
coverAlt: null,
}
expect(resolveCoverSource(article)).toBe('https://jorgesolla.com.br/inline.png')
})
it('returns null when neither source exists', () => {
const article = {
slug: 'x',
title: 'x',
date: null,
html: '<p>sem img</p>',
coverUrl: null,
coverAlt: null,
}
expect(resolveCoverSource(article)).toBeNull()
})
})
describe('slugFromFilename (OPS52-media — deterministic seed key <slug>.<ext>)', () => {
it('strips the last extension', () => {
expect(slugFromFilename('solla-e-jeronimo-inauguram-mercado-publico-de-ubata.jpg')).toBe(
'solla-e-jeronimo-inauguram-mercado-publico-de-ubata',
)
})
it('keeps dots inside the slug', () => {
expect(slugFromFilename('obra.2026.png')).toBe('obra.2026')
})
it('leaves filenames without an extension untouched', () => {
expect(slugFromFilename('sem-extensao')).toBe('sem-extensao')
})
it('is case-insensitive about the extension', () => {
expect(slugFromFilename('slug.JPG')).toBe('slug')
})
})
describe('stripHtml (shared with the seed)', () => {
it('collapses whitespace and strips tags', () => {
expect(stripHtml('<p>Olá <strong>mundo</strong></p>')).toBe('Olá mundo')
})
it('handles empty input', () => {
expect(stripHtml('')).toBe('')
})
})
describe('resolveCoverDownloadUrl (OPS52-media — covers constrained to the WP origin)', () => {
it('keeps an absolute cover on the WordPress host', () => {
expect(
resolveCoverDownloadUrl('https://jorgesolla.com.br/wp-content/uploads/2026/07/x.jpeg'),
).toBe('https://jorgesolla.com.br/wp-content/uploads/2026/07/x.jpeg')
})
it('resolves relative covers against the WordPress origin', () => {
expect(resolveCoverDownloadUrl('/wp-content/uploads/x.png')).toBe(
'https://jorgesolla.com.br/wp-content/uploads/x.png',
)
})
it('refuses covers on other hosts (no SSRF from the homeserver)', () => {
expect(() => resolveCoverDownloadUrl('http://100.119.220.31:3900/internal')).toThrow(
/fora do origin/,
)
expect(() => resolveCoverDownloadUrl('https://evil.example.com/x.png')).toThrow(
/fora do origin/,
)
})
it('refuses non-http(s) schemes', () => {
expect(() => resolveCoverDownloadUrl('file:///etc/passwd')).toThrow(/protocolo/)
})
})
describe('resolveMediaCoverSources (OPS52-media — row→cover mapping)', () => {
const article = (slug: string) => ({
slug,
title: slug,
date: null,
html: '<figure><img src="https://jorgesolla.com.br/inline.png"></figure>',
coverUrl: null,
coverAlt: null,
})
const articlesBySlug = new Map([
['post-a', article('post-a')],
['post-b', article('post-b')],
])
it('maps via the post coverImage relation (exact source)', () => {
const rows = [{ id: 1, filename: 'post-a.jpg' }]
const posts = [{ coverImage: 1, slug: 'post-a' }]
const [entry] = resolveMediaCoverSources(rows, posts, articlesBySlug)
expect(entry.source).toBe('post')
expect(entry.slug).toBe('post-a')
expect(entry.coverUrl).toBe('https://jorgesolla.com.br/inline.png')
})
it('falls back to the filename-derived slug for rows without a post', () => {
const rows = [{ id: 2, filename: 'post-b.png' }]
const [entry] = resolveMediaCoverSources(rows, [], articlesBySlug)
expect(entry.source).toBe('filename')
expect(entry.slug).toBe('post-b')
expect(entry.coverUrl).toBe('https://jorgesolla.com.br/inline.png')
})
it('prefers the post relation over a matching filename slug', () => {
const rows = [{ id: 1, filename: 'post-b.jpg' }]
const posts = [{ coverImage: 1, slug: 'post-a' }]
const [entry] = resolveMediaCoverSources(rows, posts, articlesBySlug)
expect(entry.slug).toBe('post-a')
expect(entry.source).toBe('post')
})
it('leaves unresolvable rows with no cover and source null', () => {
const rows = [{ id: 3, filename: 'orphan.jpg' }]
const [entry] = resolveMediaCoverSources(rows, [], articlesBySlug)
expect(entry).toMatchObject({ slug: 'orphan', coverUrl: null, source: null })
})
})