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