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