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 window.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(window.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.*1\/15\/2024/i)).toBeInTheDocument();
101 expect(screen.getByText(/created.*2\/20\/2024/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(/app password created/i)).toBeInTheDocument();
203 expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
204 expect(screen.getByText(/name: myapp/i)).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(/app password created/i)).toBeInTheDocument();
225 });
226 await fireEvent.click(screen.getByRole("button", { name: /done/i }));
227 await waitFor(() => {
228 expect(screen.queryByText(/app password created/i)).not
229 .toBeInTheDocument();
230 });
231 });
232 it("shows error when creation fails", async () => {
233 mockEndpoint(
234 "com.atproto.server.createAppPassword",
235 () => errorResponse("InvalidRequest", "Name already exists", 400),
236 );
237 render(AppPasswords);
238 await waitFor(() => {
239 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
240 });
241 await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
242 target: { value: "Duplicate" },
243 });
244 await fireEvent.click(screen.getByRole("button", { name: /create/i }));
245 await waitFor(() => {
246 expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
247 expect(screen.getByText(/name already exists/i)).toHaveClass("error");
248 });
249 });
250 });
251 describe("revoke app password", () => {
252 const testPassword = mockData.appPassword({ name: "TestApp" });
253 beforeEach(() => {
254 setupAuthenticatedUser();
255 });
256 it("shows confirmation dialog before revoking", async () => {
257 const confirmSpy = vi.fn(() => false);
258 window.confirm = confirmSpy;
259 mockEndpoint(
260 "com.atproto.server.listAppPasswords",
261 () => jsonResponse({ passwords: [testPassword] }),
262 );
263 render(AppPasswords);
264 await waitFor(() => {
265 expect(screen.getByText("TestApp")).toBeInTheDocument();
266 });
267 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
268 expect(confirmSpy).toHaveBeenCalledWith(
269 expect.stringContaining("TestApp"),
270 );
271 });
272 it("does not revoke when confirmation is cancelled", async () => {
273 window.confirm = vi.fn(() => false);
274 let revokeCalled = false;
275 mockEndpoint(
276 "com.atproto.server.listAppPasswords",
277 () => jsonResponse({ passwords: [testPassword] }),
278 );
279 mockEndpoint("com.atproto.server.revokeAppPassword", () => {
280 revokeCalled = true;
281 return jsonResponse({});
282 });
283 render(AppPasswords);
284 await waitFor(() => {
285 expect(screen.getByText("TestApp")).toBeInTheDocument();
286 });
287 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
288 expect(revokeCalled).toBe(false);
289 });
290 it("calls revokeAppPassword with correct name", async () => {
291 window.confirm = vi.fn(() => true);
292 let capturedName: string | null = null;
293 mockEndpoint(
294 "com.atproto.server.listAppPasswords",
295 () => jsonResponse({ passwords: [testPassword] }),
296 );
297 mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => {
298 const body = JSON.parse((options?.body as string) || "{}");
299 capturedName = body.name;
300 return jsonResponse({});
301 });
302 render(AppPasswords);
303 await waitFor(() => {
304 expect(screen.getByText("TestApp")).toBeInTheDocument();
305 });
306 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
307 await waitFor(() => {
308 expect(capturedName).toBe("TestApp");
309 });
310 });
311 it("shows loading state while revoking", async () => {
312 window.confirm = vi.fn(() => true);
313 mockEndpoint(
314 "com.atproto.server.listAppPasswords",
315 () => jsonResponse({ passwords: [testPassword] }),
316 );
317 mockEndpoint("com.atproto.server.revokeAppPassword", async () => {
318 await new Promise((resolve) => setTimeout(resolve, 100));
319 return jsonResponse({});
320 });
321 render(AppPasswords);
322 await waitFor(() => {
323 expect(screen.getByText("TestApp")).toBeInTheDocument();
324 });
325 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
326 expect(screen.getByRole("button", { name: /revoking/i }))
327 .toBeInTheDocument();
328 expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
329 });
330 it("reloads password list after successful revocation", async () => {
331 window.confirm = vi.fn(() => true);
332 let listCallCount = 0;
333 mockEndpoint("com.atproto.server.listAppPasswords", () => {
334 listCallCount++;
335 if (listCallCount === 1) {
336 return jsonResponse({ passwords: [testPassword] });
337 }
338 return jsonResponse({ passwords: [] });
339 });
340 mockEndpoint(
341 "com.atproto.server.revokeAppPassword",
342 () => jsonResponse({}),
343 );
344 render(AppPasswords);
345 await waitFor(() => {
346 expect(screen.getByText("TestApp")).toBeInTheDocument();
347 });
348 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
349 await waitFor(() => {
350 expect(screen.queryByText("TestApp")).not.toBeInTheDocument();
351 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
352 });
353 });
354 it("shows error when revocation fails", async () => {
355 window.confirm = vi.fn(() => true);
356 mockEndpoint(
357 "com.atproto.server.listAppPasswords",
358 () => jsonResponse({ passwords: [testPassword] }),
359 );
360 mockEndpoint(
361 "com.atproto.server.revokeAppPassword",
362 () => errorResponse("InternalError", "Server error", 500),
363 );
364 render(AppPasswords);
365 await waitFor(() => {
366 expect(screen.getByText("TestApp")).toBeInTheDocument();
367 });
368 await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
369 await waitFor(() => {
370 expect(screen.getByText(/server error/i)).toBeInTheDocument();
371 expect(screen.getByText(/server error/i)).toHaveClass("error");
372 });
373 });
374 });
375 describe("error handling", () => {
376 beforeEach(() => {
377 setupAuthenticatedUser();
378 });
379 it("shows error when loading passwords fails", async () => {
380 mockEndpoint(
381 "com.atproto.server.listAppPasswords",
382 () => errorResponse("InternalError", "Database connection failed", 500),
383 );
384 render(AppPasswords);
385 await waitFor(() => {
386 expect(screen.getByText(/database connection failed/i))
387 .toBeInTheDocument();
388 expect(screen.getByText(/database connection failed/i)).toHaveClass(
389 "error",
390 );
391 });
392 });
393 });
394});