teqo-backup/tests/unit/activityUi.unit.spec.ts

537 lines
17 KiB
TypeScript
Raw 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 {
activityAgendaViewFcId,
activityAgendaViewLabels,
activityAgendaViews,
activitySlotPrefill,
activityTabs,
buildActivityAgendaHref,
buildActivityAgendaSearchParams,
buildActivityAgendaWhere,
buildActivityCreateHref,
buildActivityFiltersKey,
buildActivityListHref,
buildActivityListSearchParams,
buildActivityListWhere,
parseActivityAgendaParams,
parseActivityAgendaReturnHref,
parseActivityCreatePrefill,
parseActivityListParams,
resolveActivityAgendaUrl,
resolveActivityListUrl,
restrictActivityAgendaState,
} from '@/utilities/activityUi'
const NOW = new Date('2026-07-25T12:00:00.000Z')
describe('parseActivityListParams', () => {
it('defaults to the proximos tab on page 1', () => {
expect(parseActivityListParams({})).toEqual({ page: 1, tab: 'proximos' })
})
it('falls back to proximos on an unknown tab', () => {
expect(parseActivityListParams({ tab: 'passados' }).tab).toBe('proximos')
})
it('accepts every declared tab', () => {
for (const tab of activityTabs) {
expect(parseActivityListParams({ tab }).tab).toBe(tab)
}
})
it('honors status only on the todos tab', () => {
expect(parseActivityListParams({ status: 'realizado' }).status).toBeUndefined()
expect(parseActivityListParams({ tab: 'todos', status: 'realizado' }).status).toBe('realizado')
expect(parseActivityListParams({ tab: 'todos', status: 'inexistente' }).status).toBeUndefined()
})
it('validates tag and municipality', () => {
expect(parseActivityListParams({ tag: 'Caminhada' }).tag).toBe('Caminhada')
expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined()
})
it('parses and trims q', () => {
expect(parseActivityListParams({ q: ' comício ' }).q).toBe('comício')
expect(parseActivityListParams({ q: ' ' }).q).toBeUndefined()
expect(parseActivityListParams({ q: 'a' }).q).toBeUndefined()
})
})
describe('buildActivityListWhere (tab → where matrix)', () => {
it('proximos = confirmado starting from now', () => {
const where = buildActivityListWhere({ page: 1, tab: 'proximos' }, NOW)
expect(where).toEqual({
and: [
{ status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
it('realizados filters by its status', () => {
expect(buildActivityListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
and: [{ status: { equals: 'realizado' } }],
})
})
it('todos is unfiltered unless a status is chosen', () => {
expect(buildActivityListWhere({ page: 1, tab: 'todos' }, NOW)).toEqual({})
expect(buildActivityListWhere({ page: 1, tab: 'todos', status: 'cancelado' }, NOW)).toEqual({
and: [{ status: { equals: 'cancelado' } }],
})
})
it('prepends tag and municipality filters on any tab', () => {
const where = buildActivityListWhere(
{ page: 1, tab: 'realizados', tag: 'Comício', municipality: 3 },
NOW,
)
expect(where).toEqual({
and: [
{ tags: { contains: 'Comício' } },
{ municipality: { equals: 3 } },
{ status: { equals: 'realizado' } },
],
})
})
it('filters by title when q is set (C90 — responsible is polymorphic, title-only)', () => {
const where = buildActivityListWhere({ page: 1, tab: 'proximos', q: 'Maria' }, NOW)
expect(where).toEqual({
and: [
{ title: { contains: 'Maria' } },
{ status: { equals: 'confirmado' } },
{ startAt: { greater_than_equal: NOW.toISOString() } },
],
})
})
})
describe('buildActivityListSearchParams', () => {
it('omits the default tab and page 1', () => {
expect(buildActivityListSearchParams({ page: 1, tab: 'proximos' }).toString()).toBe('')
expect(buildActivityListSearchParams({ page: 2, tab: 'todos' }).toString()).toBe(
'tab=todos&page=2',
)
})
it('serializes filters in canonical order', () => {
const params = buildActivityListSearchParams({
page: 1,
tab: 'todos',
q: 'comício',
tag: 'Caminhada',
status: 'cancelado',
municipality: 9,
})
expect(params.toString()).toBe(
'q=com%C3%ADcio&tab=todos&tag=Caminhada&status=cancelado&municipality=9',
)
})
})
describe('hrefs and canonical URL resolution', () => {
it('builds hrefs against the atividades base path', () => {
expect(buildActivityListHref({ page: 1, tab: 'proximos' }, 1)).toBe('/campanha/atividades')
expect(buildActivityListHref({ page: 1, tab: 'realizados' }, 2)).toBe(
'/campanha/atividades?tab=realizados&page=2',
)
})
it('derives the filters key from the canonical serialization', () => {
expect(buildActivityFiltersKey({ page: 1, tab: 'todos', tag: 'Comício' })).toBe(
'tab=todos&tag=Com%C3%ADcio',
)
})
it('redirects an explicit default tab out of the URL', () => {
const result = resolveActivityListUrl({ tab: 'proximos' })
expect(result.href).toBe('/campanha/atividades')
expect(result.redirectHref).toBe('/campanha/atividades')
})
it('redirects on unsupported params and clamps the page', () => {
expect(resolveActivityListUrl({ foo: 'bar' }).redirectHref).toBe('/campanha/atividades')
const clamped = resolveActivityListUrl({ tab: 'todos', page: '50' }, 2)
expect(clamped.state.page).toBe(2)
expect(clamped.redirectHref).toBe('/campanha/atividades?tab=todos&page=2')
})
})
describe('activity agenda URL contract', () => {
it('parses the three agenda filters fail-closed', () => {
expect(
parseActivityAgendaParams({
municipality: '12',
deputyPresent: '1',
tag: ' Comício ',
}),
).toEqual({ municipality: 12, deputyPresent: true, tag: 'Comício' })
expect(
parseActivityAgendaParams({ municipality: '0', deputyPresent: 'true', tag: ' ' }),
).toEqual({})
})
it('serializes agenda filters in one canonical order', () => {
const state = { municipality: 12, deputyPresent: true as const, tag: 'Comício' }
expect(buildActivityAgendaSearchParams(state).toString()).toBe(
'municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
)
expect(buildActivityAgendaHref(state)).toBe(
'/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
)
})
it('canonicalizes invalid and unsupported agenda params', () => {
expect(
resolveActivityAgendaUrl({
municipality: '12',
deputyPresent: 'true',
tag: ' Comício ',
page: '2',
}).redirectHref,
).toBe('/campanha/agenda?municipality=12&tag=Com%C3%ADcio')
})
it('drops municipality and tag filters outside the actor accessible options', () => {
expect(
restrictActivityAgendaState(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
new Set([13]),
new Set(['Reunião']),
),
).toEqual({ deputyPresent: true })
})
})
describe('activity agenda view modes (C95)', () => {
it('parses only the declared view codes fail-closed', () => {
expect(parseActivityAgendaParams({ view: 'month' }).view).toBe('month')
expect(parseActivityAgendaParams({ view: 'week' }).view).toBe('week')
expect(parseActivityAgendaParams({ view: 'day' }).view).toBe('day')
expect(parseActivityAgendaParams({ view: 'list' }).view).toBe('list')
expect(parseActivityAgendaParams({ view: 'bogus' }).view).toBeUndefined()
expect(parseActivityAgendaParams({ view: 'timeGridMonth' }).view).toBeUndefined()
})
it('maps every declared view code to a FullCalendar view id', () => {
expect(activityAgendaViews).toEqual(['week', 'day', 'month', 'list'])
expect(activityAgendaViewFcId).toEqual({
week: 'timeGridWeek',
day: 'timeGridDay',
month: 'dayGridMonth',
list: 'listMonth',
})
})
it('labels every declared view mode in pt-BR for the header selector', () => {
expect(activityAgendaViewLabels).toEqual({
week: 'Semana',
day: 'Dia',
month: 'Mês',
list: 'Lista',
})
})
it('serializes the view last in the canonical agenda query', () => {
expect(
buildActivityAgendaSearchParams({
municipality: 12,
deputyPresent: true,
tag: 'Comício',
view: 'month',
}).toString(),
).toBe('municipality=12&deputyPresent=1&tag=Com%C3%ADcio&view=month')
expect(buildActivityAgendaHref({ view: 'day' })).toBe('/campanha/agenda?view=day')
})
it('canonicalizes an unknown view out of the URL', () => {
expect(resolveActivityAgendaUrl({ view: 'bogus', municipality: '12' }).redirectHref).toBe(
'/campanha/agenda?municipality=12',
)
expect(resolveActivityAgendaUrl({ view: 'month' }).redirectHref).toBeUndefined()
})
it('keeps the view when restricting the agenda state', () => {
expect(
restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()),
).toEqual({ view: 'list' })
})
it('round-trips the view through the create return href', () => {
expect(
parseActivityAgendaReturnHref(
'/campanha/agenda?municipality=12&view=month',
new Set([12]),
new Set(),
),
).toBe('/campanha/agenda?municipality=12&view=month')
expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe(
'/campanha/agenda',
)
})
})
describe('buildActivityAgendaWhere', () => {
it('combines visible-range overlap with municipality, deputy and tag filters', () => {
expect(
buildActivityAgendaWhere(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
'2026-08-03T03:00:00.000Z',
'2026-08-10T03:00:00.000Z',
),
).toEqual({
and: [
{ startAt: { less_than: '2026-08-10T03:00:00.000Z' } },
{
or: [
{ endAt: { greater_than: '2026-08-03T03:00:00.000Z' } },
{
and: [
{ endAt: { exists: false } },
{ startAt: { greater_than_equal: '2026-08-03T03:00:00.000Z' } },
],
},
],
},
{ municipality: { equals: 12 } },
{ deputyPresent: { equals: true } },
{ tags: { contains: 'Comício' } },
],
})
})
})
describe('activity create prefill', () => {
it('carries the canonical agenda return URL into creation', () => {
expect(
buildActivityCreateHref(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
{
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T14:00:00.000Z',
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T14%3A00%3A00.000Z&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12%26deputyPresent%3D1%26tag%3DCom%25C3%25ADcio',
)
})
it('accepts only accessible canonical agenda return URLs', () => {
expect(
parseActivityAgendaReturnHref(
'/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
new Set([12]),
new Set(['Comício']),
),
).toBe('/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio')
expect(
parseActivityAgendaReturnHref(
'https://example.com/campanha/agenda?municipality=12',
new Set([12]),
new Set(),
),
).toBe('/campanha/agenda')
})
it('normalizes a valid slot and accepts only an accessible municipality', () => {
expect(
parseActivityCreatePrefill(
{
startAt: '2026-08-07T10:00:00-03:00',
endAt: '2026-08-07T11:00:00-03:00',
municipality: '12',
},
new Set([12]),
),
).toEqual({
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T14:00:00.000Z',
municipalityId: 12,
})
})
it('drops an invalid slot and an out-of-scope municipality', () => {
expect(
parseActivityCreatePrefill(
{
startAt: 'sexta de manhã',
endAt: '2026-08-07T11:00:00-03:00',
municipality: '99',
},
new Set([12]),
),
).toEqual({})
})
it('keeps a valid start but drops an inverted end', () => {
expect(
parseActivityCreatePrefill(
{
startAt: '2026-08-07T10:00:00-03:00',
endAt: '2026-08-07T09:00:00-03:00',
},
new Set(),
),
).toEqual({ startAt: '2026-08-07T13:00:00.000Z' })
})
it('carries the inline title into the creation URL (C91)', () => {
expect(
buildActivityCreateHref(
{ tag: 'Comício' },
{
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T13:30:00.000Z',
municipalityId: 3,
title: 'Café com apoiadores',
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T13%3A30%3A00.000Z&municipality=3&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Ftag%3DCom%25C3%25ADcio',
)
})
it('prefers the inline municipality over the agenda filter', () => {
expect(buildActivityCreateHref({ municipality: 12 }, { municipalityId: 3 })).toBe(
'/campanha/atividades/nova?municipality=3&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('parses a bounded inline title and drops an oversized one', () => {
expect(parseActivityCreatePrefill({ title: ' Café com apoiadores ' }, new Set())).toEqual({
title: 'Café com apoiadores',
})
expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({})
})
it('carries inline tags into the creation URL as repeated params (C105)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
startAt: '2026-08-07T13:00:00.000Z',
municipalityId: 12,
title: 'Panfletagem',
tags: ['Panfletagem', 'Caminhada, café'],
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&municipality=12&title=Panfletagem&tags=Panfletagem&tags=Caminhada%2C+caf%C3%A9&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('round-trips inline tags through the create prefill (C105)', () => {
expect(
parseActivityCreatePrefill(
{ tags: [' Panfletagem ', 'Caminhada, café', 'Panfletagem'] },
new Set(),
),
).toEqual({ tags: ['Panfletagem', 'Caminhada, café'] })
})
it('drops oversized and out-of-bound tags from the prefill (C105)', () => {
const oversized = 'a'.repeat(81)
expect(parseActivityCreatePrefill({ tags: [oversized, ' '] }, new Set())).toEqual({})
expect(parseActivityCreatePrefill({ tags: ['válida', oversized] }, new Set())).toEqual({
tags: ['válida'],
})
})
it('carries the all-day choice as civil dates into the creation URL (C104)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
allDay: true,
startAt: '2026-08-10',
endAt: '2026-08-12',
municipalityId: 12,
},
),
).toBe(
'/campanha/atividades/nova?allDay=1&startAt=2026-08-10&endAt=2026-08-12&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('parses an all-day prefill into day-boundary instants', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-12',
municipality: '12',
},
new Set([12]),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
municipalityId: 12,
})
})
it('allows a single-day all-day prefill (end equals start)', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
})
})
it('drops an inverted all-day end and malformed all-day dates', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-12',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({ allDay: true, startAt: '2026-08-12T03:00:00.000Z' })
expect(
parseActivityCreatePrefill(
{ allDay: '1', startAt: '10/08/2026', endAt: '2026-08-10' },
new Set(),
),
).toEqual({ allDay: true, endAt: '2026-08-10T03:00:00.000Z' })
expect(parseActivityCreatePrefill({ allDay: '0' }, new Set())).toEqual({})
})
})
describe('activity slot prefill (C91)', () => {
it('uses the snapped slot interval for time grids', () => {
expect(activitySlotPrefill({ allDay: false, dateStr: '2026-08-07T13:00:00-03:00' })).toEqual({
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
})
})
it('falls back to the 09:00–10:00 window on an all-day click', () => {
expect(activitySlotPrefill({ allDay: true, dateStr: '2026-08-07' })).toEqual({
startAt: '2026-08-07T12:00:00.000Z',
endAt: '2026-08-07T13:00:00.000Z',
})
})
it('returns null for an unparseable date', () => {
expect(activitySlotPrefill({ allDay: false, dateStr: 'não é uma data' })).toBeNull()
expect(activitySlotPrefill({ allDay: true, dateStr: 'não é uma data' })).toBeNull()
})
})