Our Personal Data Server from scratch!
0
fork

Configure Feed

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

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