teqo/tests/unit/campaignCompositionCleanup.unit.spec.ts
Francisco Ignácio Fontoura Solla d5b4af2d98 E13: visit eligibility checklist and the tour composer.
Five conditions per município (volume, headroom, rede, janela, encaixe) with
their reason always visible, plus a staff-only composer at
/campanha/atividades/giros that turns an âncora + satélites + semente into draft
activities in one transaction. No `visita` kind and no `tour` entity:
`deputyPresent` already marks the candidate's own agenda.
2026-07-27 21:04:04 -03:00

300 lines
10 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
/**
* The client graphs overlap heavily — the ui kit sits in almost every one — so
* each file is read and scanned once for the whole suite instead of once per
* entry that reaches it.
*/
const sourceCache = new Map<string, string>()
const readSource = (file: string): string => {
const cached = sourceCache.get(file)
if (cached !== undefined) return cached
const source = readFileSync(file, 'utf8')
sourceCache.set(file, source)
return source
}
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 runtimeDependencyCache = new Map<string, string[]>()
/** Local modules a file pulls in for VALUES — `import type` never reaches the client. */
const runtimeDependenciesOf = (file: string): string[] => {
const cached = runtimeDependencyCache.get(file)
if (cached) return cached
const dependencies: string[] = []
for (const match of readSource(file).matchAll(localImportPattern)) {
if (/^import\s+type\b/.test(match[0])) continue
const dependency = resolveLocalModule(file, match[1]!)
// A `'use server'` module is a network boundary, not a bundled import.
if (dependency && !/^['"]use server['"]/.test(readSource(dependency))) {
dependencies.push(dependency)
}
}
runtimeDependencyCache.set(file, dependencies)
return dependencies
}
const collectLocalDependencyGraph = (entry: string, visited = new Set<string>()): Set<string> => {
if (visited.has(entry)) return visited
visited.add(entry)
for (const dependency of runtimeDependenciesOf(entry)) {
collectLocalDependencyGraph(dependency, visited)
}
return visited
}
const reactActEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
beforeAll(() => {
reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
delete reactActEnvironment.IS_REACT_ACT_ENVIRONMENT
vi.unstubAllGlobals()
})
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 rememberMe = screen.getByLabelText('Lembrar de mim neste dispositivo')
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(rememberMe.getAttribute('aria-checked')).toBe('false')
expect(rememberMe.getAttribute('aria-describedby')).toBe('remember-me-description')
expect(
screen.getByText(
'Em aparelho pessoal, fique conectado por 14 dias. Desmarcado, o acesso dura 8 horas.',
),
).toBeTruthy()
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(readSource(file)))
const reachable = new Set<string>()
for (const entry of clientEntries) {
for (const dependency of collectLocalDependencyGraph(entry)) reachable.add(dependency)
}
for (const dependency of reachable) {
expect(dependency).not.toBe(resolve(sourceRoot, 'lib/formData.ts'))
expect(readSource(dependency), `${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()
})
})