teqo-backup/tests/unit/contentEvents.unit.spec.ts
Francisco Solla f47c2645ac feat(C213): contadores anônimos de circulação por peça na Central de Conteúdos (Closes #1258)
Collection contentEvent (hidden, sem hooks, sem PII) + beacon público
POST /api/content-events fail-soft, download contado no servidor na rota
de mídia, agregação em uma query group-by e a coluna "Circulação" na lista
interna + painel na ficha (port classe-a-classe do artefato estendido,
crítica final do designer certificada).
2026-09-23 15:29:43 -03:00

96 lines
3.6 KiB
TypeScript

// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CONTENT_EVENT_ENDPOINT, sendContentPieceEvent } from '@/lib/contentEvents'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('ContentEvent collection contract', () => {
it('carries the subject only — no visitor identifier — and never hooks the public cache', async () => {
const { ContentEvent } = await import('@/collections/ContentEvent')
const fieldNames = ContentEvent.fields.map((field) => ('name' in field ? field.name : ''))
expect(fieldNames).toEqual(['type', 'subjectType', 'subjectId'])
for (const forbidden of ['ip', 'useragent', 'cookie', 'session', 'visitor', 'fingerprint']) {
expect(fieldNames.some((name) => name.toLowerCase().includes(forbidden))).toBe(false)
}
// A write must never bust the public `contentPieces` listing tag.
expect(ContentEvent.hooks ?? {}).toEqual({})
expect(ContentEvent.admin?.hidden).toBeTypeOf('function')
// No access predicate grants a create; the writer bypasses explicitly.
const createAccess = ContentEvent.access?.create
expect(createAccess).toBeTypeOf('function')
const decision = await (
createAccess as (args: { req: { user: null } }) => boolean | Promise<boolean>
)({ req: { user: null } })
expect(decision).toBe(false)
})
})
describe('sendContentPieceEvent', () => {
it('prefers sendBeacon with a JSON blob and does not touch fetch', async () => {
const sendBeacon = vi.fn().mockReturnValue(true)
const fetchMock = vi.fn()
vi.stubGlobal('navigator', { sendBeacon })
vi.stubGlobal('fetch', fetchMock)
sendContentPieceEvent('abertura', 'fim-da-escala-6x1')
expect(sendBeacon).toHaveBeenCalledTimes(1)
const [endpoint, blob] = sendBeacon.mock.calls[0]!
expect(endpoint).toBe(CONTENT_EVENT_ENDPOINT)
expect(blob).toBeInstanceOf(Blob)
expect((blob as Blob).type).toBe('application/json')
await expect((blob as Blob).text()).resolves.toBe(
JSON.stringify({ type: 'abertura', pieceSlug: 'fim-da-escala-6x1' }),
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('falls back to a keepalive fetch when the beacon refuses the body', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('navigator', { sendBeacon: vi.fn().mockReturnValue(false) })
vi.stubGlobal('fetch', fetchMock)
sendContentPieceEvent('compartilhar_link', 'solla-no-sus')
expect(fetchMock).toHaveBeenCalledWith(
CONTENT_EVENT_ENDPOINT,
expect.objectContaining({
method: 'POST',
keepalive: true,
body: JSON.stringify({ type: 'compartilhar_link', pieceSlug: 'solla-no-sus' }),
}),
)
})
it('never throws when the beacon and the fetch both fail', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'))
vi.stubGlobal('navigator', {
sendBeacon: vi.fn().mockImplementation(() => {
throw new Error('beacon exploded')
}),
})
vi.stubGlobal('fetch', fetchMock)
expect(() => sendContentPieceEvent('download', 'peca')).not.toThrow()
await Promise.resolve()
vi.stubGlobal('fetch', undefined)
expect(() => sendContentPieceEvent('download', 'peca')).not.toThrow()
})
it('reaches the fetch fallback when there is no navigator at all', () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('navigator', undefined)
vi.stubGlobal('fetch', fetchMock)
sendContentPieceEvent('download', 'peca')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})