WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2import {
3 detectColorScheme,
4 parseRkeyFromUri,
5 FALLBACK_THEME,
6 resolveTheme,
7} from "../theme-resolution.js";
8import { logger } from "../logger.js";
9
10vi.mock("../logger.js", () => ({
11 logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
12}));
13
14describe("detectColorScheme", () => {
15 it("returns 'light' by default when no cookie or hint", () => {
16 expect(detectColorScheme(undefined, undefined)).toBe("light");
17 });
18
19 it("reads atbb-color-scheme=dark from cookie", () => {
20 expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark");
21 });
22
23 it("reads atbb-color-scheme=light from cookie", () => {
24 expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light");
25 });
26
27 it("prefers cookie over client hint", () => {
28 expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light");
29 });
30
31 it("falls back to client hint when no cookie", () => {
32 expect(detectColorScheme(undefined, "dark")).toBe("dark");
33 });
34
35 it("ignores unrecognized hint values and returns 'light'", () => {
36 expect(detectColorScheme(undefined, "no-preference")).toBe("light");
37 });
38
39 it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => {
40 // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched.
41 // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted.
42 expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light");
43 });
44});
45
46describe("parseRkeyFromUri", () => {
47 it("extracts rkey from valid AT URI", () => {
48 expect(
49 parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc")
50 ).toBe("3lblthemeabc");
51 });
52
53 it("returns null for URI with no rkey segment", () => {
54 expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull();
55 });
56
57 it("returns null for malformed URI", () => {
58 expect(parseRkeyFromUri("not-a-uri")).toBeNull();
59 });
60
61 it("returns null for empty string", () => {
62 expect(parseRkeyFromUri("")).toBeNull();
63 });
64});
65
66describe("FALLBACK_THEME", () => {
67 it("uses neobrutal-light tokens", () => {
68 expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8");
69 expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00");
70 });
71
72 it("has light colorScheme", () => {
73 expect(FALLBACK_THEME.colorScheme).toBe("light");
74 });
75
76 it("includes Google Fonts URL for Space Grotesk", () => {
77 expect(FALLBACK_THEME.fontUrls).toEqual(
78 expect.arrayContaining([expect.stringContaining("Space+Grotesk")])
79 );
80 });
81
82 it("has null cssOverrides", () => {
83 expect(FALLBACK_THEME.cssOverrides).toBeNull();
84 });
85});
86
87describe("resolveTheme", () => {
88 const mockFetch = vi.fn();
89 const mockLogger = vi.mocked(logger);
90 const APPVIEW = "http://localhost:3001";
91
92 beforeEach(() => {
93 vi.stubGlobal("fetch", mockFetch);
94 mockLogger.warn.mockClear();
95 mockLogger.error.mockClear();
96 });
97
98 afterEach(() => {
99 mockFetch.mockReset();
100 vi.unstubAllGlobals();
101 });
102
103 function policyResponse(overrides: object = {}) {
104 return {
105 ok: true,
106 json: () =>
107 Promise.resolve({
108 defaultLightThemeUri:
109 "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
110 defaultDarkThemeUri:
111 "at://did:plc:forum/space.atbb.forum.theme/3lbldark",
112 allowUserChoice: true,
113 availableThemes: [
114 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" },
115 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" },
116 ],
117 ...overrides,
118 }),
119 };
120 }
121
122 function themeResponse(colorScheme: "light" | "dark", cid: string) {
123 return {
124 ok: true,
125 json: () =>
126 Promise.resolve({
127 cid,
128 tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" },
129 cssOverrides: null,
130 fontUrls: null,
131 colorScheme,
132 }),
133 };
134 }
135
136 it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => {
137 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
138 const result = await resolveTheme(APPVIEW, undefined, undefined);
139 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
140 expect(result.colorScheme).toBe("light");
141 expect(mockLogger.warn).toHaveBeenCalledWith(
142 expect.stringContaining("non-ok status"),
143 expect.objectContaining({ operation: "resolveTheme", status: 404 })
144 );
145 });
146
147 it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => {
148 mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
149 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
150 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
151 expect(result.colorScheme).toBe("dark");
152 expect(mockLogger.warn).toHaveBeenCalledWith(
153 expect.stringContaining("non-ok status"),
154 expect.any(Object)
155 );
156 });
157
158 it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => {
159 mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null }));
160 const result = await resolveTheme(APPVIEW, undefined, undefined);
161 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
162 });
163
164 it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => {
165 mockFetch.mockResolvedValueOnce(
166 policyResponse({ defaultLightThemeUri: "malformed-uri" })
167 );
168 const result = await resolveTheme(APPVIEW, undefined, undefined);
169 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
170 // Only one fetch should happen (policy only — no theme fetch)
171 expect(mockFetch).toHaveBeenCalledTimes(1);
172 });
173
174 it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => {
175 mockFetch
176 .mockResolvedValueOnce(policyResponse())
177 .mockResolvedValueOnce({ ok: false, status: 404 });
178 const result = await resolveTheme(APPVIEW, undefined, undefined);
179 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
180 expect(mockLogger.warn).toHaveBeenCalledWith(
181 expect.stringContaining("non-ok status"),
182 expect.objectContaining({ operation: "resolveTheme", status: 404 })
183 );
184 });
185
186 it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => {
187 mockFetch
188 .mockResolvedValueOnce(policyResponse())
189 .mockResolvedValueOnce(themeResponse("light", "WRONG_CID"));
190 const result = await resolveTheme(APPVIEW, undefined, undefined);
191 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
192 expect(logger.warn).toHaveBeenCalledWith(
193 expect.stringContaining("CID mismatch"),
194 expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" })
195 );
196 });
197
198 it("resolves the light theme on happy path (no cookie)", async () => {
199 mockFetch
200 .mockResolvedValueOnce(policyResponse())
201 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
202 const result = await resolveTheme(APPVIEW, undefined, undefined);
203 expect(result.tokens["color-bg"]).toBe("#fff");
204 expect(result.colorScheme).toBe("light");
205 expect(result.cssOverrides).toBeNull();
206 expect(result.fontUrls).toBeNull();
207 });
208
209 it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => {
210 mockFetch
211 .mockResolvedValueOnce(policyResponse())
212 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
213 const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined);
214 expect(result.tokens["color-bg"]).toBe("#111");
215 expect(result.colorScheme).toBe("dark");
216 expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark"));
217 });
218
219 it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => {
220 mockFetch
221 .mockResolvedValueOnce(policyResponse())
222 .mockResolvedValueOnce(themeResponse("dark", "bafydark"));
223 const result = await resolveTheme(APPVIEW, undefined, "dark");
224 expect(result.colorScheme).toBe("dark");
225 });
226
227 it("returns FALLBACK_THEME and logs error on network exception", async () => {
228 mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
229 const result = await resolveTheme(APPVIEW, undefined, undefined);
230 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
231 expect(logger.error).toHaveBeenCalledWith(
232 expect.stringContaining("Theme policy fetch failed"),
233 expect.objectContaining({ operation: "resolveTheme" })
234 );
235 });
236
237 it("re-throws programming errors (TypeError) rather than swallowing them", async () => {
238 // A TypeError from a bug in the code should propagate, not be silently logged.
239 // This TypeError comes from the fetch() mock itself (not from .json()), so it
240 // is caught by the policy-fetch try block and re-thrown as a programming error.
241 mockFetch.mockImplementationOnce(() => {
242 throw new TypeError("Cannot read properties of null");
243 });
244 await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError);
245 });
246
247 it("passes cssOverrides and fontUrls through from theme response", async () => {
248 mockFetch
249 .mockResolvedValueOnce(policyResponse())
250 .mockResolvedValueOnce({
251 ok: true,
252 json: () =>
253 Promise.resolve({
254 cid: "bafylight",
255 tokens: { "color-bg": "#fff" },
256 cssOverrides: ".btn { font-weight: 700; }",
257 fontUrls: ["https://fonts.example.com/font.css"],
258 colorScheme: "light",
259 }),
260 });
261 const result = await resolveTheme(APPVIEW, undefined, undefined);
262 expect(result.cssOverrides).toBe(".btn { font-weight: 700; }");
263 expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]);
264 });
265
266 it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => {
267 mockFetch.mockResolvedValueOnce({
268 ok: true,
269 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
270 });
271 const result = await resolveTheme(APPVIEW, undefined, undefined);
272 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
273 expect(mockLogger.error).toHaveBeenCalledWith(
274 expect.stringContaining("invalid JSON"),
275 expect.objectContaining({ operation: "resolveTheme" })
276 );
277 });
278
279 it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => {
280 mockFetch
281 .mockResolvedValueOnce(policyResponse())
282 .mockResolvedValueOnce({
283 ok: true,
284 json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")),
285 });
286 const result = await resolveTheme(APPVIEW, undefined, undefined);
287 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
288 expect(mockLogger.error).toHaveBeenCalledWith(
289 expect.stringContaining("invalid JSON"),
290 expect.objectContaining({ operation: "resolveTheme" })
291 );
292 });
293
294 it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => {
295 mockFetch
296 .mockResolvedValueOnce(policyResponse({ availableThemes: [] }))
297 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
298 await resolveTheme(APPVIEW, undefined, undefined);
299 expect(mockLogger.warn).toHaveBeenCalledWith(
300 expect.stringContaining("not in availableThemes"),
301 expect.objectContaining({
302 operation: "resolveTheme",
303 themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight",
304 })
305 );
306 });
307
308 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => {
309 // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".."
310 // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch
311 mockFetch.mockResolvedValueOnce(
312 policyResponse({
313 defaultLightThemeUri: "at://did/col/../../secret",
314 })
315 );
316 const result = await resolveTheme(APPVIEW, undefined, undefined);
317 expect(result.tokens).toEqual(FALLBACK_THEME.tokens);
318 // Only the policy fetch should have been made (no theme fetch)
319 expect(mockFetch).toHaveBeenCalledTimes(1);
320 });
321
322 it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => {
323 // Live refs have no CID — canonical atbb.space presets ship this way.
324 // The CID integrity check must be skipped when expectedCid is null.
325 mockFetch
326 .mockResolvedValueOnce(
327 policyResponse({
328 availableThemes: [
329 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid
330 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid
331 ],
332 })
333 )
334 .mockResolvedValueOnce(themeResponse("light", "bafylight"));
335
336 const result = await resolveTheme(APPVIEW, undefined, undefined);
337
338 // Theme resolved successfully — live ref does not trigger CID mismatch
339 expect(result.tokens["color-bg"]).toBe("#fff");
340 expect(result.colorScheme).toBe("light");
341 expect(mockLogger.warn).not.toHaveBeenCalledWith(
342 expect.stringContaining("CID mismatch"),
343 expect.any(Object)
344 );
345 });
346});