Skip to content
File

Blob: tests/client/hooks/useInboxPageActions.test.tsx

typescript361 lines
1import { act, renderHook, waitFor } from "@testing-library/react";
2import { MemoryRouter } from "react-router-dom";
3import { beforeEach, describe, expect, it, vi } from "vitest";
4import { useInboxPageActions } from "@/client/hooks/useInboxPageActions";
5import { clipboardWriteTextMock, confirmMock } from "../../setup/client";
6 
7const {
8 deleteInboxMock,
9 extendInboxMock,
10 getErrorMessageMock,
11 navigateMock,
12 removeInboxSessionMock,
13 toastErrorMock,
14 toastSuccessMock,
15 updateInboxSessionMock,
16} = vi.hoisted(() => ({
17 deleteInboxMock: vi.fn(),
18 extendInboxMock: vi.fn(),
19 getErrorMessageMock: vi.fn((error: unknown) => (error instanceof Error ? error.message : "Something went wrong")),
20 navigateMock: vi.fn<(path: string) => void>(),
21 removeInboxSessionMock: vi.fn(),
22 toastErrorMock: vi.fn<(message: string) => void>(),
23 toastSuccessMock: vi.fn<(message: string) => void>(),
24 updateInboxSessionMock: vi.fn(),
25}));
26 
27vi.mock("react-router-dom", async () => {
28 const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
29 return {
30 ...actual,
31 useNavigate: () => navigateMock,
32 };
33});
34 
35vi.mock("@/client/components/toast", () => ({
36 toast: {
37 success: toastSuccessMock,
38 error: toastErrorMock,
39 },
40}));
41 
42vi.mock("@/client/lib/api", async () => {
43 const actual = await vi.importActual<typeof import("@/client/lib/api")>("@/client/lib/api");
44 return {
45 ...actual,
46 deleteInbox: deleteInboxMock,
47 extendInbox: extendInboxMock,
48 getErrorMessage: getErrorMessageMock,
49 removeInboxSession: removeInboxSessionMock,
50 updateInboxSession: updateInboxSessionMock,
51 };
52});
53 
54function createWrapper() {
55 return function Wrapper({ children }: { children: React.ReactNode }) {
56 return <MemoryRouter>{children}</MemoryRouter>;
57 };
58}
59 
60function makeInbox(
61 overrides: Partial<{ expiresAt: string; isPermanent: boolean; ttlHours: 24 | 48 | 72 | null }> = {},
62) {
63 return {
64 address: "reader@mail.test",
65 createdAt: "2026-04-15T00:00:00.000Z",
66 expiresAt: overrides.expiresAt ?? "2026-04-16T00:00:00.000Z",
67 isPermanent: overrides.isPermanent ?? false,
68 ttlHours: overrides.ttlHours ?? 24,
69 };
70}
71 
72describe("useInboxPageActions", () => {
73 beforeEach(() => {
74 navigateMock.mockReset();
75 toastSuccessMock.mockReset();
76 toastErrorMock.mockReset();
77 deleteInboxMock.mockReset();
78 extendInboxMock.mockReset();
79 getErrorMessageMock.mockClear();
80 removeInboxSessionMock.mockReset();
81 updateInboxSessionMock.mockReset();
82 confirmMock.mockReset();
83 confirmMock.mockReturnValue(true);
84 clipboardWriteTextMock.mockReset();
85 clipboardWriteTextMock.mockResolvedValue(undefined);
86 });
87 
88 it("copies the inbox address and shows a success toast", async () => {
89 const { result } = renderHook(
90 () =>
91 useInboxPageActions({
92 address: "reader@mail.test",
93 adminMode: false,
94 inbox: makeInbox(),
95 onDeleted: vi.fn(),
96 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
97 selectEmail: vi.fn(),
98 session: {
99 address: "reader@mail.test",
100 auth: { mode: "user" as const, token: "tok_user" },
101 },
102 }),
103 {
104 wrapper: createWrapper(),
105 },
106 );
107 
108 await act(async () => {
109 await result.current.handleCopy();
110 });
111 
112 expect(clipboardWriteTextMock).toHaveBeenCalledWith("reader@mail.test");
113 expect(toastSuccessMock).toHaveBeenCalledWith("Address copied to clipboard");
114 });
115 
116 it("shows an error toast when copy fails", async () => {
117 clipboardWriteTextMock.mockRejectedValueOnce(new Error("copy failed"));
118 const { result } = renderHook(
119 () =>
120 useInboxPageActions({
121 address: "reader@mail.test",
122 adminMode: false,
123 inbox: makeInbox(),
124 onDeleted: vi.fn(),
125 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
126 selectEmail: vi.fn(),
127 session: {
128 address: "reader@mail.test",
129 auth: { mode: "user" as const, token: "tok_user" },
130 },
131 }),
132 {
133 wrapper: createWrapper(),
134 },
135 );
136 
137 await act(async () => {
138 await result.current.handleCopy();
139 });
140 
141 expect(toastErrorMock).toHaveBeenCalledWith("Could not copy the inbox address.");
142 });
143 
144 it("does nothing when inbox deletion is cancelled", async () => {
145 confirmMock.mockReturnValueOnce(false);
146 const onDeleted = vi.fn();
147 const { result } = renderHook(
148 () =>
149 useInboxPageActions({
150 address: "reader@mail.test",
151 adminMode: false,
152 inbox: makeInbox(),
153 onDeleted,
154 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
155 selectEmail: vi.fn(),
156 session: {
157 address: "reader@mail.test",
158 auth: { mode: "user" as const, token: "tok_user" },
159 },
160 }),
161 {
162 wrapper: createWrapper(),
163 },
164 );
165 
166 await act(async () => {
167 await result.current.handleDeleteInbox();
168 });
169 
170 expect(deleteInboxMock).not.toHaveBeenCalled();
171 expect(onDeleted).not.toHaveBeenCalled();
172 });
173 
174 it("deletes an inbox, clears session state, notifies, and navigates", async () => {
175 deleteInboxMock.mockResolvedValueOnce({ ok: true });
176 const onDeleted = vi.fn();
177 const { result } = renderHook(
178 () =>
179 useInboxPageActions({
180 address: "reader@mail.test",
181 adminMode: false,
182 inbox: makeInbox(),
183 onDeleted,
184 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
185 selectEmail: vi.fn(),
186 session: {
187 address: "reader@mail.test",
188 auth: { mode: "user" as const, token: "tok_user" },
189 },
190 }),
191 {
192 wrapper: createWrapper(),
193 },
194 );
195 
196 await act(async () => {
197 await result.current.handleDeleteInbox();
198 });
199 
200 expect(deleteInboxMock).toHaveBeenCalledWith("reader@mail.test", { mode: "user", token: "tok_user" });
201 expect(removeInboxSessionMock).toHaveBeenCalledWith("reader@mail.test");
202 expect(onDeleted).toHaveBeenCalledWith("reader@mail.test");
203 expect(toastSuccessMock).toHaveBeenCalledWith("Inbox deleted");
204 expect(navigateMock).toHaveBeenCalledWith("/");
205 });
206 
207 it("surfaces inbox deletion failures", async () => {
208 deleteInboxMock.mockRejectedValueOnce(new Error("Delete failed"));
209 const { result } = renderHook(
210 () =>
211 useInboxPageActions({
212 address: "reader@mail.test",
213 adminMode: false,
214 inbox: makeInbox(),
215 onDeleted: vi.fn(),
216 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
217 selectEmail: vi.fn(),
218 session: {
219 address: "reader@mail.test",
220 auth: { mode: "user" as const, token: "tok_user" },
221 },
222 }),
223 {
224 wrapper: createWrapper(),
225 },
226 );
227 
228 await act(async () => {
229 await result.current.handleDeleteInbox();
230 });
231 
232 expect(toastErrorMock).toHaveBeenCalledWith("Delete failed");
233 });
234 
235 it("extends an inbox, updates local session state, and refreshes the inbox", async () => {
236 extendInboxMock.mockResolvedValueOnce({
237 address: "reader@mail.test",
238 expiresAt: "2026-04-18T00:00:00.000Z",
239 ttlHours: 72,
240 });
241 const refreshInbox = vi.fn().mockResolvedValue(makeInbox({ ttlHours: 72, expiresAt: "2026-04-18T00:00:00.000Z" }));
242 const { result } = renderHook(
243 () =>
244 useInboxPageActions({
245 address: "reader@mail.test",
246 adminMode: false,
247 inbox: makeInbox(),
248 onDeleted: vi.fn(),
249 refreshInbox,
250 selectEmail: vi.fn(),
251 session: {
252 address: "reader@mail.test",
253 auth: { mode: "user" as const, token: "tok_user" },
254 },
255 }),
256 {
257 wrapper: createWrapper(),
258 },
259 );
260 
261 await act(async () => {
262 await result.current.handleExtendInbox(72);
263 });
264 
265 expect(extendInboxMock).toHaveBeenCalledWith("reader@mail.test", { mode: "user", token: "tok_user" }, 72);
266 expect(updateInboxSessionMock).toHaveBeenCalledWith("reader@mail.test", {
267 expiresAt: "2026-04-18T00:00:00.000Z",
268 ttlHours: 72,
269 });
270 expect(refreshInbox).toHaveBeenCalled();
271 expect(toastSuccessMock).toHaveBeenCalledWith("Inbox extended to 72h");
272 await waitFor(() => {
273 expect(result.current.extendingTo).toBeNull();
274 });
275 });
276 
277 it("surfaces inbox extension failures", async () => {
278 extendInboxMock.mockRejectedValueOnce(new Error("Extend failed"));
279 const { result } = renderHook(
280 () =>
281 useInboxPageActions({
282 address: "reader@mail.test",
283 adminMode: false,
284 inbox: makeInbox(),
285 onDeleted: vi.fn(),
286 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
287 selectEmail: vi.fn(),
288 session: {
289 address: "reader@mail.test",
290 auth: { mode: "user" as const, token: "tok_user" },
291 },
292 }),
293 {
294 wrapper: createWrapper(),
295 },
296 );
297 
298 await act(async () => {
299 await result.current.handleExtendInbox(72);
300 });
301 
302 expect(toastErrorMock).toHaveBeenCalledWith("Extend failed");
303 expect(updateInboxSessionMock).not.toHaveBeenCalled();
304 });
305 
306 it("is a no-op when the session is missing or the inbox is permanent", async () => {
307 const { result: missingSession } = renderHook(
308 () =>
309 useInboxPageActions({
310 address: "reader@mail.test",
311 adminMode: false,
312 inbox: makeInbox(),
313 onDeleted: vi.fn(),
314 refreshInbox: vi.fn().mockResolvedValue(makeInbox()),
315 selectEmail: vi.fn(),
316 session: null,
317 }),
318 {
319 wrapper: createWrapper(),
320 },
321 );
322 
323 await act(async () => {
324 await missingSession.current.handleDeleteInbox();
325 await missingSession.current.handleExtendInbox(72);
326 });
327 
328 const { result: permanentInbox } = renderHook(
329 () =>
330 useInboxPageActions({
331 address: "admin@mail.test",
332 adminMode: true,
333 inbox: makeInbox({ isPermanent: true, ttlHours: null, expiresAt: null as never }),
334 onDeleted: vi.fn(),
335 refreshInbox: vi
336 .fn()
337 .mockResolvedValue(makeInbox({ isPermanent: true, ttlHours: null, expiresAt: null as never })),
338 selectEmail: vi.fn(),
339 session: {
340 address: "admin@mail.test",
341 auth: { mode: "admin" as const },
342 },
343 }),
344 {
345 wrapper: createWrapper(),
346 },
347 );
348 
349 await act(async () => {
350 await permanentInbox.current.handleDeleteInbox();
351 await permanentInbox.current.handleExtendInbox(72);
352 });
353 
354 expect(deleteInboxMock).not.toHaveBeenCalled();
355 expect(extendInboxMock).not.toHaveBeenCalled();
356 expect(permanentInbox.current.availableExtensions).toEqual([]);
357 expect(permanentInbox.current.canDeleteEmail).toBe(true);
358 expect(missingSession.current.canDeleteEmail).toBe(true);
359 });
360});