this repo has no description
at main 15 kB view raw
1import { beforeEach, describe, expect, it, vi } from "vitest"; 2import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3import AppPasswords from "../routes/AppPasswords.svelte"; 4import { 5 clearMocks, 6 errorResponse, 7 getErrorToasts, 8 jsonResponse, 9 mockData, 10 mockEndpoint, 11 setupAuthenticatedUser, 12 setupFetchMock, 13 setupUnauthenticatedUser, 14} from "./mocks.ts"; 15import { unsafeAsISODateString } from "../lib/types/branded.ts"; 16describe("AppPasswords", () => { 17 beforeEach(() => { 18 clearMocks(); 19 setupFetchMock(); 20 globalThis.confirm = vi.fn(() => true); 21 }); 22 describe("authentication guard", () => { 23 it("redirects to login when not authenticated", async () => { 24 setupUnauthenticatedUser(); 25 render(AppPasswords); 26 await waitFor(() => { 27 expect(globalThis.location.pathname).toBe("/app/login"); 28 }); 29 }); 30 }); 31 describe("page structure", () => { 32 beforeEach(() => { 33 setupAuthenticatedUser(); 34 mockEndpoint( 35 "com.atproto.server.listAppPasswords", 36 () => jsonResponse({ passwords: [] }), 37 ); 38 }); 39 it("displays all page elements", async () => { 40 render(AppPasswords); 41 await waitFor(() => { 42 expect( 43 screen.getByRole("heading", { name: /app passwords/i, level: 1 }), 44 ).toBeInTheDocument(); 45 expect(screen.getByRole("link", { name: /dashboard/i })) 46 .toHaveAttribute("href", "/app/dashboard"); 47 expect(screen.getByText(/third-party apps/i)).toBeInTheDocument(); 48 }); 49 }); 50 }); 51 describe("loading state", () => { 52 beforeEach(() => { 53 setupAuthenticatedUser(); 54 }); 55 it("shows loading skeleton while fetching passwords", () => { 56 mockEndpoint( 57 "com.atproto.server.listAppPasswords", 58 () => 59 new Promise((resolve) => 60 setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100) 61 ), 62 ); 63 const { container } = render(AppPasswords); 64 expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0); 65 }); 66 }); 67 describe("empty state", () => { 68 beforeEach(() => { 69 setupAuthenticatedUser(); 70 mockEndpoint( 71 "com.atproto.server.listAppPasswords", 72 () => jsonResponse({ passwords: [] }), 73 ); 74 }); 75 it("shows empty message when no passwords exist", async () => { 76 render(AppPasswords); 77 await waitFor(() => { 78 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 79 }); 80 }); 81 }); 82 describe("password list", () => { 83 const testPasswords = [ 84 mockData.appPassword({ 85 name: "Graysky", 86 createdAt: unsafeAsISODateString("2024-01-15T10:00:00Z"), 87 }), 88 mockData.appPassword({ 89 name: "Skeets", 90 createdAt: unsafeAsISODateString("2024-02-20T15:30:00Z"), 91 }), 92 ]; 93 beforeEach(() => { 94 setupAuthenticatedUser(); 95 mockEndpoint( 96 "com.atproto.server.listAppPasswords", 97 () => jsonResponse({ passwords: testPasswords }), 98 ); 99 }); 100 it("displays all app passwords with dates and revoke buttons", async () => { 101 render(AppPasswords); 102 await waitFor(() => { 103 expect(screen.getByText("Graysky")).toBeInTheDocument(); 104 expect(screen.getByText("Skeets")).toBeInTheDocument(); 105 expect(screen.getByText(/created.*2024-01-15/i)).toBeInTheDocument(); 106 expect(screen.getByText(/created.*2024-02-20/i)).toBeInTheDocument(); 107 expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength( 108 2, 109 ); 110 }); 111 }); 112 }); 113 describe("create app password", () => { 114 beforeEach(() => { 115 setupAuthenticatedUser(); 116 mockEndpoint( 117 "com.atproto.server.listAppPasswords", 118 () => jsonResponse({ passwords: [] }), 119 ); 120 }); 121 it("displays create form with input and button", async () => { 122 render(AppPasswords); 123 await waitFor(() => { 124 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 125 expect(screen.getByRole("button", { name: /create/i })) 126 .toBeInTheDocument(); 127 }); 128 }); 129 it("disables create button when input is empty", async () => { 130 render(AppPasswords); 131 await waitFor(() => { 132 expect(screen.getByRole("button", { name: /create/i })).toBeDisabled(); 133 }); 134 }); 135 it("enables create button when input has value", async () => { 136 render(AppPasswords); 137 await waitFor(() => { 138 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 139 }); 140 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 141 target: { value: "My New App" }, 142 }); 143 expect(screen.getByRole("button", { name: /create/i })).not 144 .toBeDisabled(); 145 }); 146 it("calls createAppPassword with correct name", async () => { 147 let capturedName: string | null = null; 148 mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => { 149 const body = JSON.parse((options?.body as string) || "{}"); 150 capturedName = body.name; 151 return jsonResponse({ 152 name: body.name, 153 password: "xxxx-xxxx-xxxx-xxxx", 154 createdAt: new Date().toISOString(), 155 }); 156 }); 157 render(AppPasswords); 158 await waitFor(() => { 159 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 160 }); 161 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 162 target: { value: "Graysky" }, 163 }); 164 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 165 await waitFor(() => { 166 expect(capturedName).toBe("Graysky"); 167 }); 168 }); 169 it("shows loading state while creating", async () => { 170 mockEndpoint("com.atproto.server.createAppPassword", async () => { 171 await new Promise((resolve) => setTimeout(resolve, 100)); 172 return jsonResponse({ 173 name: "Test", 174 password: "xxxx-xxxx-xxxx-xxxx", 175 createdAt: new Date().toISOString(), 176 }); 177 }); 178 render(AppPasswords); 179 await waitFor(() => { 180 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 181 }); 182 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 183 target: { value: "Test" }, 184 }); 185 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 186 expect(screen.getByRole("button", { name: /creating/i })) 187 .toBeInTheDocument(); 188 expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled(); 189 }); 190 it("displays created password in success box and clears input", async () => { 191 mockEndpoint("com.atproto.server.createAppPassword", () => 192 jsonResponse({ 193 name: "MyApp", 194 password: "abcd-efgh-ijkl-mnop", 195 createdAt: new Date().toISOString(), 196 })); 197 render(AppPasswords); 198 await waitFor(() => { 199 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 200 }); 201 const input = screen.getByPlaceholderText( 202 /app name/i, 203 ) as HTMLInputElement; 204 await fireEvent.input(input, { target: { value: "MyApp" } }); 205 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 206 await waitFor(() => { 207 expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 208 expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument(); 209 expect(screen.getByText("MyApp")).toBeInTheDocument(); 210 expect(input.value).toBe(""); 211 }); 212 }); 213 it("dismisses created password box when clicking Done", async () => { 214 mockEndpoint("com.atproto.server.createAppPassword", () => 215 jsonResponse({ 216 name: "Test", 217 password: "xxxx-xxxx-xxxx-xxxx", 218 createdAt: new Date().toISOString(), 219 })); 220 render(AppPasswords); 221 await waitFor(() => { 222 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 223 }); 224 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 225 target: { value: "Test" }, 226 }); 227 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 228 await waitFor(() => { 229 expect(screen.getByText(/save this app password/i)).toBeInTheDocument(); 230 }); 231 await fireEvent.click( 232 screen.getByLabelText(/i have saved my app password/i), 233 ); 234 await fireEvent.click(screen.getByRole("button", { name: /done/i })); 235 await waitFor(() => { 236 expect(screen.queryByText(/save this app password/i)).not 237 .toBeInTheDocument(); 238 }); 239 }); 240 it("shows error toast when creation fails", async () => { 241 mockEndpoint( 242 "com.atproto.server.createAppPassword", 243 () => errorResponse("InvalidRequest", "Name already exists", 400), 244 ); 245 render(AppPasswords); 246 await waitFor(() => { 247 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 248 }); 249 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 250 target: { value: "Duplicate" }, 251 }); 252 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 253 await waitFor(() => { 254 const errors = getErrorToasts(); 255 expect(errors.some((e) => /name already exists/i.test(e))).toBe(true); 256 }); 257 }); 258 }); 259 describe("revoke app password", () => { 260 const testPassword = mockData.appPassword({ name: "TestApp" }); 261 beforeEach(() => { 262 setupAuthenticatedUser(); 263 }); 264 it("shows confirmation dialog before revoking", async () => { 265 const confirmSpy = vi.fn(() => false); 266 globalThis.confirm = confirmSpy; 267 mockEndpoint( 268 "com.atproto.server.listAppPasswords", 269 () => jsonResponse({ passwords: [testPassword] }), 270 ); 271 render(AppPasswords); 272 await waitFor(() => { 273 expect(screen.getByText("TestApp")).toBeInTheDocument(); 274 }); 275 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 276 expect(confirmSpy).toHaveBeenCalledWith( 277 expect.stringContaining("TestApp"), 278 ); 279 }); 280 it("does not revoke when confirmation is cancelled", async () => { 281 globalThis.confirm = vi.fn(() => false); 282 let revokeCalled = false; 283 mockEndpoint( 284 "com.atproto.server.listAppPasswords", 285 () => jsonResponse({ passwords: [testPassword] }), 286 ); 287 mockEndpoint("com.atproto.server.revokeAppPassword", () => { 288 revokeCalled = true; 289 return jsonResponse({}); 290 }); 291 render(AppPasswords); 292 await waitFor(() => { 293 expect(screen.getByText("TestApp")).toBeInTheDocument(); 294 }); 295 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 296 expect(revokeCalled).toBe(false); 297 }); 298 it("calls revokeAppPassword with correct name", async () => { 299 globalThis.confirm = vi.fn(() => true); 300 let capturedName: string | null = null; 301 mockEndpoint( 302 "com.atproto.server.listAppPasswords", 303 () => jsonResponse({ passwords: [testPassword] }), 304 ); 305 mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => { 306 const body = JSON.parse((options?.body as string) || "{}"); 307 capturedName = body.name; 308 return jsonResponse({}); 309 }); 310 render(AppPasswords); 311 await waitFor(() => { 312 expect(screen.getByText("TestApp")).toBeInTheDocument(); 313 }); 314 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 315 await waitFor(() => { 316 expect(capturedName).toBe("TestApp"); 317 }); 318 }); 319 it("shows loading state while revoking", async () => { 320 globalThis.confirm = vi.fn(() => true); 321 mockEndpoint( 322 "com.atproto.server.listAppPasswords", 323 () => jsonResponse({ passwords: [testPassword] }), 324 ); 325 mockEndpoint("com.atproto.server.revokeAppPassword", async () => { 326 await new Promise((resolve) => setTimeout(resolve, 100)); 327 return jsonResponse({}); 328 }); 329 render(AppPasswords); 330 await waitFor(() => { 331 expect(screen.getByText("TestApp")).toBeInTheDocument(); 332 }); 333 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 334 expect(screen.getByRole("button", { name: /revoking/i })) 335 .toBeInTheDocument(); 336 expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled(); 337 }); 338 it("reloads password list after successful revocation", async () => { 339 globalThis.confirm = vi.fn(() => true); 340 let listCallCount = 0; 341 mockEndpoint("com.atproto.server.listAppPasswords", () => { 342 listCallCount++; 343 if (listCallCount === 1) { 344 return jsonResponse({ passwords: [testPassword] }); 345 } 346 return jsonResponse({ passwords: [] }); 347 }); 348 mockEndpoint( 349 "com.atproto.server.revokeAppPassword", 350 () => jsonResponse({}), 351 ); 352 render(AppPasswords); 353 await waitFor(() => { 354 expect(screen.getByText("TestApp")).toBeInTheDocument(); 355 }); 356 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 357 await waitFor(() => { 358 expect(screen.queryByText("TestApp")).not.toBeInTheDocument(); 359 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 360 }); 361 }); 362 it("shows error toast when revocation fails", async () => { 363 globalThis.confirm = vi.fn(() => true); 364 mockEndpoint( 365 "com.atproto.server.listAppPasswords", 366 () => jsonResponse({ passwords: [testPassword] }), 367 ); 368 mockEndpoint( 369 "com.atproto.server.revokeAppPassword", 370 () => errorResponse("InternalError", "Server error", 500), 371 ); 372 render(AppPasswords); 373 await waitFor(() => { 374 expect(screen.getByText("TestApp")).toBeInTheDocument(); 375 }); 376 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 377 await waitFor(() => { 378 const errors = getErrorToasts(); 379 expect(errors.some((e) => /server error/i.test(e))).toBe(true); 380 }); 381 }); 382 }); 383 describe("error handling", () => { 384 beforeEach(() => { 385 setupAuthenticatedUser(); 386 }); 387 it("shows error toast when loading passwords fails", async () => { 388 mockEndpoint( 389 "com.atproto.server.listAppPasswords", 390 () => errorResponse("InternalError", "Database connection failed", 500), 391 ); 392 render(AppPasswords); 393 await waitFor(() => { 394 const errors = getErrorToasts(); 395 expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 396 }); 397 }); 398 }); 399});