teqo-backup/tests/unit/campaignCompositionCleanup.unit.spec.ts
Francisco Ignácio Fontoura Solla 7b93c232ff C13: renomeia "Plano de Acao" para "Atividade" em toda a vertical /campanha
Collection actionPlan -> activity (labels "Atividade"/"Atividades"), campo
campaignDemand.actionPlan -> activity, rota /campanha/planos ->
/campanha/atividades (criacao em atividades/nova), sidebar "Atividades" e
rotulos alinhados a entidade ("Tipo de atividade", "Origem da atividade",
"Resultado da atividade"). Valores de enum kind/status/origin inalterados.

Schema movido por migration data-preserving escrita a mao
(20260725_213000_rename_action_plan_to_activity): 4 tabelas, 3 enums, 2
colunas, 39 indices (4 pkeys inclusos), 14 FKs e 2 sequences, cada statement
guardado na existencia do objeto de origem, com down() simetrico. O gerador do
Payload emitiria DROP+CREATE e nao ve o indice parcial escrito a mao
action_plan_upcoming_start_at_idx, entao a lista veio de
pg_indexes/pg_constraint/pg_type/pg_sequences e o snapshot .json foi derivado
do anterior por transformacao textual -- validado por migrate:create __probe
sem mudanca de schema. A mesma migration alinha os 8 indices fosseis %plaza%
que a remodelagem de municipios deixou (renomeou as colunas plaza_id, nao os
indices).

Guard: o describe "banned campaign terminology" de
codebaseConventions.unit.spec.ts virou tabela de termos varrendo src+tests+
scripts (migrations fora -- historia congelada), banindo os compostos
actionPlan, a copy "plano de acao" e a rota antiga, mas nao plan/plano isolado
(colidiria com os planos de assinatura da Fase 2). Allowlist: o proprio spec,
scripts/generate-remodel-municipalities-migration.mjs e o comentario historico
em campaignMigrationReconciliation.int.spec.ts.

Plano: docs/plans/renomear-plano-acao-para-atividade.md
2026-07-25 20:50:38 -03:00

