forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
1import { beforeEach, describe, expect, it } from "vitest";
2import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3import Comms from "../routes/Comms.svelte";
4import {
5 clearMocks,
6 errorResponse,
7 getErrorToasts,
8 getToasts,
9 jsonResponse,
10 mockData,
11 mockEndpoint,
12 setupAuthenticatedUser,
13 setupDefaultMocks,
14 setupUnauthenticatedUser,
15} from "./mocks.ts";
16describe("Comms", () => {
17 beforeEach(() => {
18 clearMocks();
19 setupDefaultMocks();
20 });
21 describe("authentication guard", () => {
22 it("redirects to login when not authenticated", async () => {
23 setupUnauthenticatedUser();
24 render(Comms);
25 await waitFor(() => {
26 expect(globalThis.location.pathname).toBe("/app/login");
27 });
28 });
29 });
30 describe("page structure", () => {
31 beforeEach(() => {
32 setupAuthenticatedUser();
33 mockEndpoint(
34 "_account.getNotificationPrefs",
35 () => jsonResponse(mockData.notificationPrefs()),
36 );
37 mockEndpoint(
38 "com.atproto.server.describeServer",
39 () => jsonResponse(mockData.describeServer()),
40 );
41 mockEndpoint(
42 "_account.getNotificationHistory",
43 () => jsonResponse({ notifications: [] }),
44 );
45 });
46 it("displays all page elements and sections", async () => {
47 render(Comms);
48 await waitFor(() => {
49 expect(
50 screen.getByRole("heading", {
51 name: /communication preferences|notification preferences/i,
52 level: 1,
53 }),
54 ).toBeInTheDocument();
55 expect(screen.getByRole("link", { name: /dashboard/i }))
56 .toHaveAttribute("href", "/app/dashboard");
57 expect(screen.getByRole("heading", { name: /preferred channel/i }))
58 .toBeInTheDocument();
59 expect(screen.getByRole("heading", { name: /channel configuration/i }))
60 .toBeInTheDocument();
61 });
62 });
63 });
64 describe("loading state", () => {
65 beforeEach(() => {
66 setupAuthenticatedUser();
67 mockEndpoint(
68 "com.atproto.server.describeServer",
69 () => jsonResponse(mockData.describeServer()),
70 );
71 mockEndpoint(
72 "_account.getNotificationHistory",
73 () => jsonResponse({ notifications: [] }),
74 );
75 });
76 it("shows loading skeleton while fetching preferences", () => {
77 mockEndpoint(
78 "_account.getNotificationPrefs",
79 () =>
80 new Promise((resolve) =>
81 setTimeout(
82 () => resolve(jsonResponse(mockData.notificationPrefs())),
83 100,
84 )
85 ),
86 );
87 const { container } = render(Comms);
88 expect(container.querySelectorAll(".skeleton-section").length)
89 .toBeGreaterThan(0);
90 });
91 });
92 describe("channel options", () => {
93 beforeEach(() => {
94 setupAuthenticatedUser();
95 mockEndpoint(
96 "com.atproto.server.describeServer",
97 () => jsonResponse(mockData.describeServer()),
98 );
99 mockEndpoint(
100 "_account.getNotificationHistory",
101 () => jsonResponse({ notifications: [] }),
102 );
103 });
104 it("displays all four channel options", async () => {
105 mockEndpoint(
106 "_account.getNotificationPrefs",
107 () => jsonResponse(mockData.notificationPrefs()),
108 );
109 render(Comms);
110 await waitFor(() => {
111 expect(screen.getByRole("radio", { name: /email/i }))
112 .toBeInTheDocument();
113 expect(screen.getByRole("radio", { name: /discord/i }))
114 .toBeInTheDocument();
115 expect(screen.getByRole("radio", { name: /telegram/i }))
116 .toBeInTheDocument();
117 expect(screen.getByRole("radio", { name: /signal/i }))
118 .toBeInTheDocument();
119 });
120 });
121 it("email channel is always selectable", async () => {
122 mockEndpoint(
123 "_account.getNotificationPrefs",
124 () => jsonResponse(mockData.notificationPrefs()),
125 );
126 render(Comms);
127 await waitFor(() => {
128 const emailRadio = screen.getByRole("radio", { name: /email/i });
129 expect(emailRadio).not.toBeDisabled();
130 });
131 });
132 it("discord channel is disabled when not configured", async () => {
133 mockEndpoint(
134 "_account.getNotificationPrefs",
135 () => jsonResponse(mockData.notificationPrefs({ discordId: null })),
136 );
137 render(Comms);
138 await waitFor(() => {
139 const discordRadio = screen.getByRole("radio", { name: /discord/i });
140 expect(discordRadio).toBeDisabled();
141 });
142 });
143 it("discord channel is enabled when configured", async () => {
144 mockEndpoint(
145 "_account.getNotificationPrefs",
146 () =>
147 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
148 );
149 render(Comms);
150 await waitFor(() => {
151 const discordRadio = screen.getByRole("radio", { name: /discord/i });
152 expect(discordRadio).not.toBeDisabled();
153 });
154 });
155 it("shows hint for disabled channels", async () => {
156 mockEndpoint(
157 "_account.getNotificationPrefs",
158 () => jsonResponse(mockData.notificationPrefs()),
159 );
160 render(Comms);
161 await waitFor(() => {
162 expect(screen.getAllByText(/configure.*to enable/i).length)
163 .toBeGreaterThan(0);
164 });
165 });
166 it("selects current preferred channel", async () => {
167 mockEndpoint(
168 "_account.getNotificationPrefs",
169 () =>
170 jsonResponse(
171 mockData.notificationPrefs({ preferredChannel: "email" }),
172 ),
173 );
174 render(Comms);
175 await waitFor(() => {
176 const emailRadio = screen.getByRole("radio", {
177 name: /email/i,
178 }) as HTMLInputElement;
179 expect(emailRadio.checked).toBe(true);
180 });
181 });
182 });
183 describe("channel configuration", () => {
184 beforeEach(() => {
185 setupAuthenticatedUser();
186 mockEndpoint(
187 "com.atproto.server.describeServer",
188 () => jsonResponse(mockData.describeServer()),
189 );
190 mockEndpoint(
191 "_account.getNotificationHistory",
192 () => jsonResponse({ notifications: [] }),
193 );
194 });
195 it("displays email as readonly with current value", async () => {
196 mockEndpoint(
197 "_account.getNotificationPrefs",
198 () => jsonResponse(mockData.notificationPrefs()),
199 );
200 render(Comms);
201 await waitFor(() => {
202 const emailInput = screen.getByLabelText(
203 /^email$/i,
204 ) as HTMLInputElement;
205 expect(emailInput).toBeDisabled();
206 expect(emailInput.value).toBe("test@example.com");
207 });
208 });
209 it("displays all channel inputs with current values", async () => {
210 mockEndpoint(
211 "_account.getNotificationPrefs",
212 () =>
213 jsonResponse(mockData.notificationPrefs({
214 discordId: "123456789",
215 telegramUsername: "testuser",
216 signalNumber: "+1234567890",
217 })),
218 );
219 render(Comms);
220 await waitFor(() => {
221 expect(
222 (screen.getByLabelText(/discord.*id/i) as HTMLInputElement).value,
223 ).toBe("123456789");
224 expect(
225 (screen.getByLabelText(/telegram.*username/i) as HTMLInputElement)
226 .value,
227 ).toBe("testuser");
228 expect(
229 (screen.getByLabelText(/signal.*number/i) as HTMLInputElement)
230 .value,
231 ).toBe("+1234567890");
232 });
233 });
234 });
235 describe("verification status badges", () => {
236 beforeEach(() => {
237 setupAuthenticatedUser();
238 mockEndpoint(
239 "com.atproto.server.describeServer",
240 () => jsonResponse(mockData.describeServer()),
241 );
242 mockEndpoint(
243 "_account.getNotificationHistory",
244 () => jsonResponse({ notifications: [] }),
245 );
246 });
247 it("shows Primary badge for email", async () => {
248 mockEndpoint(
249 "_account.getNotificationPrefs",
250 () => jsonResponse(mockData.notificationPrefs()),
251 );
252 render(Comms);
253 await waitFor(() => {
254 expect(screen.getByText("Primary")).toBeInTheDocument();
255 });
256 });
257 it("shows Verified badge for verified discord", async () => {
258 mockEndpoint(
259 "_account.getNotificationPrefs",
260 () =>
261 jsonResponse(mockData.notificationPrefs({
262 discordId: "123456789",
263 discordVerified: true,
264 })),
265 );
266 render(Comms);
267 await waitFor(() => {
268 const verifiedBadges = screen.getAllByText("Verified");
269 expect(verifiedBadges.length).toBeGreaterThan(0);
270 });
271 });
272 it("shows Not verified badge for unverified discord", async () => {
273 mockEndpoint(
274 "_account.getNotificationPrefs",
275 () =>
276 jsonResponse(mockData.notificationPrefs({
277 discordId: "123456789",
278 discordVerified: false,
279 })),
280 );
281 render(Comms);
282 await waitFor(() => {
283 expect(screen.getByText("Not verified")).toBeInTheDocument();
284 });
285 });
286 it("does not show badge when channel not configured", async () => {
287 mockEndpoint(
288 "_account.getNotificationPrefs",
289 () => jsonResponse(mockData.notificationPrefs()),
290 );
291 render(Comms);
292 await waitFor(() => {
293 expect(screen.getByText("Primary")).toBeInTheDocument();
294 expect(screen.queryByText("Not verified")).not.toBeInTheDocument();
295 });
296 });
297 });
298 describe("save preferences", () => {
299 beforeEach(() => {
300 setupAuthenticatedUser();
301 mockEndpoint(
302 "com.atproto.server.describeServer",
303 () => jsonResponse(mockData.describeServer()),
304 );
305 mockEndpoint(
306 "_account.getNotificationHistory",
307 () => jsonResponse({ notifications: [] }),
308 );
309 });
310 it("calls updateNotificationPrefs with correct data", async () => {
311 let capturedBody: Record<string, unknown> | null = null;
312 mockEndpoint(
313 "_account.getNotificationPrefs",
314 () => jsonResponse(mockData.notificationPrefs()),
315 );
316 mockEndpoint(
317 "_account.updateNotificationPrefs",
318 (_url, options) => {
319 capturedBody = JSON.parse((options?.body as string) || "{}");
320 return jsonResponse({ success: true });
321 },
322 );
323 render(Comms);
324 await waitFor(() => {
325 expect(screen.getByLabelText(/discord.*id/i)).toBeInTheDocument();
326 });
327 await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
328 target: { value: "999888777" },
329 });
330 await fireEvent.click(
331 screen.getByRole("button", { name: /save preferences/i }),
332 );
333 await waitFor(() => {
334 expect(capturedBody).not.toBeNull();
335 expect(capturedBody?.discordId).toBe("999888777");
336 expect(capturedBody?.preferredChannel).toBe("email");
337 });
338 });
339 it("shows loading state while saving", async () => {
340 mockEndpoint(
341 "_account.getNotificationPrefs",
342 () => jsonResponse(mockData.notificationPrefs()),
343 );
344 mockEndpoint("_account.updateNotificationPrefs", async () => {
345 await new Promise((resolve) => setTimeout(resolve, 100));
346 return jsonResponse({ success: true });
347 });
348 render(Comms);
349 await waitFor(() => {
350 expect(screen.getByRole("button", { name: /save preferences/i }))
351 .toBeInTheDocument();
352 });
353 await fireEvent.click(
354 screen.getByRole("button", { name: /save preferences/i }),
355 );
356 expect(screen.getByRole("button", { name: /saving/i }))
357 .toBeInTheDocument();
358 expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
359 });
360 it("shows success toast after saving", async () => {
361 mockEndpoint(
362 "_account.getNotificationPrefs",
363 () => jsonResponse(mockData.notificationPrefs()),
364 );
365 mockEndpoint(
366 "_account.updateNotificationPrefs",
367 () => jsonResponse({ success: true }),
368 );
369 render(Comms);
370 await waitFor(() => {
371 expect(screen.getByRole("button", { name: /save preferences/i }))
372 .toBeInTheDocument();
373 });
374 await fireEvent.click(
375 screen.getByRole("button", { name: /save preferences/i }),
376 );
377 await waitFor(() => {
378 const toasts = getToasts();
379 expect(
380 toasts.some((t) => t.type === "success" && /saved/i.test(t.message)),
381 ).toBe(true);
382 });
383 });
384 it("shows error toast when save fails", async () => {
385 mockEndpoint(
386 "_account.getNotificationPrefs",
387 () => jsonResponse(mockData.notificationPrefs()),
388 );
389 mockEndpoint(
390 "_account.updateNotificationPrefs",
391 () =>
392 errorResponse("InvalidRequest", "Invalid channel configuration", 400),
393 );
394 render(Comms);
395 await waitFor(() => {
396 expect(screen.getByRole("button", { name: /save preferences/i }))
397 .toBeInTheDocument();
398 });
399 await fireEvent.click(
400 screen.getByRole("button", { name: /save preferences/i }),
401 );
402 await waitFor(() => {
403 const errors = getErrorToasts();
404 expect(errors.some((e) => /invalid channel configuration/i.test(e)))
405 .toBe(true);
406 });
407 });
408 it("reloads preferences after successful save", async () => {
409 let loadCount = 0;
410 mockEndpoint("_account.getNotificationPrefs", () => {
411 loadCount++;
412 return jsonResponse(mockData.notificationPrefs());
413 });
414 mockEndpoint(
415 "_account.updateNotificationPrefs",
416 () => jsonResponse({ success: true }),
417 );
418 render(Comms);
419 await waitFor(() => {
420 expect(screen.getByRole("button", { name: /save preferences/i }))
421 .toBeInTheDocument();
422 });
423 const initialLoadCount = loadCount;
424 await fireEvent.click(
425 screen.getByRole("button", { name: /save preferences/i }),
426 );
427 await waitFor(() => {
428 expect(loadCount).toBeGreaterThan(initialLoadCount);
429 });
430 });
431 });
432 describe("channel selection interaction", () => {
433 beforeEach(() => {
434 setupAuthenticatedUser();
435 mockEndpoint(
436 "com.atproto.server.describeServer",
437 () => jsonResponse(mockData.describeServer()),
438 );
439 mockEndpoint(
440 "_account.getNotificationHistory",
441 () => jsonResponse({ notifications: [] }),
442 );
443 });
444 it("enables discord channel after entering discord ID", async () => {
445 mockEndpoint(
446 "_account.getNotificationPrefs",
447 () => jsonResponse(mockData.notificationPrefs()),
448 );
449 render(Comms);
450 await waitFor(() => {
451 expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
452 });
453 await fireEvent.input(screen.getByLabelText(/discord.*id/i), {
454 target: { value: "123456789" },
455 });
456 await waitFor(() => {
457 expect(screen.getByRole("radio", { name: /discord/i })).not
458 .toBeDisabled();
459 });
460 });
461 it("allows selecting a configured channel", async () => {
462 mockEndpoint(
463 "_account.getNotificationPrefs",
464 () =>
465 jsonResponse(mockData.notificationPrefs({
466 discordId: "123456789",
467 discordVerified: true,
468 })),
469 );
470 render(Comms);
471 await waitFor(() => {
472 expect(screen.getByRole("radio", { name: /discord/i })).not
473 .toBeDisabled();
474 });
475 await fireEvent.click(screen.getByRole("radio", { name: /discord/i }));
476 const discordRadio = screen.getByRole("radio", {
477 name: /discord/i,
478 }) as HTMLInputElement;
479 expect(discordRadio.checked).toBe(true);
480 });
481 });
482 describe("error handling", () => {
483 beforeEach(() => {
484 setupAuthenticatedUser();
485 mockEndpoint(
486 "com.atproto.server.describeServer",
487 () => jsonResponse(mockData.describeServer()),
488 );
489 mockEndpoint(
490 "_account.getNotificationHistory",
491 () => jsonResponse({ notifications: [] }),
492 );
493 });
494 it("shows error toast when loading preferences fails", async () => {
495 mockEndpoint(
496 "_account.getNotificationPrefs",
497 () => errorResponse("InternalError", "Database connection failed", 500),
498 );
499 render(Comms);
500 await waitFor(() => {
501 const errors = getErrorToasts();
502 expect(errors.some((e) => /database connection failed/i.test(e))).toBe(
503 true,
504 );
505 });
506 });
507 });
508});