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

test: settings route GET and POST integration tests

+417
+417
apps/web/src/routes/__tests__/settings.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createSettingsRoutes — GET /settings and POST /settings/appearance", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + function mockResponse(body: unknown, ok = true, status = 200) { 19 + return { 20 + ok, 21 + status, 22 + statusText: ok ? "OK" : "Error", 23 + json: () => Promise.resolve(body), 24 + }; 25 + } 26 + 27 + /** 28 + * Sets up the fetch mock sequence for an authenticated session on GET /settings. 29 + * Fetch order (no theme middleware in route factories): 30 + * 1. GET /settings handler: GET /api/auth/session 31 + * 2. GET /settings handler: GET /api/theme-policy 32 + * 3. GET /settings handler: GET /api/themes 33 + */ 34 + function setupAuthenticatedSessionGet( 35 + allowUserChoice: boolean = true, 36 + defaultLightThemeUri: string | null = "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 37 + defaultDarkThemeUri: string | null = "at://did:plc:forum/space.atbb.forum.theme/3lbldark" 38 + ) { 39 + const policyResponse = { 40 + allowUserChoice, 41 + defaultLightThemeUri, 42 + defaultDarkThemeUri, 43 + availableThemes: [ 44 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, 45 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, 46 + ], 47 + }; 48 + const themesResponse = { 49 + themes: [ 50 + { 51 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 52 + name: "Clean Light", 53 + colorScheme: "light", 54 + }, 55 + { 56 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 57 + name: "Neobrutal Dark", 58 + colorScheme: "dark", 59 + }, 60 + ], 61 + }; 62 + // GET /settings handler fetches 63 + mockFetch.mockResolvedValueOnce( 64 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 65 + ); 66 + mockFetch.mockResolvedValueOnce(mockResponse(policyResponse)); 67 + mockFetch.mockResolvedValueOnce(mockResponse(themesResponse)); 68 + } 69 + 70 + /** 71 + * Sets up the fetch mock sequence for an authenticated session on POST /settings/appearance. 72 + * Fetch order (no theme middleware in route factories): 73 + * 1. POST /settings/appearance handler: GET /api/auth/session 74 + * 2. POST /settings/appearance handler: GET /api/theme-policy 75 + */ 76 + function setupAuthenticatedSessionPost( 77 + allowUserChoice: boolean = true, 78 + availableThemeUris: string[] = [ 79 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 80 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 81 + ] 82 + ) { 83 + const policyResponse = { 84 + allowUserChoice, 85 + availableThemes: availableThemeUris.map((uri) => ({ uri })), 86 + }; 87 + // POST handler fetches 88 + mockFetch.mockResolvedValueOnce( 89 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 90 + ); 91 + mockFetch.mockResolvedValueOnce(mockResponse(policyResponse)); 92 + } 93 + 94 + async function loadSettingsRoutes() { 95 + const { createSettingsRoutes } = await import("../settings.js"); 96 + return createSettingsRoutes("http://localhost:3000"); 97 + } 98 + 99 + // ── GET /settings — Unauthenticated ────────────────────────────────── 100 + 101 + it("GET /settings redirects unauthenticated users to /login", async () => { 102 + // No cookie in request → getSession returns unauthenticated immediately without fetch 103 + // So no mocks needed 104 + const routes = await loadSettingsRoutes(); 105 + const res = await routes.request("/settings"); 106 + expect(res.status).toBe(302); 107 + expect(res.headers.get("location")).toBe("/login"); 108 + }); 109 + 110 + // ── GET /settings — Policy fetch failure ───────────────────────────── 111 + 112 + it("GET /settings shows error banner when policy fetch returns non-ok", async () => { 113 + // Fetch order: 114 + // 1. GET /settings handler: GET /api/auth/session 115 + // 2. GET /settings handler: GET /api/theme-policy (fails) 116 + mockFetch.mockResolvedValueOnce( 117 + // GET /settings: auth succeeds 118 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 119 + ); 120 + mockFetch.mockResolvedValueOnce( 121 + // GET /settings: policy fails 122 + mockResponse({}, false, 500) 123 + ); 124 + const routes = await loadSettingsRoutes(); 125 + const res = await routes.request("/settings", { 126 + headers: { cookie: "atbb_session=token" }, 127 + }); 128 + expect(res.status).toBe(200); 129 + const html = await res.text(); 130 + expect(html).toContain("Theme settings are temporarily unavailable"); 131 + expect(html).not.toContain("<select"); 132 + }); 133 + 134 + // ── GET /settings — allowUserChoice: false ─────────────────────────── 135 + 136 + it("GET /settings shows informational banner when allowUserChoice: false", async () => { 137 + setupAuthenticatedSessionGet(false); 138 + const routes = await loadSettingsRoutes(); 139 + const res = await routes.request("/settings", { 140 + headers: { cookie: "atbb_session=token" }, 141 + }); 142 + expect(res.status).toBe(200); 143 + const html = await res.text(); 144 + expect(html).toContain("Theme selection is managed by the forum administrator"); 145 + expect(html).not.toContain("id=\"lightThemeUri\""); 146 + expect(html).not.toContain("id=\"darkThemeUri\""); 147 + }); 148 + 149 + // ── GET /settings — Happy path ─────────────────────────────────────── 150 + 151 + it("GET /settings renders form with light/dark theme selects when allowUserChoice: true", async () => { 152 + setupAuthenticatedSessionGet(); 153 + const routes = await loadSettingsRoutes(); 154 + const res = await routes.request("/settings", { 155 + headers: { cookie: "atbb_session=token" }, 156 + }); 157 + expect(res.status).toBe(200); 158 + const html = await res.text(); 159 + expect(html).toContain("id=\"lightThemeUri\""); 160 + expect(html).toContain("id=\"darkThemeUri\""); 161 + expect(html).toContain("Clean Light"); 162 + expect(html).toContain("Neobrutal Dark"); 163 + }); 164 + 165 + it("GET /settings with ?saved=1 shows success banner", async () => { 166 + setupAuthenticatedSessionGet(); 167 + const routes = await loadSettingsRoutes(); 168 + const res = await routes.request("/settings?saved=1", { 169 + headers: { cookie: "atbb_session=token" }, 170 + }); 171 + expect(res.status).toBe(200); 172 + const html = await res.text(); 173 + expect(html).toContain("Preferences saved"); 174 + }); 175 + 176 + it("GET /settings pre-selects current preference cookie value in dropdown", async () => { 177 + setupAuthenticatedSessionGet(); 178 + const routes = await loadSettingsRoutes(); 179 + const res = await routes.request("/settings", { 180 + headers: { 181 + cookie: 182 + "atbb_session=token; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", 183 + }, 184 + }); 185 + expect(res.status).toBe(200); 186 + const html = await res.text(); 187 + // Find the option with the URI and check it has selected attribute 188 + expect(html).toContain( 189 + 'value="at://did:plc:forum/space.atbb.forum.theme/3lbllight" selected' 190 + ); 191 + }); 192 + 193 + it("GET /settings with ?error=invalid-theme shows error banner", async () => { 194 + setupAuthenticatedSessionGet(); 195 + const routes = await loadSettingsRoutes(); 196 + const res = await routes.request("/settings?error=invalid-theme", { 197 + headers: { cookie: "atbb_session=token" }, 198 + }); 199 + expect(res.status).toBe(200); 200 + const html = await res.text(); 201 + expect(html).toContain("invalid-theme"); 202 + }); 203 + 204 + // ── POST /settings/appearance — Unauthenticated ────────────────────── 205 + 206 + it("POST /settings/appearance redirects unauthenticated users to /login", async () => { 207 + mockFetch.mockResolvedValueOnce( 208 + mockResponse({ authenticated: false }, false, 401) 209 + ); 210 + const routes = await loadSettingsRoutes(); 211 + const res = await routes.request("/settings/appearance", { 212 + method: "POST", 213 + headers: { "content-type": "application/x-www-form-urlencoded" }, 214 + body: new URLSearchParams({ 215 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 216 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 217 + }).toString(), 218 + }); 219 + expect(res.status).toBe(302); 220 + expect(res.headers.get("location")).toBe("/login"); 221 + // No cookie header means getSession returns unauthenticated 222 + // which triggers the redirect immediately without body parsing 223 + }); 224 + 225 + // ── POST /settings/appearance — Validation failures ─────────────────── 226 + 227 + it("POST /settings/appearance rejects empty body with ?error=invalid", async () => { 228 + mockFetch.mockResolvedValueOnce( 229 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 230 + ); 231 + const routes = await loadSettingsRoutes(); 232 + const res = await routes.request("/settings/appearance", { 233 + method: "POST", 234 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 235 + body: "", 236 + }); 237 + expect(res.status).toBe(302); 238 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 239 + }); 240 + 241 + it("POST /settings/appearance rejects missing lightThemeUri with ?error=invalid", async () => { 242 + mockFetch.mockResolvedValueOnce( 243 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 244 + ); 245 + const routes = await loadSettingsRoutes(); 246 + const res = await routes.request("/settings/appearance", { 247 + method: "POST", 248 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 249 + body: new URLSearchParams({ 250 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 251 + }).toString(), 252 + }); 253 + expect(res.status).toBe(302); 254 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 255 + }); 256 + 257 + it("POST /settings/appearance rejects missing darkThemeUri with ?error=invalid", async () => { 258 + mockFetch.mockResolvedValueOnce( 259 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 260 + ); 261 + const routes = await loadSettingsRoutes(); 262 + const res = await routes.request("/settings/appearance", { 263 + method: "POST", 264 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 265 + body: new URLSearchParams({ 266 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 267 + }).toString(), 268 + }); 269 + expect(res.status).toBe(302); 270 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 271 + }); 272 + 273 + it("POST /settings/appearance rejects invalid light theme URI with ?error=invalid-theme", async () => { 274 + setupAuthenticatedSessionPost(); 275 + const routes = await loadSettingsRoutes(); 276 + const res = await routes.request("/settings/appearance", { 277 + method: "POST", 278 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 279 + body: new URLSearchParams({ 280 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/invalid", 281 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 282 + }).toString(), 283 + }); 284 + expect(res.status).toBe(302); 285 + expect(res.headers.get("location")).toBe("/settings?error=invalid-theme"); 286 + }); 287 + 288 + it("POST /settings/appearance rejects invalid dark theme URI with ?error=invalid-theme", async () => { 289 + setupAuthenticatedSessionPost(); 290 + const routes = await loadSettingsRoutes(); 291 + const res = await routes.request("/settings/appearance", { 292 + method: "POST", 293 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 294 + body: new URLSearchParams({ 295 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 296 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/invalid", 297 + }).toString(), 298 + }); 299 + expect(res.status).toBe(302); 300 + expect(res.headers.get("location")).toBe("/settings?error=invalid-theme"); 301 + }); 302 + 303 + // ── POST /settings/appearance — Policy fetch failure ────────────────── 304 + 305 + it("POST /settings/appearance rejects when policy fetch fails with ?error=unavailable", async () => { 306 + // Fetch order: 307 + // 1. POST handler: GET /api/auth/session 308 + // 2. POST handler: GET /api/theme-policy (fails) 309 + mockFetch.mockResolvedValueOnce( 310 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 311 + ); 312 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); // policy fetch fails 313 + const routes = await loadSettingsRoutes(); 314 + const res = await routes.request("/settings/appearance", { 315 + method: "POST", 316 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 317 + body: new URLSearchParams({ 318 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 319 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 320 + }).toString(), 321 + }); 322 + expect(res.status).toBe(302); 323 + expect(res.headers.get("location")).toBe("/settings?error=unavailable"); 324 + // Verify no cookies are set 325 + const cookies = res.headers.getSetCookie?.() ?? []; 326 + expect(cookies.length).toBe(0); 327 + }); 328 + 329 + // ── POST /settings/appearance — allowUserChoice: false ──────────────── 330 + 331 + it("POST /settings/appearance rejects when allowUserChoice: false with ?error=not-allowed", async () => { 332 + setupAuthenticatedSessionPost(false); 333 + const routes = await loadSettingsRoutes(); 334 + const res = await routes.request("/settings/appearance", { 335 + method: "POST", 336 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 337 + body: new URLSearchParams({ 338 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 339 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 340 + }).toString(), 341 + }); 342 + expect(res.status).toBe(302); 343 + expect(res.headers.get("location")).toBe("/settings?error=not-allowed"); 344 + }); 345 + 346 + // ── POST /settings/appearance — Happy path ────────────────────────── 347 + 348 + it("POST /settings/appearance sets theme cookies and redirects to /settings?saved=1", async () => { 349 + setupAuthenticatedSessionPost(); 350 + const routes = await loadSettingsRoutes(); 351 + const res = await routes.request("/settings/appearance", { 352 + method: "POST", 353 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 354 + body: new URLSearchParams({ 355 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 356 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 357 + }).toString(), 358 + }); 359 + expect(res.status).toBe(302); 360 + expect(res.headers.get("location")).toBe("/settings?saved=1"); 361 + 362 + // Verify both cookies are set 363 + const cookies = res.headers.getSetCookie?.() ?? []; 364 + expect(cookies.some((c) => c.startsWith("atbb-light-theme="))).toBe(true); 365 + expect(cookies.some((c) => c.startsWith("atbb-dark-theme="))).toBe(true); 366 + 367 + // Verify cookie values match 368 + expect(cookies.some((c) => 369 + c.startsWith( 370 + "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight" 371 + ) 372 + )).toBe(true); 373 + expect(cookies.some((c) => 374 + c.startsWith("atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark") 375 + )).toBe(true); 376 + 377 + // Verify cookie attributes 378 + const lightCookie = cookies.find((c) => c.startsWith("atbb-light-theme=")); 379 + expect(lightCookie).toContain("Path=/"); 380 + expect(lightCookie).toContain("Max-Age=31536000"); 381 + expect(lightCookie).toContain("SameSite=Lax"); 382 + }); 383 + 384 + it("POST /settings/appearance accepts valid URIs both in availableThemes", async () => { 385 + setupAuthenticatedSessionPost(); 386 + const routes = await loadSettingsRoutes(); 387 + const res = await routes.request("/settings/appearance", { 388 + method: "POST", 389 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 390 + body: new URLSearchParams({ 391 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 392 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 393 + }).toString(), 394 + }); 395 + expect(res.status).toBe(302); 396 + }); 397 + 398 + it("POST /settings/appearance does not set cookies when policy fetch fails", async () => { 399 + // Same as the unavailable test - just verifying no cookies are set 400 + mockFetch.mockResolvedValueOnce( 401 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 402 + ); 403 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 404 + const routes = await loadSettingsRoutes(); 405 + const res = await routes.request("/settings/appearance", { 406 + method: "POST", 407 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 408 + body: new URLSearchParams({ 409 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 410 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 411 + }).toString(), 412 + }); 413 + expect(res.status).toBe(302); 414 + const cookies = res.headers.getSetCookie?.() ?? []; 415 + expect(cookies.length).toBe(0); 416 + }); 417 + });