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 } from "vitest";
2import { Hono } from "hono";
3import type { WebAppEnv, ResolvedTheme } from "../../lib/theme-resolution.js";
4import { createThemeMiddleware } from "../theme.js";
5
6vi.mock("../../lib/theme-resolution.js", () => ({
7 resolveTheme: vi.fn(),
8 FALLBACK_THEME: {
9 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" },
10 cssOverrides: null,
11 fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"],
12 colorScheme: "light",
13 },
14}));
15
16vi.mock("../../lib/logger.js", () => ({
17 logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
18}));
19
20import { resolveTheme, FALLBACK_THEME } from "../../lib/theme-resolution.js";
21import { logger } from "../../lib/logger.js";
22
23const mockResolveTheme = vi.mocked(resolveTheme);
24const mockLogger = vi.mocked(logger);
25
26const MOCK_THEME: ResolvedTheme = {
27 tokens: { "color-bg": "#ffffff", "color-primary": "#ff5c00" },
28 cssOverrides: null,
29 fontUrls: ["https://fonts.example.com/font.css"],
30 colorScheme: "light",
31};
32
33describe("createThemeMiddleware", () => {
34 beforeEach(() => {
35 mockResolveTheme.mockReset();
36 mockResolveTheme.mockResolvedValue(MOCK_THEME);
37 mockLogger.error.mockClear();
38 });
39
40 it("stores resolved theme in context for downstream handlers", async () => {
41 let capturedTheme: ResolvedTheme | undefined;
42
43 const app = new Hono<WebAppEnv>()
44 .use("*", createThemeMiddleware("http://appview.test"))
45 .get("/test", (c) => {
46 capturedTheme = c.get("theme");
47 return c.json({ ok: true });
48 });
49
50 const res = await app.request("http://localhost/test");
51 expect(res.status).toBe(200);
52 expect(capturedTheme).toEqual(MOCK_THEME);
53 });
54
55 it("passes Cookie header to resolveTheme", async () => {
56 const app = new Hono<WebAppEnv>()
57 .use("*", createThemeMiddleware("http://appview.test"))
58 .get("/test", (c) => c.json({ ok: true }));
59
60 await app.request("http://localhost/test", {
61 headers: { Cookie: "atbb-color-scheme=dark; session=abc" },
62 });
63
64 expect(mockResolveTheme).toHaveBeenCalledWith(
65 expect.any(String),
66 "atbb-color-scheme=dark; session=abc",
67 undefined,
68 expect.objectContaining({ ttlMs: expect.any(Number) })
69 );
70 });
71
72 it("passes Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => {
73 const app = new Hono<WebAppEnv>()
74 .use("*", createThemeMiddleware("http://appview.test"))
75 .get("/test", (c) => c.json({ ok: true }));
76
77 await app.request("http://localhost/test", {
78 headers: { "Sec-CH-Prefers-Color-Scheme": "dark" },
79 });
80
81 expect(mockResolveTheme).toHaveBeenCalledWith(
82 expect.any(String),
83 undefined,
84 "dark",
85 expect.objectContaining({ ttlMs: expect.any(Number) })
86 );
87 });
88
89 it("passes appviewUrl to resolveTheme", async () => {
90 const app = new Hono<WebAppEnv>()
91 .use("*", createThemeMiddleware("http://custom-appview.example.com"))
92 .get("/test", (c) => c.json({ ok: true }));
93
94 await app.request("http://localhost/test");
95
96 expect(mockResolveTheme).toHaveBeenCalledWith(
97 "http://custom-appview.example.com",
98 undefined,
99 undefined,
100 expect.objectContaining({ ttlMs: expect.any(Number) })
101 );
102 });
103
104 it("calls resolveTheme with undefined when Cookie and color-scheme headers are absent", async () => {
105 const app = new Hono<WebAppEnv>()
106 .use("*", createThemeMiddleware("http://appview.test"))
107 .get("/test", (c) => c.json({ ok: true }));
108
109 await app.request("http://localhost/test");
110
111 expect(mockResolveTheme).toHaveBeenCalledWith(
112 "http://appview.test",
113 undefined,
114 undefined,
115 expect.objectContaining({ ttlMs: expect.any(Number) })
116 );
117 });
118
119 it("calls next() so the downstream handler executes", async () => {
120 const app = new Hono<WebAppEnv>()
121 .use("*", createThemeMiddleware("http://appview.test"))
122 .get("/test", (c) => c.json({ message: "handler ran" }));
123
124 const res = await app.request("http://localhost/test");
125 expect(res.status).toBe(200);
126 const body = await res.json() as { message: string };
127 expect(body.message).toBe("handler ran");
128 });
129
130 it("passes a ThemeCache instance to resolveTheme (4th argument is not undefined)", async () => {
131 const app = new Hono<WebAppEnv>()
132 .use("*", createThemeMiddleware("http://appview.test", 60_000))
133 .get("/test", (c) => c.json({ ok: true }));
134
135 await app.request("http://localhost/test");
136
137 // The 4th argument (cache) should be a non-null object — not undefined
138 expect(mockResolveTheme).toHaveBeenCalledWith(
139 expect.any(String),
140 undefined,
141 undefined,
142 expect.objectContaining({ ttlMs: 60_000 })
143 );
144 });
145
146 it("uses default 5-minute TTL when cacheTtlMs is not provided", async () => {
147 const app = new Hono<WebAppEnv>()
148 .use("*", createThemeMiddleware("http://appview.test"))
149 .get("/test", (c) => c.json({ ok: true }));
150
151 await app.request("http://localhost/test");
152
153 expect(mockResolveTheme).toHaveBeenCalledWith(
154 expect.any(String),
155 undefined,
156 undefined,
157 expect.objectContaining({ ttlMs: 5 * 60 * 1000 })
158 );
159 });
160
161 it("catches unexpected throws from resolveTheme, logs the error, and sets FALLBACK_THEME", async () => {
162 mockResolveTheme.mockRejectedValueOnce(new TypeError("Unexpected programming error"));
163
164 let capturedTheme: ResolvedTheme | undefined;
165 const app = new Hono<WebAppEnv>()
166 .use("*", createThemeMiddleware("http://appview.test"))
167 .get("/test", (c) => {
168 capturedTheme = c.get("theme");
169 return c.json({ ok: true });
170 });
171
172 // The request must NOT propagate the error — the middleware should catch it
173 const res = await app.request("http://localhost/test");
174 expect(res.status).toBe(200);
175 expect(capturedTheme).toEqual(FALLBACK_THEME);
176 expect(mockLogger.error).toHaveBeenCalledWith(
177 expect.stringContaining("resolveTheme threw unexpectedly"),
178 expect.objectContaining({ operation: "createThemeMiddleware" })
179 );
180 });
181});