From 01831446df0a18a09c405320420f363886e69ff1 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 25 Sep 2026 01:15:25 -0300 Subject: [PATCH] feat(S41): assina e ancora eventos de agenda do link (Closes #1335) --- docs/changelog/2026-09-25-s41.md | 1 + ...k-de-anuncio-com-link-e-assinatura-impl.md | 199 ++++++++++++++++++ src/app/(frontend)/[type]/evento.ics/route.ts | 7 +- src/app/(frontend)/[type]/page.tsx | 13 +- .../shareLink/ShareLinkAgendaMenu.tsx | 11 +- .../shareLink/ShareLinkAnnouncement.tsx | 1 + src/lib/calendarEvent.ts | 52 ++++- src/lib/campaignTime.ts | 8 +- src/lib/shareLink.ts | 27 ++- src/lib/shareLinkAnnouncement.ts | 4 + src/utilities/shareLinkReads.ts | 15 +- tests/e2e/frontendShareLink.e2e.spec.ts | 47 ++++- tests/int/shareLink.int.spec.ts | 20 ++ tests/unit/calendarEvent.unit.spec.ts | 107 ++++++++-- tests/unit/campaignTimeCivilDate.unit.spec.ts | 7 + tests/unit/shareLink.unit.spec.ts | 7 + tests/unit/shareLinkAnnouncement.unit.spec.ts | 5 + 17 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 docs/changelog/2026-09-25-s41.md create mode 100644 docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura-impl.md diff --git a/docs/changelog/2026-09-25-s41.md b/docs/changelog/2026-09-25-s41.md new file mode 100644 index 00000000..2b49c4b6 --- /dev/null +++ b/docs/changelog/2026-09-25-s41.md @@ -0,0 +1 @@ +**Agenda do link de anúncio (S41, Issue #1335):** os eventos gerados pelo Google Agenda e pelo `.ics` agora carregam a URL canônica do anúncio na descrição, acrescentam uma única assinatura ` - Jorge Solla 1313` e usam `America/Bahia` (UTC−3) nos dois caminhos; o `.ics` também emite `TZID`, `VTIMEZONE` e `URL`. A resolução da URL canônica é server-only e a serialização rejeita URLs não absolutas/controles para preservar o fail-closed. Página, menu e cadastro permanecem inalterados; testes unit, int e o e2e de share links cobrem o novo conteúdo e os guardrails existentes. diff --git a/docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura-impl.md b/docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura-impl.md new file mode 100644 index 00000000..9f8a5ed0 --- /dev/null +++ b/docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura-impl.md @@ -0,0 +1,199 @@ +# Impl: S41 — Agenda do link de anúncio: evento leva o próprio link, assinatura "Jorge Solla 1313" e fuso da Bahia + +Status: aprovado — autoaprovado pela invocação `--auto` +Atualizado em: 2026-09-25 +Issue: #1335 +Intenção: `docs/plans/agenda-do-link-de-anuncio-com-link-e-assinatura.md` +Priority: P2 +Impeccable: A — sem UI nova; somente o conteúdo gerado para Google Agenda e `.ics` +Design UI: N/A — a página, o menu e o cadastro não mudam visualmente +Appetite restante: herdado (~0,5 dia eng); sem migration, sem schema e sem novos owners de produto +Responsável: — + +## Leitura da intenção + +- **Outcome:** os dois caminhos de agenda carregam a URL canônica do anúncio, acrescentam a assinatura ` - Jorge Solla 1313` uma única vez e apresentam o horário no fuso `America/Bahia`, sem alterar a página ou o cadastro. +- **O que não negociar:** a descrição configurada vem antes de `Página do evento: `; o `.ics` também tem `URL` de `VEVENT`; a ausência de URL canônica remove apenas o link; duração padrão de 2h, ausência de início, 404 de despublicado e fail-closed permanecem; sem Consent, PII, RSVP, convite, agenda pública ou API do Google. +- **O que reavaliar:** a intenção escreve o domínio público de produção como literal, enquanto o código usa a cadeia global-first de SEO; os dois caminhos atualmente serializam UTC e não há precedente local para `TZID`/`VTIMEZONE`; a composição de título e descrição precisa ficar em um único owner. + +## Abordagem recomendada + +```mermaid +flowchart LR + L[Link publicado] --> W{Início válido?} + W -- não --> H[Menu ausente ou ICS 404] + W -- sim --> C[Resolver URL canônica] + C --> U[URL absoluta do anúncio] + C --> N[Sem URL: canonical ausente] + U --> P[Google: título, detalhes, horário local, ctz] + U --> R[ICS: TZID, VTIMEZONE, URL, descrição] + N --> P + N --> R +``` + +**Opções macro:** A) manter os dois caminhos em UTC e acrescentar apenas texto | B) serializar o mesmo instante no fuso Bahia, com `ctz` no Google e `TZID`/`VTIMEZONE` no ICS | C) criar um segundo modelo de evento para a agenda pública. + +**Recomendação:** B, com as transformações de conteúdo centralizadas em `calendarEvent` e a URL canônica resolvida no servidor. É a menor mudança que satisfaz o aceite sem criar uma agenda paralela. + +### Decisões de engenharia + +#### D1 — Domínio canônico fixo versus metadata configurável + +**Opções:** A) hardcode de `https://jorgesolla1313.com.br` em cada builder/rota | B) usar a cadeia canônica existente: `getCachedGlobal('metadata')` → `resolveSiteMetadata` → `absoluteSitePath(siteUrl, shareLinkPath(slug))` | C) usar `window.location.origin` ou o host da requisição. + +**Recomendação:** B — em produção, o valor global deve resolver `https://jorgesolla1313.com.br`, preservando o contrato S19/S29. `NEXT_PUBLIC_SITE_URL` continua sendo o fallback de worktree, staging e ambiente sem global; a origem é configuração do site, não um novo campo por link. A política fica em `resolveShareLinkCanonicalUrl`, no reader server-only, e a página e a rota do `.ics` consomem o mesmo resultado. + +O reader devolve `undefined` quando não há `siteUrl`; nenhum builder recebe `shareLinkPath(slug)` como se fosse uma URL. Também não usar `resolveDeploymentOrigin`, que é a origem de assets e pode ser diferente do domínio canônico. + +**Alternativas rejeitadas:** A duplica a política em vários arquivos e quebraria staging/testes ao apontar para produção. C transforma a origem atual de navegação em canonical, podendo gerar URLs diferentes entre visitors, página e download. + +#### D2 — Representação do fuso nos consumidores + +**Opções:** A) manter `DTSTART`/`dates` em UTC e apenas adicionar uma dica de timezone | B) converter o instante para horário civil de `America/Bahia`, usar horário local sem `Z` no Google com `ctz=America/Bahia`, e usar `TZID=America/Bahia` mais um `VTIMEZONE` fixo no ICS | C) escrever offsets numéricos `-0300` em todo lugar. + +**Recomendação:** B — `startsAt` e `endsAt` continuam instantes UTC; só a representação externa muda. O Google recebe `20261003T190000` e `ctz=America/Bahia`; o ICS recebe `DTSTART;TZID=America/Bahia:20261003T190000` e `DTEND;TZID=America/Bahia:20261003T210000`, com `VTIMEZONE` contendo um bloco `STANDARD` fixo em `-0300`, sem `DAYLIGHT`. `DTSTAMP` continua em UTC, pois representa o instante de gravação, não o horário do evento. + +A conversão usa o owner de horário Bahia já existente em `campaignTime.ts`, sem nova dependência. O feed público da campanha e `googleCalendarEventMapping.ts` mantêm seus próprios contratos UTC/offset; não serão compartilhados nem alterados. + +**Alternativas rejeitadas:** A preserva o instante, mas não informa ao leitor qual fuso deve ser exibido. C não é a forma padrão para `DTSTART`/`DTEND` de um VEVENT e não oferece a definição IANA que alguns clientes esperam. + +#### D3 — Dono do título, descrição e URL do evento + +**Opções:** A) cada caller monta título e descrição antes de chamar o builder | B) `calendarEvent.ts` recebe título/descrição crus e uma `url` opcional, aplicando as transformações uma vez | C) criar um serviço paralelo de conteúdo de calendário. + +**Recomendação:** B — `CalendarEventInput` ganha `url?: string | null`, mas não conhece origem, environment ou slug. O builder: + +- acrescenta ` - Jorge Solla 1313` ao final apenas quando esse sufixo exato ainda não está no fim; +- compõe a descrição como descrição configurada, linha em branco, `Página do evento: ` quando houver URL; +- mantém a descrição original, sem linha extra, quando a URL não for resolvida; +- deixa o título e a descrição da página intocados. + +Assim, o menu e a rota passam o mesmo dado bruto e recebem a mesma saída. O nome `Jorge Solla 1313` é copy do produto; os identificadores permanecem em inglês. + +**Alternativas rejeitadas:** A permite divergência entre Google e ICS e duplica a regra anti-sufixo. C cria um segundo owner para um domínio que já tem um builder puro. + +#### D4 — Campo de URL do VEVENT + +**Opções:** A) URL somente na descrição | B) URL na descrição e em `URL:` dentro do `VEVENT` | C) colocar a URL no campo de local do Google ou forçar um campo de URL equivalente. + +**Recomendação:** B — o `.ics` usa a propriedade padrão `URL`; o Google recebe a URL apenas na descrição, porque o template não tem um campo de URL de evento equivalente. A URL do `VEVENT` e a linha `Página do evento` devem ser o mesmo valor absoluto. Sem canonical resolvido, as duas posições devem ser omitidas. + +**Alternativas rejeitadas:** A deixa de cobrir o requisito explícito do ICS. C mistura papel de local e destination, altera a semântica do evento e não resolve o Google. + +#### D5 — Testes + +**Opções:** A) unit para a serialização, int para a resolução server-side e e2e para a página/rota | B) somente e2e | C) somente unit. + +**Recomendação:** A — a serialização é pura e deve ser rápida; o reader server-only e o contrato de publicado/404 ficam no int; o menu real e o download precisam do fluxo e2e. Não criar teste de browser para o Google Calendar externo, apenas inspecionar o `href` gerado. + +**Alternativas rejeitadas:** B deixa a composição de URL e timezone sem feedback rápido. C não prova a integração entre metadata, página client e rota `.ics`. + +## Componentes / mudanças + +- **`resolveShareLinkCanonicalUrl`** (`src/utilities/shareLinkReads.ts`): helper server-only que reutiliza `getCachedGlobal('metadata')`, `resolveSiteMetadata`, `absoluteSitePath` e `shareLinkPath`; devolve a URL canônica ou `undefined`. Não altera o reader de publicados nem o filtro `published: true`. +- **`src/app/(frontend)/[type]/page.tsx`**: usar o helper na resolução do canonical de metadata e no ramo de anúncio; passar a URL para o view. Manter `resolveShareLinkMetadata`, `noindex`, redirect de link direto, poll de live e branch de destino sem alteração. +- **`src/lib/shareLinkAnnouncement.ts`**: adicionar `canonicalUrl: string | null` ao view e ao builder do view. A função continua pura e client-safe; não importa metadata, environment ou `window`. +- **`src/components/shareLink/ShareLinkAnnouncement.tsx`**: encaminhar `view.canonicalUrl` para `ShareLinkAgendaMenu`. Não alterar markup, rótulos, layout, heading ou CTA. +- **`src/components/shareLink/ShareLinkAgendaMenu.tsx`**: receber a URL canônica como prop e passá-la ao `buildGoogleCalendarEventUrl`. O menu continua condicionado a `startsAt`; uma URL ausente não pode esconder o menu. O href do download continua `shareLinkIcsPath(slug)`, que é um path de navegação e não a URL descrita no VEVENT. +- **`src/lib/calendarEvent.ts`**: + - estender `CalendarEventInput` com `url` opcional; + - centralizar sufixo, composição da descrição e validação de URL absoluta; + - formatar os instantes como horário civil de `America/Bahia` para os campos de evento; + - emitir `ctz=America/Bahia` no Google, sem `Z` em `dates`; + - emitir `VTIMEZONE`, `TZID` e campos locais no ICS; + - manter `METHOD`, `PRODID`, `UID`, `DTSTAMP`, `LOCATION`, `resolveCalendarEventWindow`, o fallback de 2h e o retorno `null` sem início válido; + - continuar usando `escapeICalText` e `foldICalLine` em todas as linhas lógicas do ICS. +- **`src/lib/campaignTime.ts`**: adicionar um export puro para data/hora local IANA com segundos, reaproveitando `getZonedParts`; não alterar `formatIsoAsBahiaDateTimeInput`, `formatBahiaEventDateLabel` nem os contratos usados pelo admin. +- **`src/lib/shareLink.ts`, `src/lib/ical.ts` e `src/utilities/seo.ts`**: reutilizar `shareLinkPath`, os primitivos iCal e o resolver de SEO; não criar helpers gêmeos. +- **`src/utilities/calendarFeed.ts` e `src/lib/googleCalendarEventMapping.ts`**: nenhum toque. O feed de campanha continua em UTC e o mapeamento da API Google continua com seu offset próprio. +- **Migration:** nenhuma. Não alterar `ShareLink`, Payload types, hooks, access ou banco; não executar `migrate:create`, `migrate` ou `generate:types`. +- **Access / Consent:** nenhuma mudança. Não há escrita, PII, pessoa ou nova chave de consentimento; o reader público continua fail-closed para rascunho. +- **UI:** Impeccable A. Nenhum componente, shell, token, CSS, designer ou screenshot novo; a mudança é limitada ao href gerado e ao corpo do `.ics`. +- **Changelog:** uma entrada curta em `docs/changelog/2026-09-25-s41.md`, sem editar o agregado. + +## Dados → forma + +- **data-presentation:** não aplicável. Não há métrica, gráfico, série, ranking ou painel; o dado alterado é o payload de um evento de calendário, não uma visualização de dados. + +## Fases verificáveis + +1. **Tracer de domínio puro (~40% do appetite)** + - Atualizar `calendarEvent.ts` e o export de data/hora Bahia. + - Fechar a matriz de título já assinado, título simples, descrição vazia, URL presente/ausente, URL relativa/inválida, escape, newline, URL longa, início ausente, fim inválido e duração de 2h. + - O exemplo de boundary deve confirmar que `2026-10-03T22:00:00.000Z` vira `19:00` da Bahia no Google e no ICS, enquanto `DTSTAMP` permanece UTC. + - Rodar: + - `pnpm test:unit -- tests/unit/calendarEvent.unit.spec.ts tests/unit/ical.unit.spec.ts tests/unit/shareLinkAnnouncement.unit.spec.ts` + - Critério: os dois builders produzem o mesmo título/descrição/fuso e o ICS continua com CRLF, escape e folding válidos. + +2. **Wiring server/client (~35%)** + - Adicionar o reader de canonical e alimentar `canonicalUrl` no view, menu e rota. + - Preservar a página sem canonical, o menu com início válido, a ausência de menu sem início, o 404 de despublicado/desconhecido e o comportamento de link direto/live. + - Cobrir no int a precedência `metadata.URL` sobre `NEXT_PUBLIC_SITE_URL`, o caminho `/` e o retorno ausente sem URL. + - Rodar: + - `pnpm test:int -- tests/int/shareLink.int.spec.ts` + - `pnpm test:unit -- tests/unit/calendarEvent.unit.spec.ts tests/unit/ical.unit.spec.ts tests/unit/shareLinkAnnouncement.unit.spec.ts` + - Critério: a rota e a página usam a mesma origem canônica; nenhum link gerado usa destino, path relativo ou `window.origin`. + +3. **Integração e fechamento (~25%)** + - Atualizar `tests/e2e/frontendShareLink.e2e.spec.ts` para abrir o menu no browser e verificar o `href` do Google sem abrir o serviço externo. + - No mesmo spec, baixar o `.ics` e verificar, após unfold, `SUMMARY`, `DESCRIPTION`, `URL`, `VTIMEZONE`, `TZID`, `DTSTART` e `DTEND` locais; confirmar que o `

` continua sem o sufixo e que os textos visíveis não mudaram. + - Manter os casos sem data, slug desconhecido, despublicado e live redirect intactos. + - Rodar: + - `pnpm test:e2e --no-deps -- tests/e2e/frontendShareLink.e2e.spec.ts` + - `pnpm test:e2e:affected` + - `pnpm gate:fast` + - `pnpm push` + - `pnpm push` fecha a entrega e executa o gate de CI; não usar `git push` diretamente. + +## Rabbit holes / Não escopo (engenharia) + +- **Novo campo de URL no `ShareLink`:** cortar. A URL vem do slug e do canonical já existente. +- **Hardcode de `NEXT_PUBLIC_EVENT_URL` ou de novo env:** cortar. A origem segue o contrato de metadata/SEO. +- **Usar `window.location.origin`, host da requisição ou `resolveDeploymentOrigin`:** cortar. Nenhum deles representa o canonical público. +- **Colocar a URL em `LOCATION` do Google:** cortar. O local continua sendo o local configurado. +- **Adicionar `URL` ao Google template:** cortar. Google recebe a URL na descrição; `.ics` recebe a propriedade própria. +- **Criar um segundo calendário, feed público ou registry de eventos:** cortar. A agenda pública é PUB3 e o motor/sync de campanha tem owner separado. +- **Alterar `src/utilities/calendarFeed.ts` ou `src/lib/googleCalendarEventMapping.ts`:** cortar. O `VTIMEZONE` novo pertence apenas ao evento do link de anúncio. +- **Adicionar recorrência, convidados, múltiplas sessões, dia inteiro, mapa/endereço, RSVP, lembrete, notificação, analytics, UTM ou encurtador:** fora da intenção. +- **Mudar o heading, os rótulos, a posição do menu ou o cadastro:** fora do escopo. A assinatura aparece somente no evento exportado. +- **Truncar título ou descrição para respeitar limite de um calendário consumidor:** fora. O cadastro tem seus limites; o evento deve preservar o texto e o link. +- **Criar parser de ICS, biblioteca de timezone ou importador de calendário:** YAGNI; esta entrega só serializa um VEVENT. + +## Riscos e mitigação + +- **Origem canônica divergir entre página e `.ics`:** centralizar a política em `resolveShareLinkCanonicalUrl`, usar `shareLinkPath` e comparar no e2e a URL da descrição com `URL:` do ICS. +- **Fallback para URL relativa quando o metadata está ausente:** o reader devolve `undefined`; builders mantêm título, descrição e horário, mas não inventam origem. O caso é coberto por unit e int. +- **Misturar horário local com `Z`:** Google e ICS recebem valores locais sem `Z`; `DTSTAMP` é o único campo de evento que continua com `Z`. Os testes verificam a consulta e o corpo após unfold. +- **Cliente não reconhecer `TZID`:** o `.ics` inclui `VTIMEZONE` completo, `TZID` e offsets fixos de `-0300`; não confiar apenas em um `X-WR-TIMEZONE` não padronizado. +- **Sufixo duplicado:** a transformação é idempotente para um título que já termina no literal; não procura a assinatura no meio do título nem reescreve o texto configurado. +- **Linha de descrição com espaço ou quebra inesperado:** compor a descrição antes de escapar, usar uma linha em branco entre as partes e testar descrição vazia, multilinha e URL ausente. +- **URL ou descrição longa quebrar o ICS:** continuar passando todas as linhas por `foldICalLine`; o teste de `ical.unit.spec.ts` confirma limite de 75 octets e unfold sem alteração. +- **Mudança involuntária no feed de campanha:** não editar seus owners; rodar os testes de `calendarFeed` como regressão, sem esperar mudança no formato atual. +- **Regressão de página, redirect ou kill switch:** o e2e existente de `frontendShareLink` continua verificando noindex, redirect pós-live, 404 e republicação. +- **Metadata ausente no build:** `resolveSiteMetadata` e `absoluteSitePath` mantêm a degradação existente; a ausência de URL é um estado válido do evento, não motivo para 404. +- **Export novo não usado:** `resolveShareLinkCanonicalUrl` terá consumidores na página e na rota; o formatter Bahia terá consumidores no builder e teste. `knip` e `check:cycles` entram no gate. + +## Aceite de engenharia + +- [ ] Google Agenda e `.ics` usam a URL canônica do anúncio, nunca o destino, o `.ics` ou uma origem relativa. +- [ ] O título exportado termina uma única vez com ` - Jorge Solla 1313`, sem alterar o título da página. +- [ ] A descrição configurada vem antes de uma linha em branco e de `Página do evento: `. +- [ ] O `.ics` contém `URL` de `VEVENT`; o Google não recebe um campo de URL paralelo. +- [ ] Google usa horário local da Bahia com `ctz=America/Bahia`; o ICS usa `TZID` e `VTIMEZONE` fixo `America/Bahia`/UTC-3, sem DST. +- [ ] `DTSTAMP` e o restante do contrato iCal continuam válidos; escape, CRLF e folding são preservados. +- [ ] Sem canonical resolvido, o evento continua válido com título e fuso, mas sem linha de link e sem `URL` no ICS. +- [ ] Duração padrão de 2h, início obrigatório para o menu e 404 para link despublicado/desconhecido continuam iguais. +- [ ] Página, menu visível, cadastro e dados do `ShareLink` não ganham campo, seção, rótulo ou fluxo novo. +- [ ] `calendarFeed.ts` e `googleCalendarEventMapping.ts` permanecem intocados. +- [ ] Unit, int e e2e afetados cobrem a matriz acima; `pnpm gate:fast` e `pnpm push` ficam verdes. +- [ ] Não há migration, alteração de schema, `Consent` novo, escrita multi-collection ou acesso a DB de produção. + +## Self-score (decision-quality) + +1. **Decisões caras com rejeitadas:** 5/5 — canonical, fuso, `VTIMEZONE`, URL do VEVENT, owner das transformações e camadas de teste têm opções explícitas. +2. **Cabe no appetite:** 5/5 — helpers puros, duas frontiers server/client e atualização de specs; sem schema, admin ou UI nova. +3. **Rabbit holes nomeados:** 5/5 — domínio paralelo, feed público, sync da campanha, parser, recurrence, RSVP e integração externa estão fora. +4. **Depth check:** 5/5 — reusa `shareLinkPath`, o reader server-only, o resolver de SEO, `campaignTime`, `ical` e os consumers existentes; não cria uma agenda gêmea. +5. **Intenção preservada:** 5/5 — todos os bullets do aceite têm arquivo, formato de saída e verificação correspondentes. + +**Média: 5,0/5 — gate de decisão aprovado para execução `--auto`.** diff --git a/src/app/(frontend)/[type]/evento.ics/route.ts b/src/app/(frontend)/[type]/evento.ics/route.ts index 7b6acb1d..c56a6936 100644 --- a/src/app/(frontend)/[type]/evento.ics/route.ts +++ b/src/app/(frontend)/[type]/evento.ics/route.ts @@ -1,6 +1,9 @@ import { buildCalendarEventIcs } from '@/lib/calendarEvent' import { shareLinkIcsUid } from '@/lib/shareLink' -import { getCachedPublishedShareLinkBySlug } from '@/utilities/shareLinkReads' +import { + getCachedPublishedShareLinkBySlug, + resolveShareLinkCanonicalUrl, +} from '@/utilities/shareLinkReads' type RouteParams = { type: string } @@ -16,11 +19,13 @@ export async function GET(_request: Request, { params }: { params: Promise => { const globalMetadata = await getCachedGlobal('metadata')() - const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) - const canonicalUrl = absoluteSitePath(siteUrl, shareLinkPath(link.slug)) + const { siteName, twitterCreator } = resolveSiteMetadata(globalMetadata) + const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug) const description = normalizeShareLinkDescription(link.description) const imageUrl = await resolveShareLinkOgImageUrl(link) const images = imageUrl ? [{ url: imageUrl, alt: link.title }] : [] @@ -139,8 +139,13 @@ export default async function Page({ params }: { params: Promise }) // The rendered page uses the media proxy path (same origin); the absolute // deployment-origin URL is only for the OG card in `resolveShareLinkMetadata`. const image = typeof link.image === 'object' ? link.image : null + const canonicalUrl = await resolveShareLinkCanonicalUrl(link.slug) const showJingles = await hasPublishedJingles() - const view = buildShareLinkAnnouncementView({ link, imageUrl: image?.url ?? null }) + const view = buildShareLinkAnnouncementView({ + link, + imageUrl: image?.url ?? null, + canonicalUrl: canonicalUrl ?? null, + }) return } diff --git a/src/components/shareLink/ShareLinkAgendaMenu.tsx b/src/components/shareLink/ShareLinkAgendaMenu.tsx index f04f8f93..4e793fb4 100644 --- a/src/components/shareLink/ShareLinkAgendaMenu.tsx +++ b/src/components/shareLink/ShareLinkAgendaMenu.tsx @@ -13,6 +13,7 @@ type ShareLinkAgendaMenuProps = { title: string description: string location: string | null + canonicalUrl: string | null startsAt: string | null endsAt: string | null } @@ -27,13 +28,21 @@ export const ShareLinkAgendaMenu = ({ title, description, location, + canonicalUrl, startsAt, endsAt, }: ShareLinkAgendaMenuProps) => { const [open, setOpen] = useState(false) const googleUrl = startsAt - ? buildGoogleCalendarEventUrl({ title, description, location, startsAt, endsAt }) + ? buildGoogleCalendarEventUrl({ + title, + description, + location, + url: canonicalUrl, + startsAt, + endsAt, + }) : null if (!googleUrl) return null diff --git a/src/components/shareLink/ShareLinkAnnouncement.tsx b/src/components/shareLink/ShareLinkAnnouncement.tsx index 259e110c..3eb16d17 100644 --- a/src/components/shareLink/ShareLinkAnnouncement.tsx +++ b/src/components/shareLink/ShareLinkAnnouncement.tsx @@ -221,6 +221,7 @@ export const ShareLinkAnnouncement = ({ title={view.title} description={view.description} location={view.location} + canonicalUrl={view.canonicalUrl} startsAt={view.startsAt} endsAt={view.endsAt} /> diff --git a/src/lib/calendarEvent.ts b/src/lib/calendarEvent.ts index 5af26241..21452e65 100644 --- a/src/lib/calendarEvent.ts +++ b/src/lib/calendarEvent.ts @@ -1,4 +1,6 @@ +import { BAHIA_TIME_ZONE, formatBahiaCalendarDateTime } from '@/lib/campaignTime' import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical' +import { normalizeAbsoluteHttpUrl } from '@/lib/shareLink' /** * S29 — pure builders for the calendar options of an announcement link @@ -7,16 +9,38 @@ import { escapeICalText, foldICalLine, formatICalDate } from '@/lib/ical' */ export const DEFAULT_EVENT_DURATION_MS = 2 * 60 * 60 * 1000 +const EVENT_TITLE_SUFFIX = ' - Jorge Solla 1313' export type CalendarEventInput = { title: string description?: string | null location?: string | null + url?: string | null startsAt?: string | null endsAt?: string | null } const formatInstant = (date: Date): string => formatICalDate(date.toISOString()) +const formatCalendarInstant = (date: Date): string => formatBahiaCalendarDateTime(date) + +const resolveEventUrl = (url: string | null | undefined): string | null => + normalizeAbsoluteHttpUrl(url) + +const resolveCalendarEventContent = (event: CalendarEventInput) => { + const title = event.title.trim() + const url = resolveEventUrl(event.url) + const descriptionParts: string[] = [] + const configuredDescription = event.description + + if (configuredDescription?.trim()) descriptionParts.push(configuredDescription) + if (url) descriptionParts.push(`Página do evento: ${url}`) + + return { + title: title.endsWith(EVENT_TITLE_SUFFIX) ? title : `${title}${EVENT_TITLE_SUFFIX}`, + description: descriptionParts.length > 0 ? descriptionParts.join('\n\n') : null, + url, + } +} /** * The event window: no `startsAt` (or an invalid one) means "no event" — the @@ -46,12 +70,14 @@ export const buildGoogleCalendarEventUrl = (event: CalendarEventInput): string | const window = resolveCalendarEventWindow(event.startsAt, event.endsAt) if (!window) return null + const content = resolveCalendarEventContent(event) const params = new URLSearchParams({ action: 'TEMPLATE', - text: event.title, - dates: `${formatInstant(window.start)}/${formatInstant(window.end)}`, + text: content.title, + dates: `${formatCalendarInstant(window.start)}/${formatCalendarInstant(window.end)}`, + ctz: BAHIA_TIME_ZONE, }) - if (event.description) params.set('details', event.description) + if (content.description) params.set('details', content.description) if (event.location) params.set('location', event.location) return `https://calendar.google.com/calendar/render?${params.toString()}` @@ -69,6 +95,7 @@ export const buildCalendarEventIcs = ({ const window = resolveCalendarEventWindow(event.startsAt, event.endsAt) if (!window) return null + const content = resolveCalendarEventContent(event) const updated = updatedAt ? new Date(updatedAt) : null const dtstamp = !updated || Number.isNaN(updated.getTime()) ? window.start : updated @@ -78,15 +105,26 @@ export const buildCalendarEventIcs = ({ 'PRODID:-//Teqo//Link de compartilhamento//PT', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', + 'BEGIN:VTIMEZONE', + `TZID:${BAHIA_TIME_ZONE}`, + `X-LIC-LOCATION:${BAHIA_TIME_ZONE}`, + 'BEGIN:STANDARD', + 'DTSTART:19700101T000000', + 'TZOFFSETFROM:-0300', + 'TZOFFSETTO:-0300', + 'TZNAME:-03', + 'END:STANDARD', + 'END:VTIMEZONE', 'BEGIN:VEVENT', `UID:${escapeICalText(uid)}`, `DTSTAMP:${formatInstant(dtstamp)}`, - `DTSTART:${formatInstant(window.start)}`, - `DTEND:${formatInstant(window.end)}`, - `SUMMARY:${escapeICalText(event.title)}`, + `DTSTART;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.start)}`, + `DTEND;TZID=${BAHIA_TIME_ZONE}:${formatCalendarInstant(window.end)}`, + `SUMMARY:${escapeICalText(content.title)}`, ] - if (event.description) lines.push(`DESCRIPTION:${escapeICalText(event.description)}`) + if (content.description) lines.push(`DESCRIPTION:${escapeICalText(content.description)}`) + if (content.url) lines.push(`URL:${content.url}`) if (event.location) lines.push(`LOCATION:${escapeICalText(event.location)}`) lines.push('END:VEVENT', 'END:VCALENDAR') diff --git a/src/lib/campaignTime.ts b/src/lib/campaignTime.ts index e77ffbb5..6021c342 100644 --- a/src/lib/campaignTime.ts +++ b/src/lib/campaignTime.ts @@ -1,4 +1,4 @@ -const BAHIA_TIME_ZONE = 'America/Bahia' +export const BAHIA_TIME_ZONE = 'America/Bahia' type CivilDateTime = { year: number @@ -102,6 +102,12 @@ export const formatIsoAsBahiaDateTimeInput = (iso: string): string => { return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}` } +export const formatBahiaCalendarDateTime = (date: Date): string => { + const { year, month, day, hour, minute, second } = getZonedParts(date) + + return `${year}${pad(month)}${pad(day)}T${pad(hour)}${pad(minute)}${pad(second)}` +} + const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', { timeZone: BAHIA_TIME_ZONE, day: '2-digit', diff --git a/src/lib/shareLink.ts b/src/lib/shareLink.ts index e05da4f5..187a1619 100644 --- a/src/lib/shareLink.ts +++ b/src/lib/shareLink.ts @@ -104,18 +104,33 @@ export const isValidShareLinkSlug = (value: string): boolean => SHARE_LINK_SLUG_ export const isReservedShareLinkSlug = (value: string): boolean => (SHARE_LINK_RESERVED_SLUGS as readonly string[]).includes(value) -/** Only `http`/`https` absolute URLs are accepted; anything else fails closed. */ -export const isValidShareLinkDestination = (value: string): boolean => { - if (!/^https?:\/\//i.test(value)) return false +export const normalizeAbsoluteHttpUrl = (value: string | null | undefined): string | null => { + if (typeof value !== 'string') return null + if ( + [...value].some((character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 + }) + ) { + return null + } + + const trimmed = value.trim() + if (!trimmed || !/^https?:\/\//i.test(trimmed)) return null try { - const url = new URL(value) - return url.protocol === 'http:' || url.protocol === 'https:' + const url = new URL(trimmed) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.href } catch { - return false + return null } } +/** Only `http`/`https` absolute URLs are accepted; anything else fails closed. */ +export const isValidShareLinkDestination = (value: string): boolean => + normalizeAbsoluteHttpUrl(value) !== null + /** Collapse whitespace so the value is safe inside a single-line meta tag. */ export const normalizeShareLinkDescription = (value: string): string => value.replace(/\s+/g, ' ').trim() diff --git a/src/lib/shareLinkAnnouncement.ts b/src/lib/shareLinkAnnouncement.ts index 6578a066..122927f7 100644 --- a/src/lib/shareLinkAnnouncement.ts +++ b/src/lib/shareLinkAnnouncement.ts @@ -31,6 +31,7 @@ export type ShareLinkAnnouncementView = { location: string | null startsAt: string | null endsAt: string | null + canonicalUrl: string | null } const isMedia = ( @@ -40,9 +41,11 @@ const isMedia = ( export const buildShareLinkAnnouncementView = ({ link, imageUrl, + canonicalUrl, }: { link: ShareLinkAnnouncementSource imageUrl: string | null + canonicalUrl: string | null }): ShareLinkAnnouncementView => { const mediaAlt = isMedia(link.image) ? link.image.alt?.trim() : null const location = link.location?.trim() @@ -57,5 +60,6 @@ export const buildShareLinkAnnouncementView = ({ location: location || null, startsAt: link.startsAt ?? null, endsAt: link.endsAt ?? null, + canonicalUrl: canonicalUrl ?? null, } } diff --git a/src/utilities/shareLinkReads.ts b/src/utilities/shareLinkReads.ts index eb66d02c..eef0a45c 100644 --- a/src/utilities/shareLinkReads.ts +++ b/src/utilities/shareLinkReads.ts @@ -1,8 +1,15 @@ import 'server-only' -import { resolveLiveShareLinkDestination, type ShareLinkLiveTarget } from '@/lib/shareLink' +import { + normalizeAbsoluteHttpUrl, + resolveLiveShareLinkDestination, + shareLinkPath, + type ShareLinkLiveTarget, +} from '@/lib/shareLink' import { getCollectionListingTag } from '@/utilities/documents' +import { getCachedGlobal } from '@/utilities/globalReads' import { resolveOgImage } from '@/utilities/ogImageReads' +import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo' import configPromise from '@payload-config' import { unstable_cache } from 'next/cache' import { getPayload } from 'payload' @@ -43,6 +50,12 @@ export const getCachedPublishedShareLinkBySlug = (slug: string, depth = 1) => }, ) +export const resolveShareLinkCanonicalUrl = async (slug: string): Promise => { + const globalMetadata = await getCachedGlobal('metadata')() + const { siteUrl } = resolveSiteMetadata(globalMetadata) + return normalizeAbsoluteHttpUrl(absoluteSitePath(siteUrl, shareLinkPath(slug))) ?? undefined +} + /** * S29 — the fresh read behind the announcement page's activation poll. It must * NOT be cached: the whole point is seeing the "no ar" flag flip without a diff --git a/tests/e2e/frontendShareLink.e2e.spec.ts b/tests/e2e/frontendShareLink.e2e.spec.ts index 3f87549f..3250760b 100644 --- a/tests/e2e/frontendShareLink.e2e.spec.ts +++ b/tests/e2e/frontendShareLink.e2e.spec.ts @@ -5,11 +5,11 @@ import { request as playwrightRequest } from '@playwright/test' import { Client } from 'pg' import { + formatBahiaCalendarDateTime, formatBahiaCivilDate, formatBahiaEventDateLabel, parseBahiaDateTimeInput, } from '../../src/lib/campaignTime.js' -import { formatICalDate } from '../../src/lib/ical.js' import { adminHeaders } from '../helpers/adminApi' import { metaContent } from '../helpers/metaContent' import { seedTestUser } from '../helpers/seedUser' @@ -45,6 +45,9 @@ const ANNOUNCEMENT_STARTS_AT = (() => { return startsAt })() +const TWO_HOURS_MS = 2 * 60 * 60 * 1000 +const unfold = (value: string): string => value.replace(/\r\n /g, '') + // 1×1 opaque PNG — enough for the upload (no dimensions are enforced on purpose). const TEST_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', @@ -313,7 +316,8 @@ test.describe('Frontend share-link announcement (S29)', () => { return { id, slug } } - test('serves the announcement page (disabled Entrar, Bahia date, .ics) pre-broadcast', async ({ + test('serves the announcement page with the signed, Bahia-time calendar event', async ({ + page, request, }) => { const headers = await adminHeaders(request, BASE_URL) @@ -324,27 +328,54 @@ test.describe('Frontend share-link announcement (S29)', () => { const response = await anonymous.get(`/${slug}`) expect(response.status()).toBe(200) const html = await response.text() + const canonicalUrl = html.match(/]*disabled[^>]*>[\s\S]*?Entrar/) expect(html).toContain(formatBahiaEventDateLabel(ANNOUNCEMENT_STARTS_AT)) expect(html).toContain('Horário da Bahia') expect(html).toContain('Online') + await page.goto(`/${slug}`) + await expect(page.getByRole('heading', { name: 'Plenária da saúde' })).toHaveText( + 'Plenária da saúde', + ) + await page.getByRole('button', { name: 'Adicionar à agenda' }).click() + const googleHref = await page + .getByRole('link', { name: /Google Agenda/ }) + .getAttribute('href') + const googleParams = new URL(googleHref ?? '').searchParams + const startsAt = new Date(ANNOUNCEMENT_STARTS_AT) + const endsAt = new Date(startsAt.getTime() + TWO_HOURS_MS) + const localStart = formatBahiaCalendarDateTime(startsAt) + const localEnd = formatBahiaCalendarDateTime(endsAt) + + expect(googleParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313') + expect(googleParams.get('dates')).toBe(`${localStart}/${localEnd}`) + expect(googleParams.get('ctz')).toBe('America/Bahia') + expect(googleParams.get('details')).toBe( + `Encontro online da campanha.\n\nPágina do evento: ${canonicalUrl}`, + ) + const ics = await anonymous.get(`/${slug}/evento.ics`) expect(ics.status()).toBe(200) expect(ics.headers()['content-type']).toContain('text/calendar') expect(ics.headers()['content-disposition']).toContain(`filename="${slug}.ics"`) - const body = await ics.text() - expect(body).toContain('BEGIN:VEVENT') - expect(body).toContain(`DTSTART:${formatICalDate(ANNOUNCEMENT_STARTS_AT)}`) + const body = unfold(await ics.text()) + expect(body).toContain('BEGIN:VTIMEZONE') + expect(body).toContain('TZID:America/Bahia') + expect(body).toContain(`DTSTART;TZID=America/Bahia:${localStart}`) + expect(body).toContain(`DTEND;TZID=America/Bahia:${localEnd}`) + expect(body).toContain('SUMMARY:Plenária da saúde - Jorge Solla 1313') + expect(body).toContain( + `DESCRIPTION:Encontro online da campanha.\\n\\nPágina do evento: ${canonicalUrl}`, + ) + expect(body).toContain(`URL:${canonicalUrl}`) expect(body).toContain(`UID:${slug}@teqo.jorgesolla.com.br`) } finally { await anonymous.dispose() diff --git a/tests/int/shareLink.int.spec.ts b/tests/int/shareLink.int.spec.ts index 5d19df71..283b10d3 100644 --- a/tests/int/shareLink.int.spec.ts +++ b/tests/int/shareLink.int.spec.ts @@ -49,6 +49,7 @@ import config from '@/payload.config' import { getCachedPublishedShareLinkBySlug, loadPublishedShareLinkLiveTarget, + resolveShareLinkCanonicalUrl, resolveShareLinkOgImageUrl, } from '@/utilities/shareLinkReads' @@ -147,6 +148,25 @@ describe('shareLink', () => { } }) + it('resolves the canonical announcement URL from metadata before the environment fallback', async () => { + getCachedGlobalMock.mockReturnValue(async () => ({ URL: 'https://canonical.example/' })) + + expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBe( + 'https://canonical.example/plenaria-saude', + ) + }) + + it('uses the environment fallback and omits the canonical URL when neither source exists', async () => { + getCachedGlobalMock.mockReturnValue(async () => ({ URL: null })) + + expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBe( + 'https://site.test/plenaria-saude', + ) + + delete process.env.NEXT_PUBLIC_SITE_URL + expect(await resolveShareLinkCanonicalUrl('plenaria-saude')).toBeUndefined() + }) + it('creates a draft by default and busts the shareLinks tag', async () => { const doc = await createShareLink({ title: 'Plenária da saúde', diff --git a/tests/unit/calendarEvent.unit.spec.ts b/tests/unit/calendarEvent.unit.spec.ts index e7f88a34..3a5a7e82 100644 --- a/tests/unit/calendarEvent.unit.spec.ts +++ b/tests/unit/calendarEvent.unit.spec.ts @@ -9,6 +9,7 @@ import { const STARTS_AT = '2026-10-03T22:00:00.000Z' const ENDS_AT = '2026-10-04T00:00:00.000Z' +const TWO_HOURS_MS = 2 * 60 * 60 * 1000 describe('resolveCalendarEventWindow', () => { it('returns null without a start, so the calendar options hide', () => { @@ -18,20 +19,21 @@ describe('resolveCalendarEventWindow', () => { }) it('falls back to the 2-hour default without an end', () => { + expect(DEFAULT_EVENT_DURATION_MS).toBe(TWO_HOURS_MS) const window = resolveCalendarEventWindow(STARTS_AT, null) - expect(window?.end.getTime()).toBe(new Date(STARTS_AT).getTime() + DEFAULT_EVENT_DURATION_MS) + expect(window?.end.getTime()).toBe(new Date(STARTS_AT).getTime() + TWO_HOURS_MS) }) it('falls back when the end is invalid or does not move forward', () => { const startMs = new Date(STARTS_AT).getTime() expect(resolveCalendarEventWindow(STARTS_AT, 'lixo')?.end.getTime()).toBe( - startMs + DEFAULT_EVENT_DURATION_MS, + startMs + TWO_HOURS_MS, ) expect(resolveCalendarEventWindow(STARTS_AT, STARTS_AT)?.end.getTime()).toBe( - startMs + DEFAULT_EVENT_DURATION_MS, + startMs + TWO_HOURS_MS, ) expect(resolveCalendarEventWindow(STARTS_AT, '2026-10-03T21:00:00.000Z')?.end.getTime()).toBe( - startMs + DEFAULT_EVENT_DURATION_MS, + startMs + TWO_HOURS_MS, ) }) @@ -41,11 +43,12 @@ describe('resolveCalendarEventWindow', () => { }) describe('buildGoogleCalendarEventUrl', () => { - it('builds an action=TEMPLATE URL with UTC dates, details and location', () => { + it('builds an action=TEMPLATE URL with Bahia dates, details, canonical link and location', () => { const url = buildGoogleCalendarEventUrl({ title: 'Plenária da saúde', description: 'Encontro online da campanha.', location: 'Online', + url: 'https://jorgesolla1313.com.br/plenaria-saude', startsAt: STARTS_AT, endsAt: ENDS_AT, }) @@ -54,16 +57,42 @@ describe('buildGoogleCalendarEventUrl', () => { const parsed = new URL(url as string) expect(`${parsed.origin}${parsed.pathname}`).toBe('https://calendar.google.com/calendar/render') expect(parsed.searchParams.get('action')).toBe('TEMPLATE') - expect(parsed.searchParams.get('text')).toBe('Plenária da saúde') - expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z') - expect(parsed.searchParams.get('details')).toBe('Encontro online da campanha.') + expect(parsed.searchParams.get('text')).toBe('Plenária da saúde - Jorge Solla 1313') + expect(parsed.searchParams.get('dates')).toBe('20261003T190000/20261003T210000') + expect(parsed.searchParams.get('ctz')).toBe('America/Bahia') + expect(parsed.searchParams.get('details')).toBe( + 'Encontro online da campanha.\n\nPágina do evento: https://jorgesolla1313.com.br/plenaria-saude', + ) expect(parsed.searchParams.get('location')).toBe('Online') }) + it('does not duplicate the signature or emit a relative link', () => { + const url = buildGoogleCalendarEventUrl({ + title: 'Plenária - Jorge Solla 1313', + url: '/plenaria-saude', + startsAt: STARTS_AT, + }) + const parsed = new URL(url as string) + expect(parsed.searchParams.get('text')).toBe('Plenária - Jorge Solla 1313') + expect(parsed.searchParams.get('details')).toBeNull() + }) + + it('uses the canonical link alone when the configured description is empty', () => { + const url = buildGoogleCalendarEventUrl({ + title: 'Plenária', + description: ' ', + url: 'https://jorgesolla1313.com.br/plenaria', + startsAt: STARTS_AT, + }) + expect(new URL(url as string).searchParams.get('details')).toBe( + 'Página do evento: https://jorgesolla1313.com.br/plenaria', + ) + }) + it('uses the 2-hour default when the end is missing', () => { const url = buildGoogleCalendarEventUrl({ title: 'Plenária', startsAt: STARTS_AT }) const parsed = new URL(url as string) - expect(parsed.searchParams.get('dates')).toBe('20261003T220000Z/20261004T000000Z') + expect(parsed.searchParams.get('dates')).toBe('20261003T190000/20261003T210000') expect(parsed.searchParams.get('details')).toBeNull() expect(parsed.searchParams.get('location')).toBeNull() }) @@ -75,12 +104,13 @@ describe('buildGoogleCalendarEventUrl', () => { }) describe('buildCalendarEventIcs', () => { - it('builds a single VEVENT with CRLF and escaped fields', () => { + it('builds a single VEVENT with Bahia timezone, canonical URL and escaped fields', () => { const ics = buildCalendarEventIcs({ uid: 'plenaria@teqo.jorgesolla.com.br', title: 'Plenária, saúde', description: 'Linha 1\nLinha 2', location: 'Online; sala 1', + url: 'https://jorgesolla1313.com.br/plenaria-saude', startsAt: STARTS_AT, endsAt: ENDS_AT, updatedAt: '2026-09-23T12:00:00.000Z', @@ -92,14 +122,21 @@ describe('buildCalendarEventIcs', () => { expect(body.endsWith('END:VCALENDAR')).toBe(true) expect(body).toContain('UID:plenaria@teqo.jorgesolla.com.br\r\n') expect(body).toContain('DTSTAMP:20260923T120000Z\r\n') - expect(body).toContain('DTSTART:20261003T220000Z\r\n') - expect(body).toContain('DTEND:20261004T000000Z\r\n') - expect(body).toContain('SUMMARY:Plenária\\, saúde\r\n') - expect(body).toContain('DESCRIPTION:Linha 1\\nLinha 2\r\n') + expect(body).toContain('BEGIN:VTIMEZONE\r\n') + expect(body).toContain('TZID:America/Bahia\r\n') + expect(body).toContain('TZOFFSETFROM:-0300\r\n') + expect(body).toContain('TZOFFSETTO:-0300\r\n') + expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n') + expect(body).toContain('DTEND;TZID=America/Bahia:20261003T210000\r\n') + expect(body).toContain('SUMMARY:Plenária\\, saúde - Jorge Solla 1313\r\n') + expect(body.replace(/\r\n /g, '')).toContain( + 'DESCRIPTION:Linha 1\\nLinha 2\\n\\nPágina do evento: https://jorgesolla1313.com.br/plenaria-saude\r\n', + ) + expect(body).toContain('URL:https://jorgesolla1313.com.br/plenaria-saude\r\n') expect(body).toContain('LOCATION:Online\\; sala 1\r\n') }) - it('omits optional lines and falls back to the start for DTSTAMP', () => { + it('omits optional lines and falls back to the start for DTSTAMP without a canonical URL', () => { const body = buildCalendarEventIcs({ uid: 'x@teqo.jorgesolla.com.br', title: 'Plenária', @@ -108,8 +145,46 @@ describe('buildCalendarEventIcs', () => { }) as string expect(body).toContain('DTSTAMP:20261003T220000Z\r\n') + expect(body).toContain('DTSTART;TZID=America/Bahia:20261003T190000\r\n') + expect(body).toContain('SUMMARY:Plenária - Jorge Solla 1313\r\n') expect(body).not.toContain('DESCRIPTION:') - expect(body).not.toContain('LOCATION:') + expect(body).not.toContain('URL:') + expect(body).not.toMatch(/(?:^|\r\n)LOCATION:/) + }) + + it('uses the canonical link alone when the configured description is empty', () => { + const body = buildCalendarEventIcs({ + uid: 'x@teqo.jorgesolla.com.br', + title: 'Plenária', + description: '', + url: 'https://jorgesolla1313.com.br/plenaria', + startsAt: STARTS_AT, + }) as string + + expect(body.replace(/\r\n /g, '')).toContain( + 'DESCRIPTION:Página do evento: https://jorgesolla1313.com.br/plenaria\r\n', + ) + }) + + it('omits a relative or invalid event URL', () => { + const body = buildCalendarEventIcs({ + uid: 'x@teqo.jorgesolla.com.br', + title: 'Plenária', + url: '/evento', + startsAt: STARTS_AT, + }) as string + + expect(body).not.toContain('URL:') + expect(body).not.toContain('Página do evento') + + const controlBody = buildCalendarEventIcs({ + uid: 'x@teqo.jorgesolla.com.br', + title: 'Plenária', + url: 'https://jorgesolla1313.com.br/evento\nX-Injected: yes', + startsAt: STARTS_AT, + }) as string + expect(controlBody).not.toContain('URL:') + expect(controlBody).not.toContain('Página do evento') }) it('returns null without a valid start — never an empty file', () => { diff --git a/tests/unit/campaignTimeCivilDate.unit.spec.ts b/tests/unit/campaignTimeCivilDate.unit.spec.ts index 49997643..4ab79f5d 100644 --- a/tests/unit/campaignTimeCivilDate.unit.spec.ts +++ b/tests/unit/campaignTimeCivilDate.unit.spec.ts @@ -4,6 +4,7 @@ import { civilDateDaysBetween, civilDateToUtcMidnightIso, floorToMinuteStep, + formatBahiaCalendarDateTime, formatBahiaCivilDate, formatBahiaCivilDateTimeLabel, formatBahiaEventDateLabel, @@ -31,6 +32,12 @@ describe('Bahia civil date helpers (B57)', () => { expect(today).toBe('2026-08-08') expect(subtractBahiaCivilDays(today, 7)).toBe('2026-08-01') }) + + it('formats a calendar instant as Bahia local time with seconds', () => { + expect(formatBahiaCalendarDateTime(new Date('2026-10-03T22:00:00.000Z'))).toBe( + '20261003T190000', + ) + }) }) describe('Bahia civil datetime picker helpers (C97)', () => { diff --git a/tests/unit/shareLink.unit.spec.ts b/tests/unit/shareLink.unit.spec.ts index 8a57080c..3f1bb822 100644 --- a/tests/unit/shareLink.unit.spec.ts +++ b/tests/unit/shareLink.unit.spec.ts @@ -6,6 +6,7 @@ import { isReservedShareLinkSlug, isValidShareLinkDestination, isValidShareLinkSlug, + normalizeAbsoluteHttpUrl, normalizeShareLinkDescription, resolveLiveShareLinkDestination, resolveShareLinkMode, @@ -83,10 +84,16 @@ describe('shareLink destination', () => { 'mailto:x@example.com', 'http:foo', 'https:example.com', + 'https://example.com/path\nX-Injected: yes', '', ])('rejects %s', (value) => { expect(isValidShareLinkDestination(value)).toBe(false) }) + + it('normalizes absolute HTTP URLs and rejects control characters', () => { + expect(normalizeAbsoluteHttpUrl(' https://example.com/path ')).toBe('https://example.com/path') + expect(normalizeAbsoluteHttpUrl('https://example.com/path\r\nX-Injected: yes')).toBeNull() + }) }) describe('shareLink mode (S29)', () => { diff --git a/tests/unit/shareLinkAnnouncement.unit.spec.ts b/tests/unit/shareLinkAnnouncement.unit.spec.ts index d2c28fa0..aee35d95 100644 --- a/tests/unit/shareLinkAnnouncement.unit.spec.ts +++ b/tests/unit/shareLinkAnnouncement.unit.spec.ts @@ -14,6 +14,7 @@ describe('buildShareLinkAnnouncementView', () => { location: ' Online ', }, imageUrl: '/api/media/file/plenaria.jpg', + canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-saude', }) expect(view).toEqual({ @@ -26,6 +27,7 @@ describe('buildShareLinkAnnouncementView', () => { location: 'Online', startsAt: '2026-10-03T22:00:00.000Z', endsAt: '2026-10-04T00:00:00.000Z', + canonicalUrl: 'https://jorgesolla1313.com.br/plenaria-saude', }) }) @@ -39,6 +41,7 @@ describe('buildShareLinkAnnouncementView', () => { location: ' ', }, imageUrl: null, + canonicalUrl: null, }) expect(view.imageAlt).toBe('Foto do encontro') @@ -47,6 +50,7 @@ describe('buildShareLinkAnnouncementView', () => { expect(view.location).toBeNull() expect(view.startsAt).toBeNull() expect(view.endsAt).toBeNull() + expect(view.canonicalUrl).toBeNull() }) it('hides the event label when the date cannot be parsed', () => { @@ -58,6 +62,7 @@ describe('buildShareLinkAnnouncementView', () => { startsAt: 'lixo', }, imageUrl: null, + canonicalUrl: null, }) expect(view.eventLabel).toBeNull()