fix(S29-FOLLOWUP): .ics do link de anúncio e feed — folding RFC 5545 (75 octets) no owner ical.ts (Closes #1281)

This commit is contained in:
Francisco Solla 2026-09-23 13:48:29 -03:00
parent c6620c2511
commit 48a01de58b
8 changed files with 295 additions and 11 deletions

View file

@ -0,0 +1 @@
**S29-FOLLOWUP — `.ics` do link de anúncio: folding RFC 5545 (75 octets) no owner `ical.ts` (Issue #1281, 2026-09-23):** débito triado na review do S29 (#1268/PR #1280) — o `.ics` do link de anúncio e o feed da agenda emitiam `DESCRIPTION` (e qualquer linha) inteira numa linha só, podendo passar de 75 octets; Apple/Outlook toleravam, parser estrito poderia truncar. O owner compartilhado `src/lib/ical.ts` ganha `foldICalLine`: linha lógica → físicas dobradas com `CRLF + 1 espaço` (1ª ≤75 octets, continuações com payload ≤74, o espaço conta), medindo octets UTF-8 reais com `TextEncoder`/`TextDecoder` (web-standard — o módulo continua client-safe, sem `server-only`/`Buffer`, porque `calendarEvent` chega ao bundle pelo `ShareLinkAgendaMenu`) e cortando sempre em boundary de code point (nunca parte "ç"/"í"/"中"); linha ≤75 sai idêntica (fast-path), então nenhum conteúdo/ETag de feed curto muda. Wiring nos 2 call sites (`buildCalendarEventIcs` e `generateICalFeed`) via `lines.map(foldICalLine).join('\r\n')`; sem migration, sem rota, sem URL nova. Unit novo `tests/unit/ical.unit.spec.ts` (8 casos: identidade, 76 dobrando, ASCII longo com boundary 75/74, multi-byte sem `\uFFFD`, backoff em boundary e na continuação, os 2 builders) e o e2e `campaignAgendaFeed` passou a desdobrar (`unfoldICal`, `replace(/\r\n /g, '')`) antes de casar título lógico — o run local afetado pegou as 2 asserções ingênuas de substring cru; 9/9 verdes. Deferidos com gatilho no `*-impl.md`: escaping do `DESCRIPTION` do feed (newline cru do usuário) e helper de unfold compartilhado unit/e2e.

View file

@ -0,0 +1,97 @@
# Impl: S29-FOLLOWUP — .ics do link de anúncio: folding RFC 5545 (75 octets) no owner ical.ts
Status: aprovado
Atualizado em: 2026-09-23
Issue: #1281
Intenção: Issue #1281 (body = spec; sem arquivo de intenção no repo — triagem da review do S29, #1268 / PR #1280)
Appetite restante: herdado — P3/chore de review, sem appetite explícito na intenção; corte explícito: ~meio dia (1 primitiva pura no owner + 2 wirings + 1 spec unit), sem tocar rota, admin, schema ou UI.
## Leitura da intenção
- **Outcome**: o `.ics` do link de anúncio e o feed da agenda passam a emitir linhas físicas ≤75 octets (RFC 5545 §3.1), dobrando com `CRLF + 1 espaço` e sem partir caractere multi-byte. Após unfold (`replace(/\r\n /g, '')`), o texto lógico — campos, escapes, datas, ordem — é byte-a-byte o de hoje; o contrato das rotas (`/slug/evento.ics`, feed `/campanha/agenda/ical/...`) e o comportamento de Apple/Outlook ficam idênticos.
- **O que NÃO negociar**: owner único `src/lib/ical.ts` (escopo explícito do issue); os 2 call sites `lib/calendarEvent.ts` e `utilities/calendarFeed.ts`; `lib/` client-safe (sem `server-only`/`Buffer` — `calendarEvent` é importado por `ShareLinkAgendaMenu` `'use client'`); separador CRLF; specs existentes verdes (asserções exatas de CRLF em `calendarEvent.unit.spec.ts:91-99`); sem schema/migration, sem mudança de URL ou de conteúdo.
- **O que reavaliar**: nada de produto neste delivery. Se um parser estrito real quebrar, o gatilho é a evidência concreta. O `DESCRIPTION` do feed hoje interpola as partes cruas (sem `escapeICalText`) — concern pré-existente e separado, não entra aqui.
## Abordagem recomendada
```mermaid
flowchart LR
A["buildCalendarEventIcs<br/>lines: string[]"] --> C["lines.map(foldICalLine)<br/>.join(CRLF)"]
B["generateICalFeed<br/>lines: string[]"] --> C
C --> D["src/lib/ical.ts<br/>foldICalLine"]
D --> E["TextEncoder<br/>octets UTF-8"]
E --> F["1ª física ≤75 octets<br/>continuação = CRLF + espaço + ≤74<br/>corte em boundary de code point"]
```
### Decisões registradas (só o caro de reverter)
- **D1 — Assinatura do folding**: Opções: A `foldICalLine(line: string): string` (linha lógica → texto dobrado com `\r\n ` embutido) | B `foldICalLines(lines: string[]): string[]` | C folding inline em cada call site. **Recomendação: A — porque** é a menor primitiva que casa com o estilo do owner (`escapeICalText`/`formatICalDate` são 1 valor → 1 valor), mantém o `lines.join('\r\n')` existente (vira `lines.map(foldICalLine).join('\r\n')`) e dá ao unit exatamente a unidade RFC: uma linha lógica → suas físicas. **Alternativas rejeitadas**: B é pass-through raso (`lines.map` embrulhado no owner) que não adiciona conhecimento — depth check manda não criar; C duplicaria a mesma máquina nos 2 call sites e contraria o escopo ("folding no owner").
- **D2 — Unidade de medida**: Opções: A `TextEncoder` (web-standard, global em Node ≥11, browser e jsdom) | B `Buffer.byteLength` (node-only) | C `line.length` (code units UTF-16). **Recomendação: A — porque** mede octets UTF-8 reais sem `server-only`, preservando o contrato client-safe de `lib/` (o `ical.ts` chega ao bundle via `ShareLinkAgendaMenu`). **Alternativas rejeitadas**: B quebra o client boundary (o precedente `src/lib/formData.ts:213` só é válido porque aquele arquivo é `server-only`); C mede pt-BR acentuado a menos (ex. "ç"/"í" = 2 octets mas 1 code unit) e deixaria passar linha >75 octets.
- **D3 — Regra de corte e forma de retorno**: Opções: A recebe linha lógica sem CRLF e devolve a linha com dobras (`\r\n` + 1 espaço; 1ª física ≤75 octets, continuações com payload ≤74; corte sempre em boundary de code point) | B devolver `string[]` de físicas para o call site fazer `flatMap(...).join('\r\n')`. **Recomendação: A — porque** a semântica de dobra fica inteira no owner (uma string iCal válida de saída), o join por `\r\n` continua sendo só o separador entre linhas lógicas, e o invariante de teste fica direto: `foldICalLine` curta = identidade byte-a-byte. **Alternativa rejeitada**: B espalha a semântica no call site e deixa a interface menos profunda, sem ganho.
- **D4 — Onde aplicar**: Opções: A nos 2 call sites, `lines.map(foldICalLine).join('\r\n')` antes do join | B helper de join no owner (`joinICalLines`) | C folding na rota/handler. **Recomendação: A — porque** é o único ponto onde as linhas lógicas existem por inteiro, cobre todas as linhas (não só `DESCRIPTION`) e não muda os contratos. **Alternativas rejeitadas**: B adiciona um segundo conceito ao owner para 2 usos, sem ganho sobre `.map` (e o join já é trivial); C fica longe do dado, duplicaria por superfície e não cobriria consumidores futuros do owner.
- **D5 — Testes**: Opções: A novo `tests/unit/ical.unit.spec.ts` (função + os 2 builders), mantendo os specs existentes como regressão | B só adicionar casos aos specs existentes. **Recomendação: A — porque** o owner é compartilhado e passa a ter spec próprio (o comentário do topo promete "pinned by unit tests"); B duplicaria o helper de boundary em 2 arquivos e não fixaria a primitiva. **Rejeitada**: B.
### Componentes / mudanças
1. `src/lib/ical.ts` — novo export `foldICalLine(line: string): string` com JSDoc do contrato (linha lógica sem CRLF; identidade se ≤75 octets; continuação `CRLF + 1 espaço`; nunca parte multi-byte). Constantes privadas `MAX_OCTETS = 75` e `CONTINUATION_PAYLOAD_OCTETS = 74` (não exportar — RFC, não API; o unit assere literais 75/74 de propósito). Algoritmo: codifica a linha uma vez com `TextEncoder`; se ≤75 octets devolve a própria string; senão caminha os bytes e escolhe o maior corte ≤75 (e ≤`end + 74` nas continuações) cujo byte seja lead de sequência UTF-8 (`(byte & 0xc0) !== 0x80`), decodificando os pedaços com `TextDecoder` e unindo com `\r\n`. Há sempre boundary válido (nenhum code point passa de 4 octets), sem loop infinito.
2. `src/lib/calendarEvent.ts` — importar `foldICalLine` e trocar o `return lines.join('\r\n')` de `buildCalendarEventIcs:93` por `lines.map(foldICalLine).join('\r\n')`.
3. `src/utilities/calendarFeed.ts` — idem em `generateICalFeed:76` (import na linha 7).
4. `tests/unit/ical.unit.spec.ts` — novo spec (detalhe na fase 1/2).
5. `docs/changelog/2026-09-23-s29-followup-ics-folding.md` — entrada de 1 linha, intro em negrito.
Sem schema, sem migration, sem UI, sem rota nova.
### Dados → forma
- Entrada: `lines: string[]` lógicas, já escapadas/formatadas (`escapeICalText`/`formatICalDate`); nunca contêm CRLF real (newlines viram `\n` literal).
- Saída: o mesmo texto com dobras; `unfold` (`replace(/\r\n /g, '')`) restaura byte-a-byte o pré-folding — é o invariante dos testes.
- Exemplo (DESCRIPTION ~160 octets): 1ª física com 75 octets + `\r\n ` + 74 + `\r\n ` + resto; cada física ≤75 contando o espaço da continuação.
- Linhas curtas (todas as existentes em produção hoje além do `DESCRIPTION` longo) saem idênticas — zero diff de conteúdo/ETag para feeds sem descrição longa.
## Fases verificáveis
1. **Tracer — owner + unit (RED→GREEN)**: adicionar `foldICalLine` e `tests/unit/ical.unit.spec.ts` com (a) linha curta = identidade (sem CRLF acrescentado), (b) exatamente 75 octets intacta e 76 dobrando, (c) >150 octets ASCII com múltiplas continuações — cada física ≤75 e boundary exato 75/74, unfold === original, (d) acento multi-byte ("Comício" repetido) sem partir caractere: sem `\uFFFD` e unfold === original. Rodar `pnpm test:unit tests/unit/ical.unit.spec.ts`.
2. **Wiring dos 2 call sites + testes de builder**: `.map(foldICalLine)` antes do join em `buildCalendarEventIcs` e `generateICalFeed`; no mesmo spec, casos com descrição longa nos 2 builders — nenhuma física >75, unfold contém a linha lógica exata (`DESCRIPTION:...`), `BEGIN:VCALENDAR\r\n`/`END:VCALENDAR` e demais linhas intactos. Regressão: `tests/unit/calendarEvent.unit.spec.ts` e `tests/unit/calendarFeed.unit.spec.ts` verdes.
3. **Gates**: `pnpm gate:fast`; e2e afetados localmente — `pnpm test:e2e:affected` resolve `campaignAgendaFeed` + `frontendShareLink` pelo manifest (`scripts/lib/e2e-affected-manifest.mjs:112`); `pnpm push` (roda `gate:push` → `gate:ci`); changelog adicionado. Int não muda (sem access/write path), mas roda no `gate:ci`.
## Execução — achado do e2e local (2026-09-23)
O run local dos 2 specs afetados (`--no-deps --workers=1`) pegou 2 falhas reais: `campaignAgendaFeed.e2e.spec.ts` assertava `toContain(title)` com títulos longos de fixture (`Comício do feed completo-<uuid>`) como substring crua — depois do folding a UID/SUMMARY quebram em `CRLF + espaço`. É o consumidor ingênuo que a RFC manda desdobrar: o spec ganhou o helper local `unfoldICal` (`replace(/\r\n /g, '')`) e os corpos passam a ser desdobrados antes do match; 9/9 verdes. Nenhuma mudança de produção — a superfície `campaignAgendaFeed` era exatamente a que o manifest mapeia para o owner. O `gate:ci` pegou a MESMA classe em `tests/int/calendarFeed.int.spec.ts:524` (título longo de fixture como substring crua no teste C113): mesmo helper `unfoldICal` aplicado aos 3 corpos; 18/18 verdes.
## Rabbit holes / Não escopo (engenharia)
- **Rate limiting da rota `/api/share-link/[slug]/live`** — deferido (fora do issue); gatilho: pico anômalo de requisições na rota.
- **Settle dev-only de 6s no e2e `frontendShareLink`** — deferido; gatilho: primeira flake local do spec.
- **Unfold/parse de ICS de entrada** (imports C165) — só geração aqui; nenhuma leitura de calendário neste delivery.
- **Escaping do `DESCRIPTION` do feed** (partes cruas, sem `escapeICalText`) — concern pré-existente; não tocar para não misturar com folding. Gatilho: quebra real de parser/consumidor por newline cru (o fold exige linha lógica sem CRLF e o call site não garante).
- **Helper de unfold compartilhado entre unit e e2e** (`unfold` × `unfoldICal`, one-liner em 2 camadas) — deferido. Gatilho: 3º consumidor de unfold.
- **Refatorar os 2 builders para um line-array compartilhado** — redesign fora de escopo; o owner `ical.ts` já é o ponto de reuso.
- **Quoted-printable/base64/parameter folding** — não emitimos esses formatos; só valores `text` simples.
- **Tornar o limite configurável/exportar constantes** — YAGNI; a RFC fixa 75 e o unit pina o literal.
## Riscos e mitigação
- **Quebrar as asserções exatas existentes** → identidade para ≤75 é o fast path; caso (a) + regressão dos specs de `calendarEvent`/`calendarFeed`.
- **Partir caractere multi-byte** → corte só em lead byte; caso (d) com "Comício" e ausência de `\uFFFD`.
- **Off-by-one da continuação (o espaço conta nos 75)** → caso (c) assere física ≤75 e boundary 75/74.
- **LF em vez de CRLF** → dobras usam CRLF; unfold do teste é literal `\r\n `; specs existentes pinam CRLF.
- **ETag do feed muda uma vez** para feeds com descrição longa (corpo muda) → esperado e correto (hash de conteúdo); clientes revalidam uma vez.
- **Export órfão (knip)** → `foldICalLine` tem 2 usos em `src/`.
- **`TextEncoder`/`TextDecoder` no bundle client** → web-standard, globais em Node ≥11, jsdom e browser; sem polyfill.
- **Consumidor ingênuo de corpo cru** → confirmado no e2e local: asserção de título longo como substring crua quebra com o fold. Consertado no spec com `unfoldICal`; qualquer outro consumidor de corpo cru deve desdobrar (RFC 5545 §3.1).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: `.ics` e feed com nenhuma linha >75 octets, dobra `CRLF + espaço`, conteúdo lógico idêntico após unfold; Apple/Outlook e parser estrito aceitam.
- [ ] Invariantes AGENTS/engineering-standards: edit-owner sem twinning; `lib/` client-safe (sem `server-only`/`Buffer`); sem schema/migration/URL; pt-BR só em copy, identificadores em inglês; knip sem órfão; sem Consent/PII; sem write multi-collection.
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: não há access/write path novo — unit novo `tests/unit/ical.unit.spec.ts` (função + 2 builders) e regressão dos specs existentes; int inalterado; e2e afetados (`campaignAgendaFeed`, `frontendShareLink`) rodados localmente antes do push.
## Self-score (decision-quality)
1. **Decisões caras com rejeitadas:** 5 — D1 (A/B/C), D2 (A/B/C), D3 (A/B), D4 (A/B/C), D5 (A/B), todas com recomendação e porquê.
2. **Cabe no appetite:** 5 — ~meio dia: 1 função pura no owner, 2 linhas de wiring e 1 spec; sem rota/admin/UI/schema.
3. **Rabbit holes nomeados:** 5 — rate limit da `live`, settle do e2e, unfold de entrada, escaping do DESCRIPTION do feed, refactor dos builders, formatos QP/base64, limite configurável.
4. **Reusa shells/helpers:** 5 — reusa o owner `src/lib/ical.ts` (não cria helper novo) e os builders existentes; nenhuma abstração nova com <3 call sites.
5. **Intenção satisfeita:** 5 — escopo exato do issue (folding no owner + 2 call sites + unit de >75/continuação); os "Fora" seguem deferidos com gatilho.
Média 5.0/5 — plano aprovado no modo `--auto`.

View file

@ -1,4 +1,4 @@
import { escapeICalText, formatICalDate } from '@/lib/ical'
import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
/**
* S29 — pure builders for the calendar options of an announcement link
@ -90,5 +90,5 @@ export const buildCalendarEventIcs = ({
if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`)
lines.push('END:VEVENT', 'END:VCALENDAR')
return lines.join('\r\n')
return lines.map(foldICalLine).join('\r\n')
}

View file

@ -10,3 +10,39 @@ export const escapeICalText = (value: string): string =>
export const formatICalDate = (isoString: string): string =>
isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
// RFC 5545 §3.1 counts octets, not UTF-16 code units: the limit is 75 octets
// per physical line, and each continuation line spends one octet on its
// leading space (so 74 octets of payload). Never split a code point — the cut
// backs off to the last UTF-8 boundary.
const MAX_OCTETS = 75
const CONTINUATION_PAYLOAD_OCTETS = MAX_OCTETS - 1
const textEncoder = new TextEncoder()
const textDecoder = new TextDecoder()
/**
* Folds one logical content line into its RFC 5545 physical lines. Lines of up
* to 75 octets come back untouched; longer ones are split at the octet
* boundary with `CRLF + 1 space` and never split a multi-byte character. The
* input must not contain CRLF (logical lines only).
*/
export const foldICalLine = (line: string): string => {
const bytes = textEncoder.encode(line)
if (bytes.length <= MAX_OCTETS) return line
const segments: string[] = []
let start = 0
let chunkOctets = MAX_OCTETS
while (start < bytes.length) {
let end = Math.min(start + chunkOctets, bytes.length)
while (end > start && (bytes[end] & 0xc0) === 0x80) end -= 1
segments.push(textDecoder.decode(bytes.subarray(start, end)))
start = end
chunkOctets = CONTINUATION_PAYLOAD_OCTETS
}
return segments.join('\r\n ')
}

View file

@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
import { escapeICalText, formatICalDate } from '@/lib/ical'
import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload'
@ -73,7 +73,7 @@ export const generateICalFeed = (
}
lines.push('END:VCALENDAR')
return lines.join('\r\n')
return lines.map(foldICalLine).join('\r\n')
}
const computeFeedLastModified = (activities: Activity[], feedUpdatedAt?: string): string => {

View file

@ -12,6 +12,11 @@ import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures
* fetch the feed via `campaign.baseURL + path` — never the canonical origin,
* which is deliberately non-resolving.
*/
// The feed folds content lines longer than 75 octets (RFC 5545 §3.1), so a
// subscriber must unfold (`CRLF + space`) before matching logical content.
const unfoldICal = (body: string): string => body.replace(/\r\n /g, '')
test.describe('Agenda — link de import (C98)', () => {
test.setTimeout(90_000)
@ -65,7 +70,7 @@ test.describe('Agenda — link de import (C98)', () => {
const response = await page.request.get(`${campaign.baseURL}${new URL(feedUrl).pathname}`)
expect(response.status()).toBe(200)
expect(response.headers()['content-type']).toContain('text/calendar')
const body = await response.text()
const body = unfoldICal(await response.text())
expect(body).toContain('BEGIN:VCALENDAR')
expect(body).toContain(title)
})
@ -170,7 +175,7 @@ test.describe('Agenda — link de import (C98)', () => {
expect(first.status()).toBe(200)
expect(first.headers()['cache-control']).toBe('public, no-cache')
expect(first.headers()['etag']).toMatch(/^"[0-9a-f]{64}"$/)
const firstBody = await first.text()
const firstBody = unfoldICal(await first.text())
expect(firstBody).toContain(titleA)
expect(firstBody).not.toContain(titleB)
expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H')
@ -192,7 +197,7 @@ test.describe('Agenda — link de import (C98)', () => {
// commitment created after the first, with a changed validator.
const second = await request.get(feedUrl)
expect(second.status()).toBe(200)
const secondBody = await second.text()
const secondBody = unfoldICal(await second.text())
expect(secondBody).toContain(titleA)
expect(secondBody).toContain(titleB)
expect(second.headers()['etag']).not.toBe(firstEtag)
@ -230,7 +235,7 @@ test.describe('Agenda — link de import (C98)', () => {
})
const cancelled = await request.get(feedUrl)
expect(cancelled.status()).toBe(200)
const cancelledBody = await cancelled.text()
const cancelledBody = unfoldICal(await cancelled.text())
expect(cancelledBody).not.toContain(titleB)
expect(cancelledBody).toContain(titleA)

View file

@ -34,6 +34,10 @@ const requireOk = (result: CalendarFeedLinkResult): CalendarFeedOk => {
return result
}
// The feed folds content lines longer than 75 octets (RFC 5545 §3.1), so a
// subscriber must unfold (`CRLF + space`) before matching logical content.
const unfoldICal = (body: string): string => body.replace(/\r\n /g, '')
const validActivityInput = (municipalityId: number, title: string) => ({
title,
tags: ['Caminhada'],
@ -520,7 +524,7 @@ describe('campaign calendar feed domain', () => {
expect(first.headers.get('Cache-Control')).toBe('public, no-cache')
expect(first.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/)
expect(first.headers.get('Last-Modified')).toMatch(/ GMT$/)
const firstBody = await first.text()
const firstBody = unfoldICal(await first.text())
expect(firstBody).toContain(titleA)
expect(firstBody).not.toContain(titleB)
expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H')
@ -537,7 +541,7 @@ describe('campaign calendar feed domain', () => {
// with a changed validator — the C113 acceptance.
const second = await getFeed()
expect(second.status).toBe(200)
const secondBody = await second.text()
const secondBody = unfoldICal(await second.text())
expect(secondBody).toContain(titleA)
expect(secondBody).toContain(titleB)
expect(second.headers.get('ETag')).not.toBe(firstEtag)
@ -576,7 +580,7 @@ describe('campaign calendar feed domain', () => {
overrideAccess: false,
})
const cancelled = await getFeed()
const cancelledBody = await cancelled.text()
const cancelledBody = unfoldICal(await cancelled.text())
expect(cancelledBody).not.toContain(titleB)
expect(cancelledBody).toContain(titleA)

View file

@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import { buildCalendarEventIcs } from '@/lib/calendarEvent'
import { foldICalLine } from '@/lib/ical'
import { generateICalFeed } from '@/utilities/calendarFeed'
const encoder = new TextEncoder()
const octets = (value: string): number => encoder.encode(value).length
/** RFC 5545 unfold: a CRLF followed by a single space is a fold, not a line break. */
const unfold = (value: string): string => value.replace(/\r\n /g, '')
const physicalLines = (value: string): string[] => value.split('\r\n')
const expectAllPhysicalLinesWithin75Octets = (value: string): void => {
for (const [index, line] of physicalLines(value).entries()) {
expect(octets(line), `physical line ${index}: ${line}`).toBeLessThanOrEqual(75)
}
}
describe('foldICalLine', () => {
it('returns lines of up to 75 octets untouched', () => {
const short = 'SUMMARY:Comício em Salvador'
expect(foldICalLine(short)).toBe(short)
const exact = `SUMMARY:${'a'.repeat(67)}`
expect(octets(exact)).toBe(75)
expect(foldICalLine(exact)).toBe(exact)
})
it('folds a 76-octet line at the boundary with a one-space continuation', () => {
const over = `SUMMARY:${'a'.repeat(68)}`
expect(octets(over)).toBe(76)
const folded = foldICalLine(over)
const lines = physicalLines(folded)
expect(lines).toEqual([`SUMMARY:${'a'.repeat(67)}`, ' a'])
expect(unfold(folded)).toBe(over)
})
it('folds long ASCII lines into physical lines of at most 75 octets', () => {
const line = `DESCRIPTION:${'x'.repeat(160)}`
const folded = foldICalLine(line)
const lines = physicalLines(folded)
expect(lines.length).toBeGreaterThan(1)
expectAllPhysicalLinesWithin75Octets(folded)
expect(octets(lines[0])).toBe(75)
expect(octets(lines[1])).toBe(75)
expect(lines.slice(1).every((part) => part.startsWith(' '))).toBe(true)
expect(unfold(folded)).toBe(line)
})
it('never splits a multi-byte character across physical lines', () => {
const line = `DESCRIPTION:${'Comício '.repeat(20)}`
const folded = foldICalLine(line)
expect(physicalLines(folded).length).toBeGreaterThan(1)
expectAllPhysicalLinesWithin75Octets(folded)
expect(folded).not.toContain('\uFFFD')
expect(unfold(folded)).toBe(line)
})
it('backs off to the last whole character when the boundary falls mid-character', () => {
const line = `DESCRIPTION:${'á'.repeat(40)}`
const folded = foldICalLine(line)
const [first] = physicalLines(folded)
expect(octets(first)).toBeLessThanOrEqual(75)
expect(first.endsWith('á')).toBe(true)
expect(folded).not.toContain('\uFFFD')
expect(unfold(folded)).toBe(line)
})
it('backs off on a continuation line whose payload does not fit whole characters', () => {
// 74 continuation octets hold 24 three-octet characters (72) — the 25th
// would cross the limit, so the chunk must close at 72, not 74.
const line = `DESCRIPTION:${'中'.repeat(60)}`
const folded = foldICalLine(line)
const lines = physicalLines(folded)
expectAllPhysicalLinesWithin75Octets(folded)
expect(octets(lines[0])).toBe(75)
expect(octets(lines[1])).toBe(73)
expect(folded).not.toContain('\uFFFD')
expect(unfold(folded)).toBe(line)
})
})
describe('folded .ics builders', () => {
const STARTS_AT = '2026-10-03T22:00:00.000Z'
const ENDS_AT = '2026-10-04T00:00:00.000Z'
const LONG_DESCRIPTION = 'Encontro da campanha com a militância. '.repeat(6)
it('buildCalendarEventIcs folds a long DESCRIPTION and preserves the logical content', () => {
const body = buildCalendarEventIcs({
uid: 'plenaria@teqo.jorgesolla.com.br',
title: 'Plenária da saúde',
description: LONG_DESCRIPTION,
location: 'Online',
startsAt: STARTS_AT,
endsAt: ENDS_AT,
updatedAt: '2026-09-23T12:00:00.000Z',
}) as string
expectAllPhysicalLinesWithin75Octets(body)
expect(unfold(body)).toContain(`DESCRIPTION:${LONG_DESCRIPTION}`)
expect(body.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true)
expect(body.endsWith('END:VCALENDAR')).toBe(true)
})
it('generateICalFeed folds a long activity DESCRIPTION and preserves the logical content', () => {
const locality = 'Centro histórico, ao lado do elevador Lacerda e do mercado modelo '.repeat(3)
const activities = [
{
id: 1,
slug: 'comicio-salvador',
title: 'Comício em Salvador',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
endAt: '2026-08-15T22:00:00.000Z',
municipality: 1,
locality,
tags: ['comício', 'campanha'],
deputyPresent: true,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const body = generateICalFeed(activities, 'Agenda do Candidato', new Map([[1, 'Salvador']]))
expectAllPhysicalLinesWithin75Octets(body)
expect(unfold(body)).toContain(`Local: ${locality}`)
expect(body).toContain('BEGIN:VCALENDAR\r\n')
expect(body).toContain('UID:comicio-salvador@teqo.jorgesolla.com.br\r\n')
expect(body).toContain('SUMMARY:[Salvador] Comício em Salvador\r\n')
})
})