Our Personal Data Server from scratch!
at main 508 lines 16 kB view raw
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});