250 lines
9 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { dirname, extname, resolve } from 'node:path'
import { act, createElement } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loginAction: vi.fn(),
}))
vi.mock('@/app/(campaign)/campanha/actions/auth', () => ({
loginCampaign: mocks.loginAction,
loginCampaignFormAction: mocks.loginAction,
}))
import { LoginForm } from '@/app/(campaign)/campanha/login/LoginForm'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
const sourceRoot = resolve(process.cwd(), 'src')
const localImportPattern = /(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g
const resolveLocalModule = (importer: string, specifier: string): string | null => {
const base = specifier.startsWith('@/')
? resolve(sourceRoot, specifier.slice(2))
: specifier.startsWith('.')
? resolve(dirname(importer), specifier)
: null
if (!base) return null
for (const candidate of [
base,
`${base}.ts`,
`${base}.tsx`,
resolve(base, 'index.ts'),
resolve(base, 'index.tsx'),
]) {
if (existsSync(candidate)) return candidate
}
return null
}
const collectLocalDependencyGraph = (entry: string, visited = new Set<string>()): Set<string> => {
if (visited.has(entry)) return visited
visited.add(entry)
const source = readFileSync(entry, 'utf8')
for (const match of source.matchAll(localImportPattern)) {
if (/^import\s+type\b/.test(match[0])) continue
const dependency = resolveLocalModule(entry, match[1]!)
if (dependency && !/^['"]use server['"]/.test(readFileSync(dependency, 'utf8'))) {
collectLocalDependencyGraph(dependency, visited)
}
}
return visited
}
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('campaign login composition', () => {
it('keeps client validation libraries out of the login graph', () => {
const source = readFileSync(
resolve(process.cwd(), 'src/app/(campaign)/campanha/login/LoginForm.tsx'),
'utf8',
)
expect(source).not.toContain('@hookform/resolvers')
expect(source).not.toContain('react-hook-form')
expect(source).not.toContain('campaignLoginSchema')
})
it('uses native constraints and password-manager metadata', () => {
render(createElement(LoginForm))
const identifier = screen.getByLabelText('E-mail ou celular')
const password = screen.getByLabelText('Senha')
const submit = screen.getByRole('button', { name: 'Entrar' })
expect(screen.getByRole('heading', { level: 1, name: 'Entrar na campanha' })).toBeTruthy()
expect(identifier.getAttribute('name')).toBe('identifier')
expect(identifier.hasAttribute('required')).toBe(true)
expect(identifier.getAttribute('autocomplete')).toBe('username')
expect(identifier.getAttribute('inputmode')).toBe('text')
expect(password.getAttribute('name')).toBe('password')
expect(password.hasAttribute('required')).toBe(true)
expect(password.getAttribute('autocomplete')).toBe('current-password')
expect(submit.getAttribute('type')).toBe('submit')
expect(submit.className).toContain('w-full')
})
it('switches identifier inputMode toward phone or email as the user types', () => {
render(createElement(LoginForm))
const identifier = screen.getByLabelText('E-mail ou celular')
fireEvent.change(identifier, { target: { value: '(71) 9' } })
expect(identifier.getAttribute('inputmode')).toBe('tel')
fireEvent.change(identifier, { target: { value: 'staff@' } })
expect(identifier.getAttribute('inputmode')).toBe('email')
})
it('links auth errors to both credential fields and shows leadership recovery copy', async () => {
mocks.loginAction.mockResolvedValue({ error: 'E-mail, celular ou senha inválidos.' })
render(createElement(LoginForm))
fireEvent.change(screen.getByLabelText('E-mail ou celular'), {
target: { value: '71' },
})
fireEvent.change(screen.getByLabelText('Senha'), {
target: { value: 'wrong' },
})
fireEvent.submit(screen.getByRole('button', { name: 'Entrar' }).closest('form')!)
const alert = await screen.findByRole('alert')
expect(alert.id).toBe('login-credentials-error')
expect(alert.textContent).toContain('E-mail, celular ou senha inválidos.')
expect(screen.getByLabelText('E-mail ou celular').getAttribute('aria-invalid')).toBe('true')
expect(screen.getByLabelText('Senha').getAttribute('aria-invalid')).toBe('true')
expect(screen.getByLabelText('E-mail ou celular').getAttribute('aria-describedby')).toBe(
'login-credentials-error',
)
expect(screen.getByLabelText('Senha').getAttribute('aria-describedby')).toBe(
'login-credentials-error',
)
expect(
screen.getByText(/Conta só com celular\? Peça um novo convite ao coordenador/),
).toBeTruthy()
})
it('shows the pending spinner and announces an action error', async () => {
let resolveAction: ((value: { error: string }) => void) | undefined
mocks.loginAction.mockImplementation(
() =>
new Promise<{ error: string }>((resolve) => {
resolveAction = resolve
}),
)
render(createElement(LoginForm))
fireEvent.change(screen.getByLabelText('E-mail ou celular'), {
target: { value: 'staff@example.com' },
})
fireEvent.change(screen.getByLabelText('Senha'), {
target: { value: ' wrong-password ' },
})
fireEvent.submit(screen.getByRole('button', { name: 'Entrar' }).closest('form')!)
const pendingButton = await screen.findByRole('button', { name: 'Entrando...' })
const submittedFormData = mocks.loginAction.mock.calls[0]?.[1]
expect(submittedFormData).toBeInstanceOf(FormData)
expect((submittedFormData as FormData).get('identifier')).toBe('staff@example.com')
expect((submittedFormData as FormData).get('password')).toBe(' wrong-password ')
expect(pendingButton.querySelector('[data-slot="spinner"]')).not.toBeNull()
expect((pendingButton as HTMLButtonElement).disabled).toBe(true)
await act(async () => {
resolveAction?.({ error: 'E-mail, celular ou senha inválidos.' })
})
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain(
'E-mail, celular ou senha inválidos.',
),
)
})
})
describe('campaign client module boundaries', () => {
it('keeps every client dependency graph free of server FormData and Node-only modules', () => {
const clientEntries = readdirSync(sourceRoot, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && ['.ts', '.tsx'].includes(extname(entry.name)))
.map((entry) => resolve(entry.parentPath, entry.name))
.filter((file) => /^['"]use client['"]/.test(readFileSync(file, 'utf8')))
for (const entry of clientEntries) {
for (const dependency of collectLocalDependencyGraph(entry)) {
const source = readFileSync(dependency, 'utf8')
expect(dependency).not.toBe(resolve(sourceRoot, 'lib/formData.ts'))
expect(source, `${dependency} entered the client graph`).not.toMatch(
/(?:from\s+['"]node:|require\(['"]node:|\bBuffer\b)/,
)
}
}
})
})
describe('static table composition', () => {
const table = createElement(
Table,
null,
createElement(
TableHeader,
null,
createElement(TableRow, null, createElement(TableHead, null, 'Nome')),
),
createElement(
TableBody,
null,
createElement(TableRow, null, createElement(TableCell, null, 'Município Centro')),
),
)
it('has no client-only directive or browser dependencies', () => {
const source = readFileSync(resolve(process.cwd(), 'src/components/ui/Table.tsx'), 'utf8')
expect(source).not.toMatch(/^['"]use client['"]/)
expect(source).not.toMatch(/\buse(State|Effect|LayoutEffect|Ref|Memo|Callback)\b/)
expect(source).not.toMatch(/\b(window|document|navigator)\b/)
})
it('renders on the server and hydrates without mismatches', async () => {
const html = renderToStaticMarkup(table)
const container = document.createElement('div')
container.innerHTML = html
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
let root: ReturnType<typeof hydrateRoot> | undefined
await act(async () => {
root = hydrateRoot(container, table)
})
expect(container.querySelector('table')).not.toBeNull()
expect(container.textContent).toContain('Município Centro')
expect(consoleError).not.toHaveBeenCalled()
await act(async () => root?.unmount())
consoleError.mockRestore()
})
})