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