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