teqo/tests/e2e/campaignContacts.e2e.spec.ts
Francisco Solla 438ac727fe fix(C139): e2e campaignContacts não vaza contatos criados pela UI
O nome letters-only não carrega o runID, então o discoverOwnedRows do
ownership não encontra o contato criado via formulário (create row/sheet)
e a limpeza da fixture o deixava para trás — cada run vazava 2 contatos
no banco de teste. Deletar explicitamente por nome exato no fim do teste.
2026-08-13 15:51:15 -03:00

291 lines
12 KiB
TypeScript

import {
campaignPageChrome,
expect,
expectPostResponse,
test,
} from './fixtures/campaignE2EFixtures.js'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
/**
* C139 — the campaign contacts entity page: staff CRUD on `/campanha/contatos`
* (desktop table + create row, mobile cards + FAB/sheet) and the staff gate.
*
* The UI name schema forbids digits (`contactNameSchema`), so contacts CREATED
* through the forms need a letters-only unique name: the fixture runID is a
* hex UUID, and hex → [a-p] is a bijection that keeps the full 128-bit
* uniqueness (API-created fixture contacts may keep digits).
*/
const lettersOnlyUnique = (runID: string): string =>
runID
.replaceAll('-', '')
.replace(/[0-9a-f]/g, (hex) => String.fromCharCode(97 + parseInt(hex, 16)))
const uiName = (fixtures: CampaignE2EOwnership, prefix: string): string =>
`${prefix} ${lettersOnlyUnique(fixtures.runID)}`
test.describe('Contatos — página da entidade Contact', () => {
test('staff opens the contacts page and the omnibox narrows the recorte', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Contatos'),
})
const phone = fixtures.phone()
const first = await fixtures.payload.create({
collection: 'contact',
data: {
name: `Alpha ${fixtures.value('Busca')}`,
phones: [{ value: phone }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const second = await fixtures.payload.create({
collection: 'contact',
data: { name: `Beta ${fixtures.value('Busca')}`, state: 'BA', city: 'Salvador' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await expect(page).toHaveURL(/\/campanha\/contatos$/)
await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
const firstRow = page.getByRole('row', { name: new RegExp(first.name as string) })
await expect(firstRow).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(second.name as string) })).toBeVisible()
await expect(page.getByText(/^\d+ contatos?$/)).toBeVisible()
// The row action links to WhatsApp with the primary phone (C139).
await expect(
firstRow.getByRole('link', { name: `Enviar WhatsApp para ${first.name}` }),
).toHaveAttribute('href', `https://wa.me/55${phone}`)
// The omnibox narrows the recorte (canonical URL, chips mounted).
const omnibox = page.getByRole('combobox', { name: 'Filtrar contatos' })
await omnibox.fill(first.name as string)
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=/)
await expect(firstRow).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(second.name as string) })).toBeHidden()
})
test('the desktop create row keeps typed values on error and lands a valid contact', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const contactName = uiName(fixtures, 'Contato Criado')
const phone = fixtures.phone()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await page.getByRole('button', { name: 'Novo contato' }).click()
const createRow = page.locator('[data-view="contact-create-row"]')
const nameInput = createRow.getByRole('textbox', { name: 'Nome do contato' })
await expect(nameInput).toBeVisible()
// Digits are not allowed by the name schema: the alert shows and the
// typed value survives the failed submit (C139 — no React form reset).
await nameInput.fill('Nome Invalido 123')
await createRow.getByRole('button', { name: 'Salvar' }).click()
await expect(createRow.getByRole('alert')).toContainText(
'Use apenas letras, no máximo um espaço ou hífen entre termos',
)
await expect(nameInput).toHaveValue('Nome Invalido 123')
// Valid create lands the row and the write reaches the database.
await nameInput.fill(contactName)
await createRow.getByRole('textbox', { name: 'Telefone do contato' }).fill(phone)
const persisted = expectPostResponse(page, '/campanha/contatos')
await createRow.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
const stored = await fixtures.payload.find({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
pagination: false,
})
expect(stored.docs).toHaveLength(1)
expect(stored.docs[0]!.phones?.[0]?.value).toBe(phone)
// The row was created THROUGH the UI, so the ownership proxy never tracked
// it (and its letters-only name does not carry the runID, so discovery
// cannot find it) — delete it explicitly or every run leaks a contact.
await fixtures.payload.delete({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
})
})
test('a duplicate name shows the safe conflict message in the create row', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const existing = await fixtures.payload.create({
collection: 'contact',
data: { name: uiName(fixtures, 'Conflito Existente'), state: 'BA' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await page.getByRole('button', { name: 'Novo contato' }).click()
const createRow = page.locator('[data-view="contact-create-row"]')
const nameInput = createRow.getByRole('textbox', { name: 'Nome do contato' })
await nameInput.fill(existing.name as string)
await createRow.getByRole('button', { name: 'Salvar' }).click()
await expect(createRow.getByRole('alert')).toContainText(
'Já existe um contato com este nome — confira a lista antes de salvar.',
)
await expect(nameInput).toHaveValue(existing.name as string)
})
test('leader cannot open the contacts page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Contatos Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/contatos')
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('mobile: the FAB opens the create sheet and a valid save lands a card', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const contactName = uiName(fixtures, 'Contato Sheet')
const phone = fixtures.phone()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
// The FAB is the mobile-only create entry point (the toolbar row is
// `md:hidden`); it floats above the bottom nav (C139 z-index fix).
await page.getByRole('button', { name: 'Novo contato' }).click()
const dialog = page.getByRole('dialog', { name: 'Novo contato' })
await expect(dialog).toBeVisible()
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(contactName)
await dialog.getByRole('button', { name: 'Adicionar telefone' }).click()
await dialog.getByRole('textbox', { name: 'Celular 1' }).fill(phone)
const persisted = expectPostResponse(page, '/campanha/contatos')
await dialog.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(dialog).toBeHidden()
const card = page.locator('[data-view="mobile-cards"] li').filter({ hasText: contactName })
await expect(card).toBeVisible()
await expect(card).toContainText('BA')
const stored = await fixtures.payload.find({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
pagination: false,
})
expect(stored.docs).toHaveLength(1)
expect(stored.docs[0]!.phones?.[0]?.value).toBe(phone)
await expect(
card.getByRole('link', { name: `Enviar WhatsApp para ${contactName}` }),
).toHaveAttribute('href', `https://wa.me/55${phone}`)
// UI-created (see the desktop create-row test): delete explicitly so the
// ownership discovery (runID in name) is not the only net.
await fixtures.payload.delete({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
})
})
test('mobile: the edit sheet keeps values on conflict and saves the ficha atomically', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const targetName = uiName(fixtures, 'Ficha Editada')
const target = await fixtures.payload.create({
collection: 'contact',
data: {
name: targetName,
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
// The conflict target: a letters-only name that already exists.
const conflictName = uiName(fixtures, 'Ficha Conflito')
await fixtures.payload.create({
collection: 'contact',
data: { name: conflictName, state: 'BA' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
const omnibox = page.getByRole('combobox', { name: 'Filtrar contatos' })
await omnibox.fill(targetName)
await omnibox.press('Enter')
await page.getByRole('button', { name: `Editar contato ${targetName}` }).click()
const dialog = page.getByRole('dialog', { name: 'Editar contato' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('textbox', { name: 'Nome *' })).toHaveValue(targetName)
// A conflict (the typed name already exists) keeps the sheet open with
// the typed value and a visible banner — no silent wipe (C139).
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(conflictName)
await dialog.getByRole('button', { name: 'Salvar' }).click()
await expect(dialog.getByRole('alert')).toContainText(
'Já existe um contato com este nome — confira a lista antes de salvar.',
)
await expect(dialog.getByRole('textbox', { name: 'Nome *' })).toHaveValue(conflictName)
// Reverting the name and editing the city writes the whole ficha in one
// action: the card shows the new city and the database agrees.
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(targetName)
await dialog.getByRole('textbox', { name: 'Cidade' }).fill('Feira de Santana')
const persisted = expectPostResponse(page, '/campanha/contatos')
await dialog.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(dialog).toBeHidden()
const card = page.locator('[data-view="mobile-cards"] li').filter({ hasText: targetName })
await expect(card).toContainText('Feira de Santana · BA')
const stored = await fixtures.payload.findByID({
collection: 'contact',
id: target.id,
depth: 0,
})
expect(stored.city).toBe('Feira de Santana')
expect(stored.name).toBe(targetName)
})
})