teqo/tests/unit/campaignQuickAction.unit.spec.ts
Francisco Solla 1daaaf9f4e feat(C139): domínio de contatos — URL state, loader com vínculos e invariante de nome
Fase 2 do plano aprovado. Três módulos em src/utilities/contacts/:
- contactListUrl: contrato de params (q|gender|state|city|ausencia|vinculo|sort|dir|page),
  canonicalize, hrefs de sort e where de fonte com 'phones.value' like (ILIKE verificado
  no Postgres local).
- contactListData: loader com UM find em contact (user threadado, overrideAccess false),
  vínculos (liderancas/dobradinhas/assessores/equipe) como facets sobre o conjunto
  legível, filtro/sort/paginação em memória, nulos no fim (precedente B15).
- contactNameInvariant: lock contact-name:{normalized} + prefilter ILIKE + veredito
  exato em memória; mensagem de conflito pt-BR.

38 testes unit novos; format + tsc limpos.
2026-08-13 14:05:24 -03:00

493 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest'
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
import { advisorQuickCreateHref } from '@/lib/campaignAdvisorQuickActions'
import { UNCOVERED_MUNICIPALITIES_LIST_HREF, homeActionsForRole } from '@/lib/campaignHomeActions'
import { CAMPAIGN_CONCEPTS_PATH } from '@/lib/campaignIntelligenceConcepts'
import { CAMPAIGN_PROFILE_HOME } from '@/lib/campaignPaths'
import {
CAMPAIGN_DEMANDS_CREATE_HREF,
demandCreateHref,
isDemandDetailPath,
isDemandsListPath,
resolveDemandDetailQuickActions,
resolveDemandsListQuickActions,
} from '@/lib/campaignQuickActionDemands'
import {
matchesDobradinhasQuickActionSurface,
parseStateDeputyDetailSlug,
resolveDobradinhasQuickActions,
} from '@/lib/campaignQuickActionDobradinhas'
import {
isCampaignActionsPath,
isCampaignHomePath,
isContactsPath,
isLeaderContactsPath,
shouldMountQuickActionsFab,
} from '@/lib/campaignQuickActionMount'
import {
ACTIVITY_DETAIL_ROOT_PATH,
ACTIVITY_LIST_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
ORGANIZATIONS_LIST_PATH,
ORGANIZATION_NEW_PATH,
isActivityTourComposerPath,
isListPath,
normalizePathname,
parseActivityQuickActionSurface,
parseOrganizationQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import { resolveOrganizationQuickActions } from '@/lib/organizationQuickActions'
import { SUPPORTER_CREATE_HREF, SUPPORTER_IMPORT_HREF } from '@/lib/supporterQuickActions'
describe('campaignQuickActionMount', () => {
it('treats Início as exact match only', () => {
expect(isCampaignHomePath('/campanha')).toBe(true)
expect(isCampaignHomePath('/campanha/')).toBe(true)
expect(isCampaignHomePath('/campanha/municipios')).toBe(false)
})
it('matches action wizard subtree', () => {
expect(isCampaignActionsPath('/campanha/acoes')).toBe(true)
expect(isCampaignActionsPath('/campanha/acoes/atualizar-votos')).toBe(true)
expect(isCampaignActionsPath('/campanha/municipios')).toBe(false)
})
it('matches leader contacts subtree (C139 — moved to meus-contatos)', () => {
expect(isLeaderContactsPath('/campanha/meus-contatos')).toBe(true)
expect(isLeaderContactsPath('/campanha/meus-contatos/novo')).toBe(true)
expect(isLeaderContactsPath('/campanha/contatos')).toBe(false)
expect(isLeaderContactsPath('/campanha')).toBe(false)
})
it('matches the staff contacts page (C139)', () => {
expect(isContactsPath('/campanha/contatos')).toBe(true)
expect(isContactsPath('/campanha/contatos/')).toBe(true)
expect(isContactsPath('/campanha/meus-contatos')).toBe(false)
expect(isContactsPath('/campanha')).toBe(false)
})
it('mounts for staff outside Início, acoes and the contacts page', () => {
expect(shouldMountQuickActionsFab('/campanha/municipios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/territorios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas/foo', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/apoiadores/42', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-atualizacao', 'advisor')).toBe(
false,
)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'advisor')).toBe(false)
})
it('mounts for leader only on contacts', () => {
expect(shouldMountQuickActionsFab('/campanha/meus-contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/municipios', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(false)
})
it('skips the E13 tour composer (B84)', () => {
expect(isActivityTourComposerPath(ACTIVITY_TOUR_COMPOSER_PATH)).toBe(true)
expect(shouldMountQuickActionsFab(ACTIVITY_TOUR_COMPOSER_PATH, 'coordinator')).toBe(false)
})
it('mounts assessores only for unrestricted roles (B87)', () => {
expect(shouldMountQuickActionsFab('/campanha/assessores', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/assessores/12', 'candidate')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/assessores', 'advisor')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/assessores/12', 'advisor')).toBe(false)
})
it('mounts on dobradinhas for staff (B83)', () => {
expect(shouldMountQuickActionsFab('/campanha/dobradinhas', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/dobradinhas/foo', 'advisor')).toBe(true)
})
it('omits FAB on perfil for leader (B90)', () => {
expect(shouldMountQuickActionsFab(CAMPAIGN_PROFILE_HOME, 'leader')).toBe(false)
})
})
describe('campaignQuickActionPaths (activities)', () => {
it('parses list and detail surfaces', () => {
expect(parseActivityQuickActionSurface(ACTIVITY_LIST_PATH)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface(`${ACTIVITY_LIST_PATH}/`)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface(ACTIVITY_DETAIL_ROOT_PATH)).toEqual({ kind: 'list' })
expect(
parseActivityQuickActionSurface(`${ACTIVITY_DETAIL_ROOT_PATH}/caminhada-centro`),
).toEqual({
kind: 'detail',
activitySlug: 'caminhada-centro',
})
})
it('ignores nova, giros and nested edit routes', () => {
expect(parseActivityQuickActionSurface('/campanha/atividades/nova')).toBeNull()
expect(parseActivityQuickActionSurface(ACTIVITY_TOUR_COMPOSER_PATH)).toBeNull()
expect(parseActivityQuickActionSurface('/campanha/atividades/foo/editar')).toBeNull()
})
})
describe('campaignQuickActionPaths (shared list helpers, C86+)', () => {
it('matches exactly with an optional trailing slash', () => {
expect(isListPath('/campanha/demandas', '/campanha/demandas')).toBe(true)
expect(isListPath('/campanha/demandas/', '/campanha/demandas')).toBe(true)
})
it('rejects siblings and descendants', () => {
expect(isListPath('/campanha/demandas/nova', '/campanha/demandas')).toBe(false)
expect(isListPath('/campanha/demandas/pedido-cairu', '/campanha/demandas')).toBe(false)
expect(isListPath('/campanha/municipios', '/campanha/demandas')).toBe(false)
})
it('normalizes a single trailing slash while preserving /', () => {
expect(normalizePathname('/campanha/')).toBe('/campanha')
expect(normalizePathname('/campanha')).toBe('/campanha')
expect(normalizePathname('/')).toBe('/')
expect(normalizePathname('/campanha/apoiadores/')).toBe('/campanha/apoiadores')
expect(normalizePathname('/campanha//')).toBe('/campanha/')
})
it('pins the degenerate inputs the inline matchers used to handle', () => {
expect(isListPath('/campanha/demandas//', '/campanha/demandas')).toBe(false)
expect(isListPath('', '/campanha/demandas')).toBe(false)
expect(isListPath('/', '/')).toBe(true)
})
})
describe('organizationQuickActions (B88)', () => {
it('parses list and detail surfaces', () => {
expect(parseOrganizationQuickActionSurface(ORGANIZATIONS_LIST_PATH)).toEqual({ kind: 'list' })
expect(parseOrganizationQuickActionSurface(`${ORGANIZATIONS_LIST_PATH}/`)).toEqual({
kind: 'list',
})
expect(parseOrganizationQuickActionSurface('/campanha/organizacoes/sindmed')).toEqual({
kind: 'detail',
organizationSlug: 'sindmed',
})
})
it('ignores nova and nested routes', () => {
expect(parseOrganizationQuickActionSurface(ORGANIZATION_NEW_PATH)).toBeNull()
expect(parseOrganizationQuickActionSurface('/campanha/organizacoes/foo/editar')).toBeNull()
})
it('lists new-organization on the catalog page', () => {
const actions = resolveOrganizationQuickActions({ kind: 'list' }, 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(['new-organization'])
expect(actions[0]?.href).toBe(ORGANIZATION_NEW_PATH)
})
it('returns empty detail catalog without leadership filter URL', () => {
expect(
resolveOrganizationQuickActions(
{ kind: 'detail', organizationSlug: 'sindmed' },
'coordinator',
{ organizationSlug: 'sindmed' },
),
).toEqual([])
})
it('returns empty catalog for leader lockdown', () => {
expect(resolveOrganizationQuickActions({ kind: 'list' }, 'leader', {})).toEqual([])
})
})
describe('activityQuickActions (B84)', () => {
it('lists vertical verbs on the activity list', () => {
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {})
// C123 — "Nova atividade" opens the agenda overlay via the page-bridged
// context; without a bridge the entry is omitted (never a dead /nova href).
expect(actions.map((action) => action.id)).toEqual(['plan-tour'])
expect(actions[0]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
})
it('bridges "Nova atividade" to the overlay when the context is registered', () => {
const openActivityCreate = () => undefined
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {
openActivityCreate,
})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
expect(actions[0]?.href).toBeUndefined()
expect(actions[0]?.onAction).toBe(openActivityCreate)
})
it('prefills wizards and detail shortcuts on activity detail', () => {
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'comicio-feira' },
'coordinator',
{ activitySlug: 'comicio-feira', municipalitySlug: 'feira-de-santana' },
)
expect(actions.some((action) => action.id === 'update-votes')).toBe(true)
expect(
actions
.find((action) => action.id === 'update-votes')
?.href?.includes('municipio=feira-de-santana'),
).toBe(true)
// C123 — "Editar" is bridged by the detail page; without the bridge it is
// omitted, never a dead /editar href.
expect(actions.find((action) => action.id === 'edit-activity')).toBeUndefined()
expect(actions.find((action) => action.id === 'activity-tasks')?.href).toBe(
'/campanha/atividades/comicio-feira?tab=tasks',
)
})
it('bridges "Editar" to the overlay when the context is registered', () => {
const openActivityEdit = () => undefined
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'panfletagem' },
'advisor',
{ activitySlug: 'panfletagem', openActivityEdit },
)
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBeUndefined()
expect(actions.find((action) => action.id === 'edit-activity')?.onAction).toBe(openActivityEdit)
})
it('omits wizard prefills without municipality context', () => {
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'panfletagem' },
'advisor',
{ activitySlug: 'panfletagem' },
)
expect(actions.some((action) => action.id === 'register-demand')).toBe(false)
expect(actions.map((action) => action.id)).toEqual(['activity-tasks', 'activity-updates'])
})
})
describe('campaignQuickActionDobradinhas (B83)', () => {
const staffActionIds = homeActionsForRole('coordinator').map((action) => action.id)
it('recognizes list, create and detail surfaces', () => {
expect(matchesDobradinhasQuickActionSurface('/campanha/dobradinhas')).toBe(true)
expect(matchesDobradinhasQuickActionSurface('/campanha/dobradinhas/nova')).toBe(true)
expect(matchesDobradinhasQuickActionSurface('/campanha/dobradinhas/eduardo-alves')).toBe(true)
expect(matchesDobradinhasQuickActionSurface('/campanha/municipios')).toBe(false)
})
it('parses detail slug and rejects nova', () => {
expect(parseStateDeputyDetailSlug('/campanha/dobradinhas/eduardo-alves')).toBe('eduardo-alves')
expect(parseStateDeputyDetailSlug('/campanha/dobradinhas/nova')).toBeUndefined()
})
it('returns list catalog with Nova dobradinha plus staff Início actions without prefill', () => {
const actions = resolveDobradinhasQuickActions('/campanha/dobradinhas', 'coordinator')
expect(actions.map((action) => action.id)).toEqual(['new-state-deputy', ...staffActionIds])
expect(actions[0]?.href).toBe('/campanha/dobradinhas/nova')
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fdobradinhas',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
)
})
it('returns staff actions without Nova on detail and create', () => {
for (const pathname of ['/campanha/dobradinhas/eduardo-alves', '/campanha/dobradinhas/nova']) {
const actions = resolveDobradinhasQuickActions(pathname, 'advisor')
expect(actions.map((action) => action.id)).toEqual(staffActionIds)
expect(actions.some((action) => action.id === 'new-state-deputy')).toBe(false)
}
})
it('returns empty for leader lockdown', () => {
expect(resolveDobradinhasQuickActions('/campanha/dobradinhas', 'leader')).toEqual([])
})
})
describe('campaignQuickActionRegistry', () => {
const staffActionIds = homeActionsForRole('coordinator').map((action) => action.id)
it('delegates organization routes to the B88 catalog', () => {
const actions = resolveQuickActionsForPath(ORGANIZATIONS_LIST_PATH, 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(['new-organization'])
expect(actions[0]?.href).toBe(ORGANIZATION_NEW_PATH)
})
it('returns empty organization detail catalog in the registry', () => {
expect(
resolveQuickActionsForPath('/campanha/organizacoes/sindmed', 'advisor', {
organizationSlug: 'sindmed',
}),
).toEqual([])
})
it('returns leadership list catalog on /liderancas (B82)', () => {
const actions = resolveQuickActionsForPath('/campanha/liderancas', 'coordinator', {})
expect(actions).toHaveLength(1)
expect(actions[0]?.id).toBe('update-leadership')
})
it('returns empty catalog for unregistered paths', () => {
expect(resolveQuickActionsForPath('/campanha/lugar-inexistente', 'coordinator', {})).toEqual([])
})
it('returns empty catalog on conceitos and perfil for staff (B90)', () => {
expect(resolveQuickActionsForPath(CAMPAIGN_CONCEPTS_PATH, 'coordinator', {})).toEqual([])
expect(resolveQuickActionsForPath(CAMPAIGN_PROFILE_HOME, 'candidate', {})).toEqual([])
})
it('delegates municipality list and detail catalogs (B80)', () => {
const list = resolveQuickActionsForPath('/campanha/municipios', 'coordinator', {})
expect(list).toHaveLength(6)
const detail = resolveQuickActionsForPath('/campanha/municipios/foo', 'coordinator', {
municipalitySlug: 'foo',
})
expect(detail).toHaveLength(5)
expect(detail.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=foo&from=%2Fcampanha%2Fmunicipios%2Ffoo',
)
})
it('delegates activity routes to the B84 catalog', () => {
// C123 — "Nova atividade" only appears once the agenda bridge registers.
expect(
resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {}).map((action) => action.id),
).toEqual(['plan-tour'])
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {
openActivityCreate: () => undefined,
})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
})
it('delegates dobradinhas paths to the B83 catalog', () => {
const actions = resolveQuickActionsForPath('/campanha/dobradinhas', 'coordinator', {})
expect(actions.some((action) => action.id === 'new-state-deputy')).toBe(true)
})
it('builds wizard hrefs with municipality slug from context', () => {
const actions = resolveQuickActionsForPath(
'/campanha/atividades/evento-zona-1',
'coordinator',
{
municipalitySlug: 'salvador-ze-01',
},
)
const registerUpdate = actions.find((action) => action.id === 'register-update')
expect(registerUpdate?.href).toBe(
`/campanha/acoes/${CAMPAIGN_WIZARD_ACTION_SLUGS['register-update']}?municipio=salvador-ze-01&from=%2Fcampanha%2Fatividades%2Fevento-zona-1`,
)
})
it('returns staff Início catalog on territorios without municipality prefill (B81)', () => {
const actions = resolveQuickActionsForPath('/campanha/territorios', 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(staffActionIds)
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?from=%2Fcampanha%2Fterritorios',
)
expect(actions.find((action) => action.id === 'uncovered-municipalities')?.href).toBe(
UNCOVERED_MUNICIPALITIES_LIST_HREF,
)
for (const action of actions) {
expect(action.href).not.toContain('municipio=')
}
})
it('returns empty catalog on territorios for leader lockdown', () => {
expect(resolveQuickActionsForPath('/campanha/territorios', 'leader', {})).toEqual([])
})
it('returns single register-demand launcher on demandas list (B85)', () => {
const actions = resolveQuickActionsForPath('/campanha/demandas', 'coordinator', {})
expect(actions.map((action) => action.id)).toEqual(['register-demand'])
expect(actions[0]?.href).toBe(CAMPAIGN_DEMANDS_CREATE_HREF)
expect(actions[0]?.href).not.toContain('municipio=')
})
it('returns A1–A5 prefilled on demand detail when municipality context is set (B85)', () => {
const actions = resolveQuickActionsForPath('/campanha/demandas/pedido-cairu', 'coordinator', {
municipalitySlug: 'cairu',
municipalityId: 42,
demandSlug: 'pedido-cairu',
})
expect(actions.map((action) => action.id)).toEqual([
'update-votes',
'register-update',
'change-trend',
'update-leadership',
'register-demand',
])
expect(actions.find((action) => action.id === 'update-votes')?.href).toBe(
'/campanha/acoes/atualizar-votos?municipio=cairu&from=%2Fcampanha%2Fdemandas%2Fpedido-cairu',
)
expect(actions.find((action) => action.id === 'register-demand')?.href).toBe(
demandCreateHref(42),
)
expect(actions.some((action) => action.id === 'uncovered-municipalities')).toBe(false)
})
it('returns empty catalog on demand detail without municipality context', () => {
expect(
resolveQuickActionsForPath('/campanha/demandas/pedido-cairu', 'coordinator', {}),
).toEqual([])
})
it('delegates assessores list and detail catalogs (B87)', () => {
const list = resolveQuickActionsForPath('/campanha/assessores', 'coordinator', {})
expect(list.map((action) => action.id)).toEqual(['new-advisor'])
expect(list[0]?.href).toBe(advisorQuickCreateHref)
const detail = resolveQuickActionsForPath('/campanha/assessores/9', 'candidate', {})
expect(detail.map((action) => action.id)).toEqual(['new-advisor'])
expect(detail[0]?.href).toBe(advisorQuickCreateHref)
})
it('returns empty catalog on assessores for advisor lockdown', () => {
expect(resolveQuickActionsForPath('/campanha/assessores', 'advisor', {})).toEqual([])
})
it('delegates apoiadores list and detail catalogs (B86)', () => {
const list = resolveQuickActionsForPath('/campanha/apoiadores', 'coordinator', {})
expect(list.map((action) => action.id)).toEqual(['register-supporter', 'import-supporters'])
expect(list[0]?.href).toBe(SUPPORTER_CREATE_HREF)
expect(list[1]?.href).toBe(SUPPORTER_IMPORT_HREF)
const advisorList = resolveQuickActionsForPath('/campanha/apoiadores', 'advisor', {})
expect(advisorList.map((action) => action.id)).toEqual(['register-supporter'])
const detail = resolveQuickActionsForPath('/campanha/apoiadores/9', 'candidate', {})
expect(detail.map((action) => action.id)).toEqual(['register-supporter'])
const listTrailingSlash = resolveQuickActionsForPath('/campanha/apoiadores/', 'advisor', {})
expect(listTrailingSlash.map((action) => action.id)).toEqual(['register-supporter'])
const detailTrailingSlash = resolveQuickActionsForPath(
'/campanha/apoiadores/9/',
'candidate',
{},
)
expect(detailTrailingSlash.map((action) => action.id)).toEqual(['register-supporter'])
expect(resolveQuickActionsForPath('/campanha/apoiadores', 'leader', {})).toEqual([])
})
})
describe('campaignQuickActionDemands paths', () => {
it('matches list and detail paths but not nova', () => {
expect(isDemandsListPath('/campanha/demandas')).toBe(true)
expect(isDemandsListPath('/campanha/demandas/')).toBe(true)
expect(isDemandDetailPath('/campanha/demandas/pedido-cairu')).toBe(true)
expect(isDemandDetailPath('/campanha/demandas/nova')).toBe(false)
expect(isDemandDetailPath('/campanha/demandas')).toBe(false)
})
})
describe('campaignQuickActionDemands resolvers', () => {
it('returns empty catalog for leader lockdown', () => {
expect(resolveDemandsListQuickActions('leader')).toEqual([])
expect(
resolveDemandDetailQuickActions(
'leader',
{ municipalitySlug: 'cairu', municipalityId: 1 },
'/campanha/demandas/foo',
),
).toEqual([])
})
})