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