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
at user-theme-preferences 181 lines 6.2 kB view raw
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});