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