1import { delay } from "@clo/lib/async.ts";
2import { assertEquals } from "@std/assert";
3import { test, vi } from "vitest";
4import { createTestMutationClient } from "./share.ts";
5
6test.each([
7 false,
8 true,
9])("run executes callbacks in correct order (refetchOnSuccess=%s)", async (refetchOnSuccess) => {
10 vi.useFakeTimers();
11 const { client, errorMessages, successMessages } = createTestMutationClient();
12 const calls: string[] = [];
13 const mutTest = client.define({
14 id: "test-1",
15 mutate: async () => {
16 calls.push("mutate");
17 await delay(100);
18 return "success";
19 },
20 describe: () => {
21 calls.push("describe");
22 return "Test the action";
23 },
24 describeResult: () => {
25 calls.push("describeResult");
26 return "Tested the action";
27 },
28 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
29 onRefetch(async () => void calls.push("refetch"));
30 onSuccess(async () => void calls.push("success"));
31 onRestore(async () => void calls.push("restore"));
32 calls.push("optimistic");
33 },
34 refetchOnSuccess,
35 });
36 mutTest.run();
37 await vi.advanceTimersByTimeAsync(50);
38 assertEquals(calls, [
39 "optimistic",
40 "mutate",
41 ]);
42 await vi.advanceTimersByTimeAsync(100);
43 assertEquals(
44 calls,
45 [
46 "optimistic",
47 "mutate",
48 "success",
49 refetchOnSuccess && "refetch",
50 "describeResult",
51 ].filter(Boolean),
52 );
53 assertEquals(errorMessages, []);
54 assertEquals(successMessages, ["Tested the action"]);
55});
56
57test("error case: describe is called before rollback", async () => {
58 vi.useFakeTimers();
59 const { client, errorMessages, successMessages } = createTestMutationClient();
60 const calls: string[] = [];
61 let optimisticState = false;
62
63 const mutTest = client.define({
64 id: "test-2",
65 mutate: async () => {
66 calls.push("mutate");
67 await delay(100);
68 throw new Error("API failed");
69 },
70 describe: () => {
71 calls.push("describe");
72 // This simulates reading optimistic state (e.g., get(...).isFollowing)
73 calls.push(`describe-state:${optimisticState}`);
74 return "follow user";
75 },
76 describeResult: () => {
77 calls.push("describeResult");
78 return "Followed user";
79 },
80 optimistic: ({ onRefetch, onRestore, onSuccess }) => {
81 optimisticState = true;
82 onRefetch(async () => void calls.push("refetch"));
83 onSuccess(async () => void calls.push("success"));
84 onRestore(async () => {
85 calls.push("restore");
86 optimisticState = false;
87 });
88 calls.push("optimistic");
89 },
90 });
91
92 mutTest.run();
93 await vi.advanceTimersByTimeAsync(50);
94 assertEquals(calls, [
95 "optimistic",
96 "mutate",
97 ]);
98
99 await vi.advanceTimersByTimeAsync(100);
100 // CRITICAL: describe must be called BEFORE restore
101 // So the order should be: optimistic -> mutate -> describe -> restore -> refetch
102 const describeIndex = calls.indexOf("describe");
103 const restoreIndex = calls.indexOf("restore");
104
105 assertEquals(
106 describeIndex < restoreIndex,
107 true,
108 `describe (at ${describeIndex}) must be called before restore (at ${restoreIndex}). Actual order: ${
109 calls.join(", ")
110 }`,
111 );
112
113 // Verify describe was called with optimistic state still active
114 assertEquals(calls.includes("describe-state:true"), true, "describe should see optimistic state");
115
116 assertEquals(successMessages, []);
117 assertEquals(errorMessages.length, 1);
118 assertEquals(errorMessages[0].message, "Could not follow user: API failed");
119});
120
121test("error case with refetchOnSuccess=false still refetches", async () => {
122 vi.useFakeTimers();
123 const { client, errorMessages } = createTestMutationClient();
124 const calls: string[] = [];
125
126 const mutTest = client.define({
127 id: "test-3",
128 mutate: async () => {
129 calls.push("mutate");
130 await delay(100);
131 throw new Error("Failed");
132 },
133 describe: () => {
134 calls.push("describe");
135 return "test action";
136 },
137 describeResult: null,
138 optimistic: ({ onRefetch, onRestore }) => {
139 onRefetch(async () => void calls.push("refetch"));
140 onRestore(async () => void calls.push("restore"));
141 calls.push("optimistic");
142 },
143 refetchOnSuccess: false,
144 });
145
146 mutTest.run();
147 await vi.advanceTimersByTimeAsync(200);
148
149 // Even with refetchOnSuccess=false, errors should still trigger refetch
150 assertEquals(calls.includes("refetch"), true, "refetch should be called on error");
151 assertEquals(calls.includes("restore"), true, "restore should be called on error");
152 assertEquals(errorMessages.length, 1);
153});
154
155test("multiple mutations in sequence: ordering preserved", async () => {
156 vi.useFakeTimers();
157 const { client, successMessages } = createTestMutationClient();
158 const calls: string[] = [];
159
160 const mutTest = client.define({
161 id: "test-4",
162 mutate: async (id: number) => {
163 calls.push(`mutate-${id}`);
164 await delay(100);
165 return `result-${id}`;
166 },
167 describe: () => {
168 calls.push("describe");
169 return "test action";
170 },
171 describeResult: (ctx) => {
172 calls.push(`describeResult-${ctx.args[0]}`);
173 return `Completed ${ctx.args[0]}`;
174 },
175 optimistic: ({ args, onSuccess, onRestore }) => {
176 onSuccess(async () => void calls.push(`success-${args[0]}`));
177 onRestore(async () => void calls.push(`restore-${args[0]}`));
178 calls.push(`optimistic-${args[0]}`);
179 },
180 refetchOnSuccess: false,
181 });
182
183 mutTest.run(1);
184 mutTest.run(2);
185 mutTest.run(3);
186
187 await vi.advanceTimersByTimeAsync(50);
188 // Optimistic updates apply immediately, but mutate-1 starts right after optimistic-1
189 assertEquals(calls, [
190 "optimistic-1",
191 "mutate-1",
192 "optimistic-2",
193 "optimistic-3",
194 ]);
195
196 await vi.advanceTimersByTimeAsync(100);
197 // describeResult is called in a promise handler, so it happens after mutate-2 starts
198 assertEquals(calls, [
199 "optimistic-1",
200 "mutate-1",
201 "optimistic-2",
202 "optimistic-3",
203 "success-1",
204 "mutate-2",
205 "describeResult-1",
206 ]);
207
208 await vi.advanceTimersByTimeAsync(100);
209 assertEquals(calls, [
210 "optimistic-1",
211 "mutate-1",
212 "optimistic-2",
213 "optimistic-3",
214 "success-1",
215 "mutate-2",
216 "describeResult-1",
217 "success-2",
218 "mutate-3",
219 "describeResult-2",
220 ]);
221
222 await vi.advanceTimersByTimeAsync(100);
223 assertEquals(successMessages, ["Completed 1", "Completed 2", "Completed 3"]);
224});
225
226test("error in second mutation: first stays applied, second rolls back", async () => {
227 vi.useFakeTimers();
228 const { client, errorMessages, successMessages } = createTestMutationClient();
229 const calls: string[] = [];
230 let state = 0;
231
232 const mutTest = client.define({
233 id: "test-5",
234 mutate: async (id: number) => {
235 calls.push(`mutate-${id}`);
236 await delay(100);
237 if (id === 2) throw new Error("Second failed");
238 return `result-${id}`;
239 },
240 describe: (ctx) => {
241 calls.push(`describe-${ctx.args[0]}`);
242 calls.push(`describe-${ctx.args[0]}-state:${state}`);
243 return `action ${ctx.args[0]}`;
244 },
245 describeResult: (ctx) => {
246 calls.push(`describeResult-${ctx.args[0]}`);
247 return `Completed ${ctx.args[0]}`;
248 },
249 optimistic: ({ args, onSuccess, onRestore }) => {
250 state += 1;
251 onSuccess(async () => void calls.push(`success-${args[0]}`));
252 onRestore(async () => {
253 calls.push(`restore-${args[0]}`);
254 state -= 1;
255 });
256 calls.push(`optimistic-${args[0]}`);
257 },
258 refetchOnSuccess: false,
259 });
260
261 mutTest.run(1);
262 mutTest.run(2);
263
264 await vi.advanceTimersByTimeAsync(250);
265
266 // First mutation succeeds, second fails
267 // describe-2 should see state=2 (both optimistic updates applied)
268 // Then restore-2 rolls back only the second mutation
269 assertEquals(calls.includes("describe-2-state:2"), true, "describe for error should see optimistic state");
270 assertEquals(calls.includes("success-1"), true);
271 assertEquals(calls.includes("restore-2"), true);
272 assertEquals(successMessages, ["Completed 1"]);
273 assertEquals(errorMessages.length, 1);
274 assertEquals(errorMessages[0].message, "Could not action 2: Second failed");
275});
276
277test("onSuccess callback ordering relative to refetch", async () => {
278 vi.useFakeTimers();
279 const { client } = createTestMutationClient();
280 const calls: string[] = [];
281
282 const mutTest = client.define({
283 id: "test-6",
284 mutate: async () => {
285 calls.push("mutate");
286 await delay(100);
287 return "success";
288 },
289 describe: () => "test",
290 describeResult: () => "tested",
291 optimistic: ({ onRefetch, onSuccess }) => {
292 onRefetch(async () => {
293 calls.push("refetch-start");
294 await delay(50);
295 calls.push("refetch-end");
296 });
297 onSuccess(async () => {
298 calls.push("onSuccess-start");
299 await delay(30);
300 calls.push("onSuccess-end");
301 });
302 calls.push("optimistic");
303 },
304 refetchOnSuccess: true,
305 });
306
307 mutTest.run();
308 await vi.advanceTimersByTimeAsync(100);
309
310 // onSuccess should complete before refetch starts
311 const onSuccessEndIndex = calls.indexOf("onSuccess-end");
312 const refetchStartIndex = calls.indexOf("refetch-start");
313
314 assertEquals(
315 onSuccessEndIndex < refetchStartIndex,
316 true,
317 `onSuccess must complete before refetch starts. Order: ${calls.join(", ")}`,
318 );
319
320 await vi.advanceTimersByTimeAsync(100);
321 assertEquals(calls.includes("refetch-end"), true);
322});
323
324test("runWithOptions callbacks: onError called before global handler", async () => {
325 vi.useFakeTimers();
326 const { client, errorMessages } = createTestMutationClient();
327 const calls: string[] = [];
328
329 const mutTest = client.define({
330 id: "test-7",
331 mutate: async () => {
332 await delay(100);
333 throw new Error("Failed");
334 },
335 describe: () => {
336 calls.push("describe");
337 return "test";
338 },
339 describeResult: () => "tested",
340 optimistic: ({ onRestore }) => {
341 onRestore(() => calls.push("restore"));
342 calls.push("optimistic");
343 },
344 });
345
346 mutTest.runWithOptions({
347 onError: () => {
348 calls.push("onError");
349 },
350 onSettled: () => {
351 calls.push("onSettled");
352 },
353 });
354
355 await vi.advanceTimersByTimeAsync(200);
356
357 // onError suppresses global error handler
358 assertEquals(errorMessages, [], "onError should suppress global error handler");
359 assertEquals(calls.includes("onError"), true);
360 assertEquals(calls.includes("onSettled"), true);
361
362 // onError should be called before onSettled
363 const onErrorIndex = calls.indexOf("onError");
364 const onSettledIndex = calls.indexOf("onSettled");
365 assertEquals(onErrorIndex < onSettledIndex, true);
366});
367
368test("runWithOptions callbacks: onSuccess called before global handler", async () => {
369 vi.useFakeTimers();
370 const { client, successMessages } = createTestMutationClient();
371 const calls: string[] = [];
372
373 const mutTest = client.define({
374 id: "test-8",
375 mutate: async () => {
376 await delay(100);
377 return "result";
378 },
379 describe: () => "test",
380 describeResult: () => {
381 calls.push("describeResult");
382 return "tested";
383 },
384 optimistic: () => {
385 calls.push("optimistic");
386 },
387 refetchOnSuccess: false,
388 });
389
390 mutTest.runWithOptions({
391 onSuccessUi: () => {
392 calls.push("onSuccess");
393 },
394 onSettled: () => {
395 calls.push("onSettled");
396 },
397 });
398
399 await vi.advanceTimersByTimeAsync(150);
400
401 // onSuccess suppresses global success handler (describeResult won't add to successMessages)
402 assertEquals(successMessages, [], "onSuccess should suppress global success handler");
403 assertEquals(calls.includes("onSuccess"), true);
404 assertEquals(calls.includes("onSettled"), true);
405
406 // Order should be: onSuccess, onSettled, describeResult (describeResult still called but not reported)
407 const onSuccessIndex = calls.indexOf("onSuccess");
408 const onSettledIndex = calls.indexOf("onSettled");
409 assertEquals(onSuccessIndex < onSettledIndex, true);
410});
411
412test("optimistic update with no describeResult: no success message", async () => {
413 vi.useFakeTimers();
414 const { client, successMessages } = createTestMutationClient();
415 const calls: string[] = [];
416
417 const mutTest = client.define({
418 id: "test-9",
419 mutate: async () => {
420 await delay(100);
421 return "result";
422 },
423 describe: () => "test",
424 describeResult: null,
425 optimistic: ({ onSuccess }) => {
426 onSuccess(() => calls.push("success"));
427 calls.push("optimistic");
428 },
429 refetchOnSuccess: false,
430 });
431
432 mutTest.run();
433 await vi.advanceTimersByTimeAsync(150);
434
435 assertEquals(successMessages, [], "No success message when describeResult is null");
436 assertEquals(calls.includes("success"), true, "onSuccess callback still called");
437});