Skip to content
File

Blob: tests/client/components/editor/lib/page-mention/resolver.test.ts

typescript451 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import type { ResolvePageMentionsResponse } from "@/shared/types";
3import type { PageMentionCacheMode } from "@/client/components/page-mention/types";
4import type { PageMentionResolverEnvironment } from "@/client/components/page-mention/resolver";
5 
6let createPageMentionResolver: typeof import("@/client/components/page-mention/resolver").createPageMentionResolver;
7 
8const resolveMock = vi.fn();
9 
10function createResponse(pageIds: string[], opts: { accessibleIds?: string[] } = {}): ResolvePageMentionsResponse {
11 const accessibleIds = new Set(opts.accessibleIds ?? pageIds);
12 return {
13 mentions: pageIds.map((pageId) =>
14 accessibleIds.has(pageId)
15 ? {
16 page_id: pageId,
17 accessible: true,
18 title: `Title ${pageId}`,
19 icon: null,
20 }
21 : {
22 page_id: pageId,
23 accessible: false,
24 title: null,
25 icon: null,
26 },
27 ),
28 };
29}
30 
31function createCachedLookup(entries: Record<string, { title: string; icon: string | null }>) {
32 return (pageId: string) => entries[pageId] ?? null;
33}
34 
35function createDeferred<T>() {
36 let resolve!: (value: T | PromiseLike<T>) => void;
37 let reject!: (reason?: unknown) => void;
38 const promise = new Promise<T>((res, rej) => {
39 resolve = res;
40 reject = rej;
41 });
42 return { promise, resolve, reject };
43}
44 
45function createResolver(
46 opts: Partial<{
47 workspaceId: string;
48 shareToken: string | undefined;
49 cacheMode: PageMentionCacheMode;
50 networkEnabled: boolean;
51 lookupCachedPage: (pageId: string) => { title: string; icon: string | null } | null;
52 }> = {},
53) {
54 let environment: PageMentionResolverEnvironment = {
55 cacheMode: opts.cacheMode ?? "live",
56 networkEnabled: opts.networkEnabled ?? true,
57 lookupCachedPage: opts.lookupCachedPage,
58 };
59 const resolver = createPageMentionResolver({
60 workspaceId: opts.workspaceId ?? "ws-1",
61 shareToken: opts.shareToken,
62 environment,
63 });
64 
65 return {
66 resolver,
67 setCacheMode(next: PageMentionCacheMode) {
68 environment = { ...environment, cacheMode: next };
69 resolver.setEnvironment(environment);
70 },
71 setNetworkEnabled(next: boolean) {
72 environment = { ...environment, networkEnabled: next };
73 resolver.setEnvironment(environment);
74 },
75 };
76}
77 
78async function flushAsyncWork() {
79 await Promise.resolve();
80 await Promise.resolve();
81 await Promise.resolve();
82 await Promise.resolve();
83}
84 
85beforeEach(async () => {
86 vi.useFakeTimers();
87 vi.resetModules();
88 resolveMock.mockReset();
89 vi.doMock("@/client/lib/api", () => ({
90 api: {
91 pageMentions: {
92 resolve: resolveMock,
93 },
94 },
95 }));
96 const mod = await import("@/client/components/page-mention/resolver");
97 createPageMentionResolver = mod.createPageMentionResolver;
98});
99 
100afterEach(() => {
101 vi.useRealTimers();
102 vi.restoreAllMocks();
103 vi.unstubAllGlobals();
104});
105 
106describe("page mention resolver", () => {
107 it("chunks more than 100 unique ids into multiple resolver calls", async () => {
108 resolveMock.mockImplementation(async (_workspaceId: string, pageIds: string[]) => createResponse(pageIds));
109 const { resolver } = createResolver();
110 
111 for (let i = 0; i < 101; i++) {
112 resolver.request(`p-${i}`);
113 }
114 
115 await flushAsyncWork();
116 
117 expect(resolveMock).toHaveBeenCalledTimes(2);
118 expect(resolveMock.mock.calls[0]?.[1]).toHaveLength(100);
119 expect(resolveMock.mock.calls[1]?.[1]).toHaveLength(1);
120 expect(resolver.get("p-0")).toMatchObject({
121 status: "resolved",
122 source: "server",
123 accessible: true,
124 title: "Title p-0",
125 });
126 expect(resolver.get("p-100")).toMatchObject({
127 status: "resolved",
128 source: "server",
129 accessible: true,
130 title: "Title p-100",
131 });
132 });
133 
134 it("dedupes duplicate ids before resolving", async () => {
135 resolveMock.mockImplementation(async (_workspaceId: string, pageIds: string[]) => createResponse(pageIds));
136 const { resolver } = createResolver();
137 
138 resolver.request("p-1");
139 resolver.request("p-1");
140 resolver.request("p-1");
141 
142 await flushAsyncWork();
143 
144 expect(resolveMock).toHaveBeenCalledTimes(1);
145 expect(resolveMock.mock.calls[0]?.[1]).toEqual(["p-1"]);
146 expect(resolver.get("p-1")).toMatchObject({ status: "resolved", source: "server", accessible: true });
147 });
148 
149 it("ignores cached labels on live canonical routes", async () => {
150 const deferred = createDeferred<ResolvePageMentionsResponse>();
151 resolveMock.mockReturnValueOnce(deferred.promise);
152 const { resolver } = createResolver({
153 cacheMode: "live",
154 lookupCachedPage: createCachedLookup({
155 "p-1": { title: "Cached title", icon: "C" },
156 }),
157 });
158 
159 resolver.request("p-1");
160 
161 expect(resolver.get("p-1")).toMatchObject({
162 status: "pending",
163 source: null,
164 accessible: false,
165 title: null,
166 });
167 
168 deferred.resolve(createResponse(["p-1"]));
169 await flushAsyncWork();
170 
171 expect(resolver.get("p-1")).toMatchObject({
172 status: "resolved",
173 source: "server",
174 accessible: true,
175 title: "Title p-1",
176 });
177 });
178 
179 it("uses cached labels on cache-backed canonical routes while the request is in flight", async () => {
180 const deferred = createDeferred<ResolvePageMentionsResponse>();
181 resolveMock.mockReturnValueOnce(deferred.promise);
182 const { resolver } = createResolver({
183 cacheMode: "cache",
184 lookupCachedPage: createCachedLookup({
185 "p-1": { title: "Cached title", icon: "C" },
186 }),
187 });
188 
189 resolver.request("p-1");
190 
191 expect(resolver.get("p-1")).toMatchObject({
192 status: "resolved",
193 source: "cache",
194 accessible: true,
195 title: "Cached title",
196 icon: "C",
197 });
198 
199 deferred.resolve(createResponse(["p-1"]));
200 await flushAsyncWork();
201 
202 expect(resolver.get("p-1")).toMatchObject({
203 status: "resolved",
204 source: "server",
205 accessible: true,
206 title: "Title p-1",
207 });
208 });
209 
210 it("does not use cached labels when callers keep cache mode live", async () => {
211 const deferred = createDeferred<ResolvePageMentionsResponse>();
212 resolveMock.mockReturnValueOnce(deferred.promise);
213 const { resolver } = createResolver({
214 shareToken: "share-token",
215 cacheMode: "live",
216 lookupCachedPage: createCachedLookup({
217 "p-1": { title: "Cached title", icon: "C" },
218 }),
219 });
220 
221 resolver.request("p-1");
222 
223 expect(resolver.get("p-1")).toMatchObject({
224 status: "pending",
225 source: null,
226 accessible: false,
227 title: null,
228 });
229 
230 deferred.resolve(createResponse(["p-1"]));
231 await flushAsyncWork();
232 
233 expect(resolver.get("p-1")).toMatchObject({ status: "resolved", source: "server", accessible: true });
234 });
235 
236 it("keeps cached labels visible when transport requests fail in cache mode", async () => {
237 resolveMock.mockRejectedValueOnce(new Error("offline"));
238 const { resolver } = createResolver({
239 cacheMode: "cache",
240 lookupCachedPage: createCachedLookup({
241 "p-1": { title: "Cached title", icon: "C" },
242 }),
243 });
244 
245 resolver.request("p-1");
246 await flushAsyncWork();
247 
248 expect(resolver.get("p-1")).toMatchObject({
249 status: "resolved",
250 source: "cache",
251 accessible: true,
252 title: "Cached title",
253 icon: "C",
254 });
255 expect(resolveMock).toHaveBeenCalledTimes(1);
256 });
257 
258 it("stays cache-backed without firing resolve while network is disabled", async () => {
259 const { resolver, setNetworkEnabled } = createResolver({
260 cacheMode: "cache",
261 networkEnabled: false,
262 lookupCachedPage: createCachedLookup({
263 "p-1": { title: "Cached title", icon: "C" },
264 }),
265 });
266 
267 resolver.request("p-1");
268 await flushAsyncWork();
269 
270 expect(resolveMock).not.toHaveBeenCalled();
271 expect(resolver.get("p-1")).toMatchObject({
272 status: "resolved",
273 source: "cache",
274 accessible: true,
275 title: "Cached title",
276 icon: "C",
277 });
278 
279 setNetworkEnabled(true);
280 await flushAsyncWork();
281 
282 expect(resolveMock).toHaveBeenCalledTimes(1);
283 });
284 
285 it("keeps uncached ids pending until the resolver returns metadata", async () => {
286 const deferred = createDeferred<ResolvePageMentionsResponse>();
287 resolveMock.mockReturnValueOnce(deferred.promise);
288 const { resolver } = createResolver({
289 cacheMode: "cache",
290 lookupCachedPage: createCachedLookup({}),
291 });
292 
293 resolver.request("p-2");
294 
295 expect(resolver.get("p-2")).toMatchObject({
296 status: "pending",
297 source: null,
298 accessible: false,
299 title: null,
300 icon: null,
301 });
302 
303 deferred.resolve(createResponse(["p-2"]));
304 await flushAsyncWork();
305 
306 expect(resolver.get("p-2")).toMatchObject({
307 status: "resolved",
308 source: "server",
309 accessible: true,
310 title: "Title p-2",
311 });
312 });
313 
314 it("resolves failed batches as restricted and re-fetches on explicit request()", async () => {
315 // Retry machinery has been removed: a batch that fails resolves its
316 // entries as `resolved / source: server / accessible: false`. The entry
317 // is re-queued only when a fresh `request()` fires (mention re-render,
318 // scope change, or user navigation), not via an internal timer.
319 resolveMock
320 .mockImplementationOnce(async (_workspaceId: string, pageIds: string[]) => createResponse(pageIds))
321 .mockRejectedValueOnce(new Error("offline"))
322 .mockImplementationOnce(async (_workspaceId: string, pageIds: string[]) => createResponse(pageIds));
323 
324 const { resolver } = createResolver();
325 for (let i = 0; i < 101; i++) {
326 resolver.request(`p-${i}`);
327 }
328 
329 await flushAsyncWork();
330 
331 expect(resolveMock).toHaveBeenCalledTimes(2);
332 expect(resolver.get("p-0")).toMatchObject({ status: "resolved", source: "server", accessible: true });
333 expect(resolver.get("p-100")).toMatchObject({
334 status: "resolved",
335 source: "server",
336 accessible: false,
337 title: null,
338 });
339 
340 // Waiting should NOT trigger any additional network calls.
341 await vi.advanceTimersByTimeAsync(10_000);
342 await flushAsyncWork();
343 expect(resolveMock).toHaveBeenCalledTimes(2);
344 
345 // A fresh request re-queues the entry; next flush succeeds.
346 resolver.request("p-100");
347 await flushAsyncWork();
348 expect(resolveMock).toHaveBeenCalledTimes(3);
349 expect(resolveMock.mock.calls[2]?.[1]).toEqual(["p-100"]);
350 expect(resolver.get("p-100")).toMatchObject({ status: "resolved", source: "server", accessible: true });
351 });
352 
353 it("overrides cached labels when the server resolves a mention as restricted", async () => {
354 resolveMock.mockImplementationOnce(async (_workspaceId: string, pageIds: string[]) =>
355 createResponse(pageIds, { accessibleIds: [] }),
356 );
357 
358 const { resolver } = createResolver({
359 cacheMode: "cache",
360 lookupCachedPage: createCachedLookup({
361 "p-1": { title: "Cached title", icon: "C" },
362 }),
363 });
364 
365 resolver.request("p-1");
366 await flushAsyncWork();
367 
368 expect(resolver.get("p-1")).toMatchObject({
369 status: "resolved",
370 source: "server",
371 accessible: false,
372 title: null,
373 icon: null,
374 });
375 });
376 
377 it("does not retry route-level failures and resolves mentions as restricted", async () => {
378 resolveMock.mockRejectedValueOnce({ error: "validation_error", message: "Invalid request body" });
379 const { resolver } = createResolver();
380 
381 resolver.request("p-1");
382 await flushAsyncWork();
383 
384 await vi.advanceTimersByTimeAsync(10_000);
385 await flushAsyncWork();
386 
387 expect(resolveMock).toHaveBeenCalledTimes(1);
388 expect(resolver.get("p-1")).toMatchObject({
389 status: "resolved",
390 source: "server",
391 accessible: false,
392 title: null,
393 icon: null,
394 });
395 });
396 
397 it("revalidates cache-backed entries when route policy flips back to live", async () => {
398 resolveMock
399 .mockRejectedValueOnce(new Error("offline"))
400 .mockImplementationOnce(async (_workspaceId: string, pageIds: string[]) => createResponse(pageIds));
401 
402 const { resolver, setCacheMode } = createResolver({
403 cacheMode: "cache",
404 lookupCachedPage: createCachedLookup({
405 "p-1": { title: "Cached title", icon: "C" },
406 }),
407 });
408 
409 resolver.request("p-1");
410 await flushAsyncWork();
411 
412 expect(resolver.get("p-1")).toMatchObject({
413 status: "resolved",
414 source: "cache",
415 title: "Cached title",
416 });
417 
418 setCacheMode("live");
419 await flushAsyncWork();
420 
421 expect(resolveMock).toHaveBeenCalledTimes(2);
422 expect(resolver.get("p-1")).toMatchObject({
423 status: "resolved",
424 source: "server",
425 accessible: true,
426 title: "Title p-1",
427 });
428 });
429 
430 it("dispose cancels in-flight work without notifying listeners", async () => {
431 const deferred = createDeferred<ResolvePageMentionsResponse>();
432 resolveMock.mockReturnValueOnce(deferred.promise);
433 const { resolver } = createResolver();
434 const listener = vi.fn();
435 
436 resolver.request("p-1");
437 const unsubscribe = resolver.subscribe("p-1", listener);
438 await flushAsyncWork();
439 expect(resolveMock).toHaveBeenCalledTimes(1);
440 
441 resolver.dispose();
442 deferred.resolve(createResponse(["p-1"]));
443 await flushAsyncWork();
444 
445 expect(listener).not.toHaveBeenCalled();
446 expect(resolver.get("p-1")).toMatchObject({ status: "pending", source: null, accessible: false, title: null });
447 
448 unsubscribe();
449 });
450});