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});