teqo-backup/tests/unit/googleCalendarSyncDialog.unit.spec.tsx

239 lines
8.5 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed'
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
const syncedState = (
overrides: Partial<GoogleCalendarSyncActionResult> = {},
): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: 'connected',
oauthAvailable: true,
oauthConnectedAt: '2026-08-01T12:00:00.000Z',
oauthError: null,
status: 'synced',
calendarId: 'c_campanha@group.calendar.google.com',
lastSyncedAt: '2026-08-11T12:00:00.000Z',
lastSuccessAt: '2026-08-11T12:00:00.000Z',
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2F…',
...overrides,
})
const notConfiguredState = (
overrides: Partial<GoogleCalendarSyncActionResult> = {},
): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: 'not-configured',
oauthAvailable: true,
oauthConnectedAt: null,
oauthError: null,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: null,
...overrides,
})
const renderDialog = (
state: GoogleCalendarSyncActionResult,
overrides: {
onStartOAuth?: () => Promise<
{ ok: true; authorizeUrl: string } | { ok: false; message: string }
>
onDisconnect?: () => Promise<GoogleCalendarSyncActionResult>
} = {},
) =>
render(
<GoogleCalendarSyncDialog
open
onOpenChange={vi.fn()}
state={state}
onSyncNow={vi.fn()}
onSetDisabled={vi.fn()}
onStartOAuth={overrides.onStartOAuth ?? vi.fn()}
onDisconnect={overrides.onDisconnect ?? vi.fn()}
/>,
)
const matchMediaMock = vi.fn()
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
beforeEach(() => {
matchMediaMock.mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
vi.stubGlobal('matchMedia', matchMediaMock)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)', () => {
it('mostra a data de expiração do canal quando ativo', () => {
renderDialog(syncedState({ pushChannelExpiresAt: '2026-09-10T12:00:00.000Z' }))
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText(/Edições pelo Google/)).toBeTruthy()
expect(within(dialog).getByText(/Notificações ativas até/)).toBeTruthy()
})
it('mostra o erro do canal quando a notificação falhou, sem prometer renovação', () => {
renderDialog(syncedState({ pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada' }))
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(
within(dialog).getByText(/Notificações de mudanças feitas no Google indisponíveis/),
).toBeTruthy()
expect(within(dialog).queryByText(/Notificações ativas até/)).toBeNull()
})
it('não mostra a seção no estado não configurado', () => {
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull()
})
})
describe('GoogleCalendarSyncDialog — encaixe desktop (C148)', () => {
it('mantém o conteúdo no corpo rolável e as ações no rodapé', () => {
renderDialog(syncedState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
const body = dialog.querySelector('[data-slot="dialog-scroll-body"]')
const footer = dialog.querySelector('[data-slot="dialog-footer"]')
expect(body).toBeTruthy()
expect(footer).toBeTruthy()
expect(body!.contains(screen.getByText(/Edições pelo Google/))).toBe(true)
expect(body!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(false)
expect(footer!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(true)
expect(footer!.contains(screen.getByRole('button', { name: 'Desativar' }))).toBe(true)
})
it('não renderiza rodapé quando não há ação (não configurado)', () => {
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(dialog.querySelector('[data-slot="dialog-footer"]')).toBeNull()
expect(dialog.querySelector('[data-slot="dialog-scroll-body"]')).toBeTruthy()
})
})
describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => {
it('offers the connect button when the OAuth client is configured', () => {
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText('Não configurado')).toBeTruthy()
expect(within(dialog).getByRole('button', { name: /Conectar com o Google/ })).toBeTruthy()
// The service account remains documented as the technical fallback.
expect(within(dialog).getByText(/service account continua disponível/)).toBeTruthy()
})
it('shows the handshake error with the one-click reconnect path', () => {
renderDialog(
syncedState({
connection: 'error',
oauthError: 'A conexão com o Google expirou ou foi revogada.',
}),
)
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText('Erro')).toBeTruthy()
expect(within(dialog).getByText(/expirou ou foi revogada/)).toBeTruthy()
expect(within(dialog).getByRole('button', { name: /Reconectar com o Google/ })).toBeTruthy()
})
it('disconnects from the card when connected', async () => {
const onDisconnect = vi.fn(async () => notConfiguredState())
renderDialog(syncedState(), { onDisconnect })
fireEvent.click(screen.getByRole('button', { name: 'Desconectar' }))
await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(1))
})
it('hides the connect/disconnect actions from non-managers (advisor)', () => {
renderDialog(notConfiguredState({ canManageConnection: false }))
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
expect(screen.getByText(/Somente candidato ou coordenação/)).toBeTruthy()
})
it('falls back to the admin runbook when the server has no OAuth client', () => {
renderDialog(notConfiguredState({ oauthAvailable: false }))
expect(screen.getByText(/service account do Teqo/)).toBeTruthy()
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
})
})
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
render(<ActivityUpdateFeed updates={updates} />)
it('atribui "Google Calendar" ao registro reverso sem autor', () => {
renderFeed([
{
id: '1',
body: 'Google Calendar: remarcada — antes 14/08 às 10:00, agora 14/08 às 16:00',
authorName: null,
createdAt: '2026-08-11T12:00:00.000Z',
},
])
expect(screen.getByText('Google Calendar')).toBeTruthy()
})
it('mantém "Autor removido" para entradas sem autor fora do prefixo', () => {
renderFeed([
{
id: '2',
body: 'Atualização de um autor que saiu da campanha',
authorName: null,
createdAt: '2026-08-11T12:00:00.000Z',
},
])
expect(screen.getByText('Autor removido')).toBeTruthy()
})
it('mantém o nome do autor quando presente (o prefixo não rouba a atribuição)', () => {
renderFeed([
{
id: '3',
body: 'Google Calendar: título alterado',
authorName: 'Ana Coordenadora',
createdAt: '2026-08-11T12:00:00.000Z',
},
])
expect(screen.getByText('Ana Coordenadora')).toBeTruthy()
})
})