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