diff --git a/src/channels/slack.test.ts b/src/channels/slack.test.ts index 25187aa..647b0ec 100644 --- a/src/channels/slack.test.ts +++ b/src/channels/slack.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { describe } from "node:test"; import { slackMany, slackOne } from "./slack.ts"; @@ -11,39 +11,34 @@ const event = (overrides: Partial<{ name: string; slug: string; startDate: strin ...overrides, }); -test("slackOne includes the event name and url", () => { - const msg = slackOne(event({ name: "Hack Night", slug: "hack-night" })); - assert.ok(msg.includes("Hack Night"), `expected name in: ${msg}`); - assert.ok( - msg.includes("https://platform.openmeet.net/events/hack-night"), - `expected url in: ${msg}`, - ); +describe("slackOne", () => { + test("includes the name and url of events", () => { + for (const { name, slug } of [ + { name: "Hack Night", slug: "hack-night" }, + { name: "Lunch", slug: "lunch" }, + { name: "X", slug: "x" }, + ]) { + const msg = slackOne(event({ name, slug })); + assert.ok(msg.includes(name), `expected ${name} in: ${msg}`); + assert.ok( + msg.includes(`https://platform.openmeet.net/events/${slug}`), + `expected url for ${slug} in: ${msg}`, + ); + } + }); }); -test("slackOne includes the name and url for different events", () => { - for (const { name, slug } of [ - { name: "Hack Night", slug: "hack-night" }, - { name: "Lunch", slug: "lunch" }, - { name: "X", slug: "x" }, - ]) { - const msg = slackOne(event({ name, slug })); - assert.ok(msg.includes(name), `expected ${name} in: ${msg}`); - assert.ok( - msg.includes(`https://platform.openmeet.net/events/${slug}`), - `expected url for ${slug} in: ${msg}`, - ); - } -}); - -test("slackMany uses the empty-state message when no events", () => { - const msg = slackMany([]); - assert.ok(msg.includes("No events this week."), "expected 'no events' message from empty list"); -}); +describe("slackMany", () => { + test("uses the empty-state message when no events", () => { + const msg = slackMany([]); + assert.ok(msg.includes("No events this week."), "expected 'no events' message from empty list"); + }); -test("slackMany includes each event's name and url", () => { - const msg = slackMany([event({ name: "A", slug: "a" }), event({ name: "B", slug: "b" })]); - assert.ok(msg.includes("A"), `expected A in: ${msg}`); - assert.ok(msg.includes("https://platform.openmeet.net/events/a"), `expected a url in: ${msg}`); - assert.ok(msg.includes("B"), `expected B in: ${msg}`); - assert.ok(msg.includes("https://platform.openmeet.net/events/b"), `expected b url in: ${msg}`); + test("includes each event's name and url", () => { + const msg = slackMany([event({ name: "A", slug: "a" }), event({ name: "B", slug: "b" })]); + assert.ok(msg.includes("A"), `expected A in: ${msg}`); + assert.ok(msg.includes("https://platform.openmeet.net/events/a"), `expected a url in: ${msg}`); + assert.ok(msg.includes("B"), `expected B in: ${msg}`); + assert.ok(msg.includes("https://platform.openmeet.net/events/b"), `expected b url in: ${msg}`); + }); }); diff --git a/src/openmeet.test.ts b/src/openmeet.test.ts deleted file mode 100644 index 7f680a9..0000000 --- a/src/openmeet.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import type { AtprotoLogin } from "./atproto.ts"; -import type { OpenMeetConfig } from "./config.ts"; -import { authenticate, getUpcomingEvents, type OpenMeetEvent } from "./openmeet.ts"; - -const om: OpenMeetConfig = { openMeetBaseUrl: "http://openmeet.test" }; -const atp: AtprotoLogin = { - atprotoHandle: "test.example", - atprotoAppPassword: "hunter2", - pdsUrl: "http://pds.test", // skip handle resolution; everything goes through fetch -}; - -const events: OpenMeetEvent[] = [ - { - name: "Hack Night", - slug: "hack-night", - startDate: "2026-06-15T18:00:00", - endDate: "2026-06-15T20:00:00", - }, -]; - -// Routes fetch calls by URL path so a test can script each endpoint's -// responses. Handlers receive the RequestInit and may shift through an array -// of canned responses. -function routeFetch(t: any, routes: Record Response>) { - const calls: string[] = []; - t.mock.method(globalThis, "fetch", async (url: any, init: RequestInit = {}) => { - const path = new URL(String(url)).pathname; - calls.push(path); - const handler = routes[path]; - if (!handler) throw new Error(`unexpected fetch: ${path}`); - return handler(init); - }); - return calls; -} - -const authRoutes = { - "/xrpc/com.atproto.server.createSession": () => Response.json({ accessJwt: "fake-jwt" }), - "/xrpc/com.atproto.server.getServiceAuth": () => Response.json({ token: "fake-service-token" }), - "/api/v1/auth/atproto/service-auth": () => - Response.json({ token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }), -}; - -test("authenticate runs the full ATProto handshake and returns OpenMeet tokens", async (t) => { - const calls = routeFetch(t, authRoutes); - - const tokens = await authenticate({ ...atp, ...om }); - - assert.deepEqual(tokens, { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }); - assert.deepEqual(calls, [ - "/xrpc/com.atproto.server.createSession", - "/xrpc/com.atproto.server.getServiceAuth", - "/api/v1/auth/atproto/service-auth", - ]); -}); - -test("sends a 7-day date range", async (t) => { - let query: URLSearchParams | undefined; - t.mock.method(globalThis, "fetch", async (url: any) => { - query = new URL(String(url)).searchParams; - return Response.json(events); - }); - - const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - await getUpcomingEvents({ ...om, ...atp }, tokens, Temporal.PlainDate.from("2026-06-12")); - - assert.equal(query?.get("startDate"), "2026-06-12T00:00:00"); - assert.equal(query?.get("endDate"), "2026-06-19T00:00:00"); -}); - -test("refreshes tokens on 401 and retries", async (t) => { - const eventResponses = [ - () => new Response("expired", { status: 401 }), - (init: RequestInit) => { - assert.equal(new Headers(init.headers).get("authorization"), "Bearer access-2"); - return Response.json(events); - }, - ]; - const calls = routeFetch(t, { - "/api/groups/devict/events": (init) => eventResponses.shift()!(init), - "/api/v1/auth/refresh": () => - Response.json({ token: "access-2", refreshToken: "refresh-2", tokenExpires: 9999 }), - }); - - const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - const result = await getUpcomingEvents( - { ...om, ...atp }, - tokens, - Temporal.PlainDate.from("2026-06-12"), - ); - - assert.deepEqual(result.events, events); - assert.equal(result.tokens.token, "access-2"); - assert.deepEqual(calls, [ - "/api/groups/devict/events", - "/api/v1/auth/refresh", - "/api/groups/devict/events", - ]); -}); - -test("falls back to full re-auth when the refresh token is rejected", async (t) => { - let eventCalls = 0; - routeFetch(t, { - ...authRoutes, - "/api/v1/auth/refresh": () => new Response("nope", { status: 401 }), - "/api/groups/devict/events": () => - ++eventCalls === 1 ? new Response("expired", { status: 401 }) : Response.json(events), - }); - - const tokens = { token: "stale", refreshToken: "stale", tokenExpires: 0 }; - const result = await getUpcomingEvents( - { ...om, ...atp }, - tokens, - Temporal.PlainDate.from("2026-06-12"), - ); - - assert.deepEqual(result.events, events); - assert.equal(result.tokens.token, "access-1"); // came from the full ATProto handshake -}); - -test("gives up after exhausting retries on persistent 401s", async (t) => { - routeFetch(t, { - "/api/groups/devict/events": () => new Response("expired", { status: 401 }), - "/api/v1/auth/refresh": () => - Response.json({ token: "x", refreshToken: "y", tokenExpires: 9999 }), - }); - - const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - await assert.rejects( - getUpcomingEvents({ ...om, ...atp }, tokens, Temporal.PlainDate.from("2026-06-12")), - /events fetch failed: 401/, - ); -}); - -test("non-401 errors propagate immediately", async (t) => { - const calls = routeFetch(t, { - "/api/groups/devict/events": () => new Response("boom", { status: 500 }), - }); - - const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; - await assert.rejects( - getUpcomingEvents({ ...om, ...atp }, tokens, Temporal.PlainDate.from("2026-06-12")), - /events fetch failed: 500: boom/, - ); - assert.equal(calls.length, 1); -}); diff --git a/src/schedule.test.ts b/src/schedule.test.ts index 4998b77..9c1f5da 100644 --- a/src/schedule.test.ts +++ b/src/schedule.test.ts @@ -1,38 +1,40 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import test, { describe } from "node:test"; -import { nextWeeklyOccurrence } from "./schedule.ts"; +import { nextWeeklyOccurrence, TIMEZONE } from "./schedule.ts"; const MONDAY = 1; -const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[America/Chicago]`); +const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[`${TIMEZONE}`]`); // 2026-06-08 is a Monday; 2026-06-10 is a Wednesday. const cfg = { weeklyNotifyDay: MONDAY, weeklyNotifyHour: 7, weeklyNotifyMinute: 30 }; -test("mid-week rolls forward to the next scheduled weekday", () => { - const next = nextWeeklyOccurrence(cfg, zdt("2026-06-10T12:00:00")); - assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); -}); - -test("same day before the scheduled time fires today", () => { - const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T06:00:00")); - assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-08T07:30:00-05:00"); -}); - -test("exactly at the scheduled time rolls to next week (strictly after)", () => { - const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T07:30:00")); - assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); -}); - -test("same day after the scheduled time rolls to next week", () => { - const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T08:00:00")); - assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); -}); - -test("crosses the spring-forward DST boundary at the right wall-clock time", () => { - // DST begins 2026-03-08 in America/Chicago; the following Monday's 07:30 - // should be in CDT (-05:00) while "after" is still in CST (-06:00). - const next = nextWeeklyOccurrence(cfg, zdt("2026-03-06T12:00:00")); - assert.equal(next.toString({ timeZoneName: "never" }), "2026-03-09T07:30:00-05:00"); +describe("nextWeeklyOccurrence", () => { + test("mid-week rolls forward to the next scheduled weekday", () => { + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-10T12:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); + }); + + test("same day before the scheduled time fires today", () => { + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T06:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-08T07:30:00-05:00"); + }); + + test("exactly at the scheduled time rolls to next week (strictly after)", () => { + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T07:30:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); + }); + + test("same day after the scheduled time rolls to next week", () => { + const next = nextWeeklyOccurrence(cfg, zdt("2026-06-08T08:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); + }); + + test("crosses the spring-forward DST boundary at the right wall-clock time", () => { + // DST begins 2026-03-08 in America/Chicago; the following Monday's 07:30 + // should be in CDT (-05:00) while "after" is still in CST (-06:00). + const next = nextWeeklyOccurrence(cfg, zdt("2026-03-06T12:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-03-09T07:30:00-05:00"); + }); });