mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
c6620c2511
commit
48a01de58b
8 changed files with 295 additions and 11 deletions
1
docs/changelog/2026-09-23-s29-followup-ics-folding.md
Normal file
1
docs/changelog/2026-09-23-s29-followup-ics-folding.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
141
tests/unit/ical.unit.spec.ts
Normal file
141
tests/unit/ical.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue