SPB Git

Repository Public

external.test.ts 7.1 KB 95 lines typescript

1import { afterAll, beforeEach, describe, expect, it } from "vitest";2import { sql } from "@/lib/db";3import { encrypt } from "@/lib/crypto";4import { fold, invitation } from "@/lib/ics";5import { busyFromIcs, normalizeIcsUrl, syncCalendar } from "@/lib/services/external";6import { code, resetDb } from "./helpers";78const TZ = "America/Toronto";9const NOW = new Date("2026-11-02T12:00:00Z");10const cal = (body: string) => ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//test//EN", body.trim(), "END:VCALENDAR"].join("\r\n");11const ev = (lines: string[]) => ["BEGIN:VEVENT", "DTSTAMP:20261101T000000Z", ...lines, "END:VEVENT"].join("\r\n");12const iso = (b: { start: Date; end: Date }) => `${b.start.toISOString()}/${b.end.toISOString()}`;1314describe("ICS → busy periods", () => {15  it("UTC, TZID and floating-zone events become instants", () => {16    const busy = busyFromIcs(cal([17      ev(["UID:a", "DTSTART:20261116T150000Z", "DTEND:20261116T160000Z", "SUMMARY:Privé"]),18      ev(["UID:b", "DTSTART;TZID=Europe/Paris:20261117T100000", "DTEND;TZID=Europe/Paris:20261117T113000"]),19    ].join("\r\n")), TZ, NOW);20    expect(busy.map(iso).sort()).toEqual(["2026-11-16T15:00:00.000Z/2026-11-16T16:00:00.000Z", "2026-11-17T09:00:00.000Z/2026-11-17T10:30:00.000Z"]);21  });22  it("all-day events block whole days in the owner's zone", () => {23    const [b] = busyFromIcs(cal(ev(["UID:c", "DTSTART;VALUE=DATE:20261118", "DTEND;VALUE=DATE:20261120"])), TZ, NOW);24    expect(iso(b)).toBe("2026-11-18T05:00:00.000Z/2026-11-20T05:00:00.000Z");25  });26  it("weekly recurrence is expanded, EXDATE removed, wall-clock time kept across the DST change", () => {27    const busy = busyFromIcs(cal(ev(["UID:d", "DTSTART;TZID=America/Toronto:20261026T090000", "DTEND;TZID=America/Toronto:20261026T100000", "RRULE:FREQ=WEEKLY;COUNT=5", "EXDATE;TZID=America/Toronto:20261109T090000"])), TZ, NOW);28    // 26 Oct is before the window; 2 Nov (EST, after the 1 Nov change) 14:00Z; 9 Nov excluded; 16 and 23 Nov29    expect(busy.map((b) => b.start.toISOString())).toEqual(["2026-11-02T14:00:00.000Z", "2026-11-16T14:00:00.000Z", "2026-11-23T14:00:00.000Z"]);30  });31  it("cancelled and transparent (free) events do not block", () => {32    const busy = busyFromIcs(cal([33      ev(["UID:e", "DTSTART:20261116T150000Z", "DTEND:20261116T160000Z", "STATUS:CANCELLED"]),34      ev(["UID:f", "DTSTART:20261116T170000Z", "DTEND:20261116T180000Z", "TRANSP:TRANSPARENT"]),35    ].join("\r\n")), TZ, NOW);36    expect(busy).toEqual([]);37  });38});3940describe("ICS subscription sync", () => {41  let userId: string, calId: string;42  beforeEach(async () => {43    ({ userId } = await resetDb());44    process.env.SPCAL_ALLOW_LOCAL_ICS = "1";45    const [a] = await sql<{ id: string }[]>`INSERT INTO external_calendar_accounts (user_id, provider, label, secret_enc) VALUES (${userId}, 'ics', 'Perso', ${encrypt("external-calendar", "https://calendar.example.test/x.ics")}) RETURNING id`;46    const [c] = await sql<{ id: string }[]>`INSERT INTO external_calendars (account_id, user_id, name) VALUES (${a.id}, ${userId}, 'Perso') RETURNING id`;47    calId = c.id;48  });49  afterAll(async () => { await sql.end(); });50  const soon = new Date(Date.now() + 5 * 86_400_000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";51  const later = new Date(Date.now() + 5 * 86_400_000 + 3_600_000).toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z";52  const okFetch = (async () => new Response(cal(ev(["UID:x", `DTSTART:${soon}`, `DTEND:${later}`])), { status: 200, headers: { etag: '"v1"' } })) as unknown as typeof fetch;5354  it("success stores busy periods, freshness and the ETag; the stored address is encrypted", async () => {55    expect(await syncCalendar(calId, okFetch)).toMatchObject({ ok: true, events: 1 });56    expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId} AND source = 'external'`).length).toBe(1);57    const [s] = await sql<{ last_success_at: Date; etag: string; last_error: string | null }[]>`SELECT last_success_at, etag, last_error FROM sync_state WHERE calendar_id = ${calId}`;58    expect(s.etag).toBe('"v1"'); expect(s.last_error).toBeNull(); expect(s.last_success_at).toBeInstanceOf(Date);59    const [a] = await sql<{ secret_enc: Buffer }[]>`SELECT secret_enc FROM external_calendar_accounts`;60    expect(a.secret_enc.toString("latin1")).not.toContain("calendar.example.test");61  });62  it("304 Not Modified keeps the data and refreshes freshness", async () => {63    await syncCalendar(calId, okFetch);64    const r = await syncCalendar(calId, (async (_u: unknown, init: RequestInit) => { expect((init.headers as Record<string, string>)["If-None-Match"]).toBe('"v1"'); return new Response(null, { status: 304 }); }) as unknown as typeof fetch);65    expect(r).toMatchObject({ ok: true, notModified: true });66    expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId}`).length).toBe(1);67  });68  it("failure (HTTP 503, rate limit, not a calendar) keeps the last known busy periods and records the error", async () => {69    await syncCalendar(calId, okFetch);70    for (const [res, msg] of [[new Response("down", { status: 503 }), "HTTP 503"], [new Response("", { status: 429 }), "rate limited"], [new Response("<html>login</html>", { status: 200 }), "did not return an iCalendar"]] as const) {71      const r = await syncCalendar(calId, (async () => res) as unknown as typeof fetch);72      expect(r.ok).toBe(false); expect(r.error).toContain(msg);73    }74    expect((await sql`SELECT 1 FROM busy_blocks WHERE external_calendar_id = ${calId}`).length).toBe(1);75    const [s] = await sql<{ consecutive_failures: number; status: string }[]>`SELECT s.consecutive_failures, a.status FROM sync_state s JOIN external_calendars c ON c.id = s.calendar_id JOIN external_calendar_accounts a ON a.id = c.account_id`;76    expect(s).toMatchObject({ consecutive_failures: 3, status: "error" });77  });78  it("only public https addresses are accepted", async () => {79    delete process.env.SPCAL_ALLOW_LOCAL_ICS;80    for (const u of ["http://example.com/a.ics", "https://127.0.0.1/a.ics", "https://192.168.2.72/a.ics", "https://10.67.0.1/a.ics", "https://[::1]/a.ics", "https://user:pw@example.com/a.ics", "file:///etc/passwd", "nonsense"]) expect(await code(normalizeIcsUrl(u))).toBe("invalid_url");81    expect(await normalizeIcsUrl("webcal://1.1.1.1/a.ics")).toBe("https://1.1.1.1/a.ics");82  });83});8485describe("ICS output", () => {86  it("folds at 75 octets without cutting a multi-byte character, and escapes text", () => {87    const line = "SUMMARY:" + "é".repeat(80);88    for (const part of fold(line).split("\r\n")) expect(Buffer.byteLength(part)).toBeLessThanOrEqual(75);89    expect(fold(line).replace(/\r\n /g, "")).toBe(line);90    const ics = invitation({ uid: "u@sp-calendar", sequence: 2, start: new Date("2026-11-16T14:00:00Z"), end: new Date("2026-11-16T14:30:00Z"), summary: "Rencontre; test, ligne\nsuivante", status: "CANCELLED", organizerName: "O", organizerEmail: "o@example.test" }, new Date("2026-11-01T00:00:00Z"));91    expect(ics).toContain("METHOD:CANCEL\r\n"); expect(ics).toContain("SEQUENCE:2\r\n"); expect(ics).toContain("SUMMARY:Rencontre\; test\\, ligne\\nsuivante\r\n");92    expect(ics.endsWith("END:VCALENDAR\r\n")).toBe(true);93  });94});95
Files main
Menu

Theme