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