diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 3b88a5b2..259ac363 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -134,6 +134,14 @@ export const E2E_AFFECTED_MANIFEST = [ ], specs: ['campaignPeople'], }, + { + prefixes: [ + `${CAMPAIGN_APP}/contatos`, + 'src/components/campaign/contacts', + 'src/utilities/contacts', + ], + specs: ['campaignContacts'], + }, // Domains without a dedicated e2e family still wake campaign home smoke so // the affected classifier cannot return mode=none on an unmapped domain dir. { @@ -165,9 +173,6 @@ export const E2E_AFFECTED_MANIFEST = [ `${CAMPAIGN_APP}/assessores`, `${CAMPAIGN_APP}/atualizacoes`, `${CAMPAIGN_APP}/meus-contatos`, - `${CAMPAIGN_APP}/contatos`, - 'src/components/campaign/contacts', - 'src/utilities/contacts', `${CAMPAIGN_APP}/demandas`, `${CAMPAIGN_APP}/dobradinhas`, `${CAMPAIGN_APP}/organizacoes`, diff --git a/tests/e2e/campaignContacts.e2e.spec.ts b/tests/e2e/campaignContacts.e2e.spec.ts new file mode 100644 index 00000000..4e73438d --- /dev/null +++ b/tests/e2e/campaignContacts.e2e.spec.ts @@ -0,0 +1,267 @@ +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) + }) + + 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}`) + }) + + 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) + }) +})