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 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});