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