Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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