Files
cloudflare__vinext/tests/fetch-cache.test.ts
T
Nathan Nguyen 1d01d2989a feat(cache): record app render observations (#1198)
* feat(cache): record app render observations

App Router cache artifacts and payloads did not carry the render-observation metadata required by #726-CACHE-05/07. That left later cache-proof work without an attached record of request APIs, dynamic fetches, output scope, cache tags, and boundary outcome.

The missing boundary was that render-scoped observations lived only as dynamic/cache state, not as metadata owned by each produced payload or artifact.

Record render request API usage and dynamic fetch observations in request-scoped state, build redacted RenderObservation payloads for AppElements and APP_PAGE cache writes, and preserve complete observations for HTML/RSC ISR artifacts including stale regeneration.

Targeted coverage asserts payload metadata, artifact metadata, redaction, and existing cache-proof behavior.

* test(cache): cover search param render observation usage

The CI unit shard failed because app-page-element-builder tests fully mocked the headers shim and omitted the new markRenderRequestApiUsage export. The production path now records searchParams as a render observation whenever populated search params make the page dynamic.

Update the mock to expose the new shim export and assert that populated search params mark both dynamic usage and render-observation usage, while empty search params mark neither.

* refactor(cache): tighten render observation review handling

Render observation metadata now reuses the app page observation state shape instead of duplicating cache-local types. The deferred cache writes also document the stream-consumption boundary that preserves late request API observations.

Dynamic fetch observations are now stored in a Set so repeated no-store fetches do not inflate metadata, and the collector has focused regression coverage. The Link transition test also patches and restores the runtime React default so the full unit project is not order-sensitive when other files instantiate the module graph first.

* fix(cache): preserve parent render observations
2026-05-15 11:51:50 +01:00

2416 lines
85 KiB
TypeScript

/**
* Unit tests for fetch cache shim.
*
* Tests the patched fetch() with Next.js caching semantics:
* - next.revalidate for TTL-based caching
* - next.tags for tag-based invalidation
* - cache: 'no-store' and cache: 'force-cache'
* - Stale-while-revalidate behavior
* - next property stripping
* - Independent cache entries per URL
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
// We need to mock fetch at the module level BEFORE fetch-cache.ts captures
// `originalFetch`. Use vi.stubGlobal to intercept at import time.
let requestCount = 0;
const defaultFetchMockImplementation = async (
input: string | URL | Request,
_init?: RequestInit,
) => {
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify({ url, count: requestCount }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const fetchMock = vi.fn(defaultFetchMockImplementation);
// Stub globalThis.fetch BEFORE importing modules that capture it
vi.stubGlobal("fetch", fetchMock);
// Now import — these will capture fetchMock as "originalFetch"
const {
withFetchCache,
runWithFetchCache,
getCollectedFetchTags,
setCurrentFetchCacheMode,
setCurrentFetchSoftTags,
getOriginalFetch,
_resetPendingRefetches,
consumeDynamicFetchObservations,
peekDynamicFetchObservations,
} = await import("../packages/vinext/src/shims/fetch-cache.js");
const { getCacheHandler, revalidatePath, revalidateTag, MemoryCacheHandler, setCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { runWithExecutionContext } = await import("../packages/vinext/src/shims/request-context.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
describe("fetch cache shim", () => {
let cleanup: (() => void) | null = null;
function startNewFetchCacheScope(): void {
cleanup?.();
cleanup = withFetchCache();
}
beforeEach(() => {
// Reset state
requestCount = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(defaultFetchMockImplementation);
// Reset the cache handler to a fresh instance for each test
setCacheHandler(new MemoryCacheHandler());
// Clear in-flight refetch dedup state
_resetPendingRefetches();
// Install the patched fetch
cleanup = withFetchCache();
});
afterEach(() => {
cleanup?.();
cleanup = null;
});
// ── Basic caching with next.revalidate ──────────────────────────────
it("caches fetch with next.revalidate and returns cached on second call", async () => {
const res1 = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Second call should return cached data (no new network request)
const res2 = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same count = cached
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one real fetch
});
it("cache: 'force-cache' caches indefinitely", async () => {
const res1 = await fetch("https://api.example.com/force", {
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/force", {
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches fetches without per-fetch options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-default-cache");
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-cache");
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches Request inputs without per-fetch options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch(new Request("https://api.example.com/segment-request-default-cache"));
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(new Request("https://api.example.com/segment-request-default-cache"));
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches fetches with metadata-only next options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-default-cache-tags", {
next: { tags: ["segment-default-cache-tags"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-cache-tags", {
next: { tags: ["segment-default-cache-tags"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache does not override explicit no-store", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-explicit-no-store", {
cache: "no-store",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-explicit-no-store", {
cache: "no-store",
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-no-store bypasses cache with metadata-only next options", async () => {
setCurrentFetchCacheMode("default-no-store");
const res1 = await fetch("https://api.example.com/segment-default-no-store-tags", {
next: { tags: ["segment-default-no-store-tags"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-no-store-tags", {
next: { tags: ["segment-default-no-store-tags"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache force-no-store overrides explicit force-cache", async () => {
setCurrentFetchCacheMode("force-no-store");
const res1 = await fetch("https://api.example.com/segment-force-no-store", {
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-force-no-store", {
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache force-no-store forwards no-store to the real fetch", async () => {
setCurrentFetchCacheMode("force-no-store");
await fetch("https://api.example.com/segment-force-no-store-init", {
cache: "force-cache",
});
expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/segment-force-no-store-init", {
cache: "no-store",
});
});
it("segment fetchCache only-cache rejects no-store fetches", async () => {
setCurrentFetchCacheMode("only-cache");
await expect(
fetch("https://api.example.com/segment-only-cache", {
cache: "no-store",
}),
).rejects.toThrow(/only-cache/);
});
it("segment fetchCache only-cache rejects no-store Request inputs", async () => {
setCurrentFetchCacheMode("only-cache");
await expect(
fetch(
new Request("https://api.example.com/segment-only-cache-request", {
cache: "no-store",
}),
),
).rejects.toThrow(/only-cache/);
});
it("segment fetchCache only-no-store rejects cacheable fetches", async () => {
setCurrentFetchCacheMode("only-no-store");
await expect(
fetch("https://api.example.com/segment-only-no-store", {
cache: "force-cache",
}),
).rejects.toThrow(/only-no-store/);
});
it("segment fetchCache only-no-store rejects cacheable Request inputs", async () => {
setCurrentFetchCacheMode("only-no-store");
await expect(
fetch(
new Request("https://api.example.com/segment-only-no-store-request", {
cache: "force-cache",
}),
),
).rejects.toThrow(/only-no-store/);
});
// ── No caching (no-store, revalidate: 0, revalidate: false) ─────────
// Ported from Next.js: test coverage for packages/next/src/server/lib/dedupe-fetch.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/dedupe-fetch.test.ts
it("cache: 'no-store' bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/nostore", {
cache: "no-store",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nostore", {
cache: "no-store",
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("next.revalidate: 0 bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/rev0", {
next: { revalidate: 0 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/rev0", {
next: { revalidate: 0 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("dedupes dynamic fetch observations within a fetch cache scope", async () => {
await fetch("https://api.example.com/dynamic?token=secret", {
cache: "no-store",
});
await fetch("https://api.example.com/dynamic?token=secret", {
cache: "no-store",
});
await fetch(new URL("https://api.example.com/other-dynamic"), {
cache: "no-store",
});
const expected = [
"https://api.example.com/dynamic?token=secret",
"https://api.example.com/other-dynamic",
];
expect(peekDynamicFetchObservations()).toEqual(expected);
expect(consumeDynamicFetchObservations()).toEqual(expected);
expect(peekDynamicFetchObservations()).toEqual([]);
});
it("next.revalidate: false bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/revfalse", {
next: { revalidate: false },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/revfalse", {
next: { revalidate: false },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("no cache or next options bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/passthrough");
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/passthrough");
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("no cache or next options do not dedupe across request scopes", async () => {
cleanup?.();
cleanup = null;
const data1 = await runWithFetchCache(async () => {
const res = await fetch("https://api.example.com/request-scoped-dedupe");
return await res.json();
});
const data2 = await runWithFetchCache(async () => {
const res = await fetch("https://api.example.com/request-scoped-dedupe");
return await res.json();
});
expect(data1.count).toBe(1);
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
cleanup = withFetchCache();
});
it("does not dedupe ordinary fetches outside a fetch-cache scope", async () => {
cleanup?.();
cleanup = null;
await fetch("https://api.example.com/outside-scope");
await fetch("https://api.example.com/outside-scope");
expect(fetchMock).toHaveBeenCalledTimes(2);
cleanup = withFetchCache();
});
it("dedupes identical uncached responses with independent response bodies", async () => {
const [res1, res2] = await Promise.all([
fetch("https://api.example.com/body-dedupe"),
fetch("https://api.example.com/body-dedupe"),
]);
expect(await res1.json()).toEqual({
url: "https://api.example.com/body-dedupe",
count: 1,
});
expect(await res2.json()).toEqual({
url: "https://api.example.com/body-dedupe",
count: 1,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not dedupe uncached fetches with abort signals", async () => {
const controller = new AbortController();
await fetch("https://api.example.com/signal-dedupe", { signal: controller.signal });
await fetch("https://api.example.com/signal-dedupe", { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not dedupe uncached fetches with side-effecting methods", async () => {
await fetch("https://api.example.com/post-dedupe", { method: "POST", body: "one" });
await fetch("https://api.example.com/post-dedupe", { method: "POST", body: "one" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("dedupes uncached fetches across trace header differences only", async () => {
const [res1, res2] = await Promise.all([
fetch("https://api.example.com/trace-dedupe", {
headers: {
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=a",
},
}),
fetch("https://api.example.com/trace-dedupe", {
headers: {
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cccccccccccccccc-01",
tracestate: "vendor=b",
},
}),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
await fetch("https://api.example.com/trace-dedupe", {
headers: { "x-custom": "different" },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("dedupes identical Request object inputs as the dedupe key source", async () => {
const [res1, res2] = await Promise.all([
fetch(new Request("https://api.example.com/req-input-dedupe")),
fetch(new Request("https://api.example.com/req-input-dedupe")),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("removes failed dedupe entries so a later fetch in the same scope can retry", async () => {
fetchMock.mockReset();
fetchMock
.mockImplementationOnce(async () => {
throw new Error("network down");
})
.mockImplementation(defaultFetchMockImplementation);
await expect(fetch("https://api.example.com/retry-after-failure")).rejects.toThrow(
"network down",
);
const res = await fetch("https://api.example.com/retry-after-failure");
expect((await res.json()).url).toBe("https://api.example.com/retry-after-failure");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not dedupe Request inputs that differ in non-trace headers", async () => {
const [res1, res2] = await Promise.all([
fetch(
new Request("https://api.example.com/req-input-headers", {
headers: { "x-variant": "a" },
}),
),
fetch(
new Request("https://api.example.com/req-input-headers", {
headers: { "x-variant": "b" },
}),
),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Tag-based invalidation ──────────────────────────────────────────
it("next.tags caches and revalidateTag invalidates", async () => {
const res1 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Cached
const res2 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Invalidate via tag
await revalidateTag("posts");
startNewFetchCacheScope();
// Should re-fetch after tag invalidation
const res3 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data3 = await res3.json();
expect(data3.count).toBe(2); // Fresh fetch
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("revalidateTag only invalidates matching tags", async () => {
// Cache two different tagged fetches
await fetch("https://api.example.com/posts-tag", {
next: { tags: ["posts"] },
});
await fetch("https://api.example.com/users-tag", {
next: { tags: ["users"] },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
// Invalidate only "posts"
await revalidateTag("posts");
startNewFetchCacheScope();
// Posts should re-fetch
const postRes = await fetch("https://api.example.com/posts-tag", {
next: { tags: ["posts"] },
});
const postData = await postRes.json();
expect(postData.count).toBe(3); // Fresh fetch (count continues from 2)
// Users should still be cached
const userRes = await fetch("https://api.example.com/users-tag", {
next: { tags: ["users"] },
});
const userData = await userRes.json();
expect(userData.count).toBe(2); // Still the cached version
expect(fetchMock).toHaveBeenCalledTimes(3); // Only posts re-fetched
});
// ── TTL expiry (stale-while-revalidate) ─────────────────────────────
it("returns stale data after TTL expires and triggers background refetch", async () => {
const res1 = await fetch("https://api.example.com/stale-test", {
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Manually expire the cache entry (key is a SHA-256 hash, find it dynamically)
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000; // Expired 1 second ago
}
startNewFetchCacheScope();
// Should return stale data immediately
const res2 = await fetch("https://api.example.com/stale-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data (same as first fetch)
// Wait for background refetch
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2); // Original + background refetch
});
it("preserves Request bodies for stale background revalidation", async () => {
const seenBodies: string[] = [];
fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => {
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const body = input instanceof Request ? await input.clone().text() : "";
seenBodies.push(body);
return new Response(JSON.stringify({ url, count: requestCount, body }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const makeRequest = () =>
new Request("https://api.example.com/stale-request-body", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(makeRequest(), { next: { revalidate: 1 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
expect(data1.body).toBe("request-body-content");
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
const res2 = await fetch(makeRequest(), { next: { revalidate: 1 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(data2.body).toBe("request-body-content");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(seenBodies).toEqual(["request-body-content", "request-body-content"]);
});
it("registers stale background refetch with waitUntil when ExecutionContext is available", async () => {
const waitUntilSpy = vi.fn<(p: Promise<unknown>) => void>();
const mockCtx = { waitUntil: waitUntilSpy };
await runWithExecutionContext(mockCtx, async () => {
// Populate cache
const res1 = await fetch("https://api.example.com/waituntil-test", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
// Manually expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Trigger stale hit — should fire background refetch via waitUntil
const res2 = await fetch("https://api.example.com/waituntil-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data returned
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
expect(waitUntilSpy.mock.calls[0]![0]).toBeInstanceOf(Promise);
// Wait for the refetch to complete
await waitUntilSpy.mock.calls[0]![0];
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
it("registers stale background refetch with waitUntil inside a unified request scope", async () => {
const waitUntilSpy = vi.fn<(p: Promise<unknown>) => void>();
const mockCtx = { waitUntil: waitUntilSpy };
await runWithExecutionContext(mockCtx, async () => {
await runWithRequestContext(createRequestContext(), async () => {
const res1 = await fetch("https://api.example.com/unified-waituntil-test", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/unified-waituntil-test", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(1);
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
expect(waitUntilSpy.mock.calls[0]![0]).toBeInstanceOf(Promise);
await waitUntilSpy.mock.calls[0]![0];
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
});
it("deduplicates concurrent stale background refetches for the same cache key", async () => {
// Use a deferred promise to control when the background refetch resolves,
// ensuring all concurrent stale hits see stale data before the refetch completes.
let resolveRefetch!: () => void;
const refetchGate = new Promise<void>((r) => {
resolveRefetch = r;
});
// Populate cache (first call resolves normally)
const res1 = await fetch("https://api.example.com/dedup-stale", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Subsequent calls wait on the gate before resolving
fetchMock.mockImplementation(async (input: string | URL | Request) => {
await refetchGate;
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify({ url, count: requestCount }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Fire 5 concurrent stale hits — should all return stale data
// but only trigger ONE background refetch
const results = await Promise.all(
Array.from({ length: 5 }, () =>
fetch("https://api.example.com/dedup-stale", {
next: { revalidate: 1 },
}),
),
);
// All 5 should return the stale data
for (const res of results) {
const data = await res.json();
expect(data.count).toBe(1);
}
// Let the background refetch complete
resolveRefetch();
await new Promise((resolve) => setTimeout(resolve, 50));
// Original fetch (1) + exactly one background refetch (1) = 2 total
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("allows a new background refetch after the previous one completes", async () => {
// Populate cache
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// First stale hit — triggers background refetch
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
// Expire again
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Second stale hit — should trigger a NEW background refetch
// (the previous one completed and cleaned up)
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("cleans up dedup entry when background refetch fails, allowing retry", async () => {
// Populate cache
await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make subsequent fetches reject
fetchMock.mockImplementation(async () => {
throw new Error("network down");
});
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Stale hit — background refetch will fail
const res = await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
expect((await res.json()).count).toBe(1); // Still returns stale data
await new Promise((resolve) => setTimeout(resolve, 50));
// The failed refetch should have been called and cleaned up
expect(fetchMock).toHaveBeenCalledTimes(2);
// Restore working fetch and expire again
fetchMock.mockImplementation(defaultFetchMockImplementation);
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// A new stale hit should trigger a fresh refetch (dedup entry was cleaned up)
await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("background revalidation does not cache error responses", async () => {
// Populate cache with a good response
const res1 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
expect(res1.status).toBe(200);
// Manually expire the cache entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
// Make the upstream return a 500 error for the background refetch
fetchMock.mockImplementationOnce(
async () =>
new Response("Internal Server Error", {
status: 500,
headers: { "content-type": "text/plain" },
}),
);
// Should return stale data immediately (stale-while-revalidate)
const res2 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data returned
expect(res2.status).toBe(200);
// Wait for background refetch to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// The background refetch got a 500, so the cache should still hold the
// original good response — not the error.
// Expire the entry again to force another stale read from cache.
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
// Restore good fetch for next background refetch
fetchMock.mockImplementation(defaultFetchMockImplementation);
const res3 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
// If the bug exists, this will be 500 (the error was cached).
// If fixed, this will be 200 (the original good data was preserved).
expect(res3.status).toBe(200);
});
it("force-cleans dedup entry after timeout when upstream fetch hangs", async () => {
vi.useFakeTimers();
try {
// Populate cache
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make subsequent fetches hang forever
fetchMock.mockImplementation(() => new Promise(() => {}));
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Stale hit — background refetch hangs
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Hung fetch was called
// Another stale hit before timeout — dedup suppresses it
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Still suppressed
// Advance past the 60s timeout — dedup entry should be force-cleaned
vi.advanceTimersByTime(60_000);
// Restore working fetch and expire again
fetchMock.mockImplementation(defaultFetchMockImplementation);
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// New stale hit should trigger a fresh refetch
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
// Flush the microtask for the background refetch
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(3); // New refetch succeeded
} finally {
vi.useRealTimers();
}
});
it("hung fetch settling after timeout does not evict replacement refetch", async () => {
vi.useFakeTimers();
try {
let resolveHungFetch!: (resp: Response) => void;
// Populate cache
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make next fetch hang until we resolve it manually
fetchMock.mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveHungFetch = resolve;
}),
);
// Expire and trigger a stale hit — background refetch #1 hangs
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Hung fetch was called
// Advance past the 60s timeout — dedup entry is force-cleaned
vi.advanceTimersByTime(60_000);
// Restore working fetch for the replacement refetch
fetchMock.mockImplementation(defaultFetchMockImplementation);
// Expire and trigger a new stale hit — background refetch #2 starts
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
// Let refetch #2 complete
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(3); // Replacement refetch ran
// Now the hung refetch #1 finally settles — it must NOT evict #2's slot
resolveHungFetch(
new Response(JSON.stringify({ url: "stale", count: 999 }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await vi.advanceTimersByTimeAsync(50);
// Expire again — a new stale hit should NOT start another refetch
// because #2's slot should still be gone (it completed normally).
// The key behavior: #1 settling did not delete #2's entry while #2 was live.
// Since #2 already completed and cleaned up its own slot, a new refetch
// should start normally (proving #1 didn't corrupt state).
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(4); // Clean new refetch
} finally {
vi.useRealTimers();
}
});
// ── Independent cache entries per URL ───────────────────────────────
it("different URLs get independent cache entries", async () => {
const res1 = await fetch("https://api.example.com/url-a", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.url).toBe("https://api.example.com/url-a");
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/url-b", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.url).toBe("https://api.example.com/url-b");
expect(data2.count).toBe(2); // Different URL = different cache
// Re-fetch url-a should be cached
const res3 = await fetch("https://api.example.com/url-a", {
next: { revalidate: 60 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same URL with different methods get separate cache entries", async () => {
const getRes = await fetch("https://api.example.com/method-test", {
method: "GET",
next: { revalidate: 60 },
});
const getData = await getRes.json();
expect(getData.count).toBe(1);
const postRes = await fetch("https://api.example.com/method-test", {
method: "POST",
body: "test",
next: { revalidate: 60 },
});
const postData = await postRes.json();
expect(postData.count).toBe(2); // Different method = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── next property stripping ─────────────────────────────────────────
it("strips next property before passing to real fetch", async () => {
await fetch("https://api.example.com/strip-test", {
next: { revalidate: 60, tags: ["test"] },
headers: { "X-Custom": "value" },
});
// Verify the mock was called with init that does NOT have `next`
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init).toBeDefined();
expect((init as any).next).toBeUndefined();
expect((init as any).headers).toEqual({ "X-Custom": "value" });
});
it("strips next property for no-store fetches too", async () => {
await fetch("https://api.example.com/strip-nostore", {
cache: "no-store",
next: { tags: ["test"] },
});
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect((init as any).next).toBeUndefined();
});
// ── Tag collection during rendering ─────────────────────────────────
it("collects tags during render pass via getCollectedFetchTags", async () => {
await fetch("https://api.example.com/tag-collect-a", {
next: { tags: ["posts", "list"] },
});
await fetch("https://api.example.com/tag-collect-b", {
next: { tags: ["users"] },
});
const tags = getCollectedFetchTags();
expect(tags).toContain("posts");
expect(tags).toContain("list");
expect(tags).toContain("users");
expect(tags).toHaveLength(3);
});
it("does not collect duplicate tags", async () => {
await fetch("https://api.example.com/dup-tag-a", {
next: { tags: ["data"] },
});
await fetch("https://api.example.com/dup-tag-b", {
next: { tags: ["data"] },
});
const tags = getCollectedFetchTags();
expect(tags.filter((t) => t === "data")).toHaveLength(1);
});
it("revalidatePath invalidates fetch cache through current render soft tags", async () => {
setCurrentFetchSoftTags(["_N_T_/posts/hello"]);
const res1 = await fetch("https://api.example.com/path-soft-tag", {
next: { revalidate: 3600 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
await revalidatePath("/posts/hello");
startNewFetchCacheScope();
setCurrentFetchSoftTags(["_N_T_/posts/hello"]);
const res2 = await fetch("https://api.example.com/path-soft-tag", {
next: { revalidate: 3600 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Only caches successful responses ────────────────────────────────
it("does not cache non-2xx responses", async () => {
// Override mock to return 404 once
fetchMock.mockImplementationOnce(async () => {
requestCount++;
return new Response("Not found", { status: 404 });
});
const res1 = await fetch("https://api.example.com/missing-page", {
next: { revalidate: 60 },
});
expect(res1.status).toBe(404);
// Should re-fetch since 404 wasn't cached
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/missing-page", {
next: { revalidate: 60 },
});
expect(res2.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── URL and Request object inputs ───────────────────────────────────
it("handles URL objects as input", async () => {
const url = new URL("https://api.example.com/url-obj");
const res = await fetch(url, { next: { revalidate: 60 } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// Cached on second call
const res2 = await fetch(url, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("handles Request objects as input", async () => {
const req = new Request("https://api.example.com/req-obj");
const res = await fetch(req, { next: { revalidate: 60 } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// Cached on second call with same URL
const req2 = new Request("https://api.example.com/req-obj");
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("includes Request object bodies in the cache key", async () => {
const req1 = new Request("https://api.example.com/req-body", {
method: "POST",
body: "alpha",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-body", {
method: "POST",
body: "bravo",
headers: { "content-type": "text/plain" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Request body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Request object bodies hit the same cache entry", async () => {
const req1 = new Request("https://api.example.com/req-body-same", {
method: "POST",
body: "same-body",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-body-same", {
method: "POST",
body: "same-body",
headers: { "content-type": "text/plain" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same Request body = cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Request FormData values with commas do not collide in the cache key", async () => {
const formA = new FormData();
formA.append("name", "a,b");
formA.append("name", "c");
const req1 = new Request("https://api.example.com/req-form-body", {
method: "POST",
body: formA,
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const formB = new FormData();
formB.append("name", "a");
formB.append("name", "b,c");
const req2 = new Request("https://api.example.com/req-form-body", {
method: "POST",
body: formB,
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Request FormData body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Request FormData bodies hit the same cache entry despite generated multipart boundaries", async () => {
const makeForm = () => {
const form = new FormData();
form.append("name", "same-value");
return form;
};
const req1 = new Request("https://api.example.com/req-form-same", {
method: "POST",
body: makeForm(),
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-form-same", {
method: "POST",
body: makeForm(),
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("same multipart Request bodies hit the same cache entry even with different boundaries", async () => {
const makeMultipartRequest = (boundary: string) =>
new Request("https://api.example.com/req-form-boundary", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: [
`--${boundary}`,
'Content-Disposition: form-data; name="name"',
"",
"same-value",
`--${boundary}--`,
"",
].join("\r\n"),
});
const res1 = await fetch(makeMultipartRequest("boundary-a"), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeMultipartRequest("boundary-b"), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("malformed multipart Request bodies bypass cache instead of hashing raw bytes", async () => {
const makeMalformedMultipartRequest = () =>
new Request("https://api.example.com/req-form-malformed", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=expected" },
body: [
"--actual",
'Content-Disposition: form-data; name="name"',
"",
"value",
"--actual--",
"",
].join("\r\n"),
});
const res1 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("urlencoded Request bodies with different charset headers get separate cache entries", async () => {
const makeRequest = (charset: string) =>
new Request("https://api.example.com/req-form-charset", {
method: "POST",
headers: { "content-type": `application/x-www-form-urlencoded; charset=${charset}` },
body: "name=value",
});
const res1 = await fetch(makeRequest("utf-8"), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeRequest("shift_jis"), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── force-cache with next.revalidate ────────────────────────────────
it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => {
const res1 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Verify it's cached
const res2 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
// Expire the cache manually (key is a SHA-256 hash, find it dynamically)
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Should return stale
const res3 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Stale data returned
// Background refetch
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Cleanup clears per-request state ─────────────────────────────────
it("cleanup function clears collected tags", async () => {
// Collect some tags
await fetch("https://api.example.com/cleanup-test", {
next: { tags: ["cleanup-tag"] },
});
expect(getCollectedFetchTags()).toContain("cleanup-tag");
// Cleanup should reset tag state
cleanup!();
cleanup = null;
expect(getCollectedFetchTags()).toHaveLength(0);
// Re-install for afterEach cleanup
cleanup = withFetchCache();
});
// ── getOriginalFetch ────────────────────────────────────────────────
it("getOriginalFetch returns the module-level original fetch", () => {
const orig = getOriginalFetch();
expect(typeof orig).toBe("function");
// It should be fetchMock since that was the global fetch when the module loaded
expect(orig).toBe(fetchMock);
});
// ── next: {} empty passes through ───────────────────────────────────
it("next: {} with no revalidate or tags bypasses persistent cache but dedupes render fetches", async () => {
const res1 = await fetch("https://api.example.com/empty-next", { next: {} });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/empty-next", { next: {} });
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
// ── Concurrent request isolation via ALS ─────────────────────────────
it("concurrent runWithFetchCache calls have isolated tags", async () => {
// Clean up the withFetchCache() from beforeEach — runWithFetchCache
// manages its own ALS scope.
cleanup?.();
cleanup = null;
const [tags1, tags2] = await Promise.all([
runWithFetchCache(async () => {
await fetch("https://api.example.com/concurrent-a", {
next: { tags: ["request-1"] },
});
return getCollectedFetchTags();
}),
runWithFetchCache(async () => {
await fetch("https://api.example.com/concurrent-b", {
next: { tags: ["request-2"] },
});
return getCollectedFetchTags();
}),
]);
expect(tags1).toEqual(["request-1"]);
expect(tags2).toEqual(["request-2"]);
// Re-install for afterEach
cleanup = withFetchCache();
});
// ── Auth header isolation in cache keys ─────────────────────────────
describe("auth header cache isolation", () => {
it("different Authorization headers produce separate cache entries", async () => {
// Alice fetches with her token — explicitly opt into caching
const res1 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer alice-token" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Bob fetches with his token — should NOT get Alice's cached response
const res2 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer bob-token" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
// Alice fetches again — should get her cached response
const res3 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer alice-token" },
next: { revalidate: 60 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Cached from first request
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("different Cookie headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/profile", {
headers: { Cookie: "session=alice" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Bob's cookie should get a separate cache entry
const res2 = await fetch("https://api.example.com/profile", {
headers: { Cookie: "session=bob" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Fresh fetch, not Alice's data
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("requests without auth headers share cache (public data)", async () => {
const res1 = await fetch("https://api.example.com/public", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// No auth headers → same cache entry
const res2 = await fetch("https://api.example.com/public", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("auth headers with force-cache still produce per-user cache entries", async () => {
const res1 = await fetch("https://api.example.com/forced", {
headers: { Authorization: "Bearer alice" },
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/forced", {
headers: { Authorization: "Bearer bob" },
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Separate cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("auth headers with tags-only (no explicit revalidate) bypass cache", async () => {
// When only tags are specified but no explicit revalidate or force-cache,
// auth headers should cause a cache bypass
const res1 = await fetch("https://api.example.com/tagged-auth", {
headers: { Authorization: "Bearer alice" },
next: { tags: ["user-data"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Same user, same tags — should still bypass (no explicit cache opt-in)
const res2 = await fetch("https://api.example.com/tagged-auth", {
headers: { Authorization: "Bearer alice" },
next: { tags: ["user-data"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("X-API-Key header is included in cache key", async () => {
const res1 = await fetch("https://api.example.com/api-key", {
headers: { "X-API-Key": "key-alice" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/api-key", {
headers: { "X-API-Key": "key-bob" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different key = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("auth headers from Request object are included in cache key", async () => {
const req1 = new Request("https://api.example.com/req-auth", {
headers: { Authorization: "Bearer alice" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-auth", {
headers: { Authorization: "Bearer bob" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different auth = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── cache: 'no-cache' bypass ────────────────────────────────────────
it("cache: 'no-cache' bypasses cache entirely", async () => {
const res1 = await fetch("https://api.example.com/nocache", {
cache: "no-cache" as RequestCache,
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nocache", {
cache: "no-cache" as RequestCache,
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("cache: 'no-store' with auth headers bypasses cache", async () => {
const res1 = await fetch("https://api.example.com/nostore-auth", {
cache: "no-store",
headers: { Authorization: "Bearer token" },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nostore-auth", {
cache: "no-store",
headers: { Authorization: "Bearer token" },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("cache: 'no-cache' with auth headers bypasses cache", async () => {
const res1 = await fetch("https://api.example.com/nocache-auth", {
cache: "no-cache" as RequestCache,
headers: { Cookie: "session=alice" },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nocache-auth", {
cache: "no-cache" as RequestCache,
headers: { Cookie: "session=bob" },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Always fresh
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Cache key: body type handling ─────────────────────────────────
describe("cache key body type handling", () => {
it("different string bodies produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/body-str", {
method: "POST",
body: '{"type":"a"}',
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-str", {
method: "POST",
body: '{"type":"b"}',
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same string bodies hit the same cache entry", async () => {
const res1 = await fetch("https://api.example.com/body-same", {
method: "POST",
body: '{"query":"test"}',
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-same", {
method: "POST",
body: '{"query":"test"}',
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same body = same cache
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Uint8Array bodies are included in cache key", async () => {
const bodyA = new TextEncoder().encode("payload-a");
const bodyB = new TextEncoder().encode("payload-b");
const res1 = await fetch("https://api.example.com/body-uint8", {
method: "POST",
body: bodyA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-uint8", {
method: "POST",
body: bodyB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different binary body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Uint8Array bodies hit the same cache entry", async () => {
const body1 = new TextEncoder().encode("same-payload");
const body2 = new TextEncoder().encode("same-payload");
const res1 = await fetch("https://api.example.com/body-uint8-same", {
method: "POST",
body: body1,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-uint8-same", {
method: "POST",
body: body2,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same payload = same cache
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Blob bodies are included in cache key", async () => {
const blobA = new Blob(["blob-content-a"], { type: "text/plain" });
const blobB = new Blob(["blob-content-b"], { type: "text/plain" });
const res1 = await fetch("https://api.example.com/body-blob", {
method: "POST",
body: blobA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-blob", {
method: "POST",
body: blobB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different blob = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData bodies are included in cache key", async () => {
const formA = new FormData();
formA.append("name", "alice");
const formB = new FormData();
formB.append("name", "bob");
const res1 = await fetch("https://api.example.com/body-form", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different form data = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData values with commas do not collide in the cache key", async () => {
const formA = new FormData();
formA.append("name", "a,b");
formA.append("name", "c");
const formB = new FormData();
formB.append("name", "a");
formB.append("name", "b,c");
const res1 = await fetch("https://api.example.com/body-form-comma", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-comma", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different multi-value form data = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData entry order is preserved in the cache key", async () => {
const formA = new FormData();
formA.append("a", "1");
formA.append("b", "2");
formA.append("a", "3");
const formB = new FormData();
formB.append("a", "1");
formB.append("a", "3");
formB.append("b", "2");
const res1 = await fetch("https://api.example.com/body-form-order", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-order", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData file metadata is included in the cache key", async () => {
const formA = new FormData();
formA.append("file", new File(["same-bytes"], "a.txt", { type: "text/plain" }));
const formB = new FormData();
formB.append("file", new File(["same-bytes"], "b.bin", { type: "application/octet-stream" }));
const res1 = await fetch("https://api.example.com/body-form-file-metadata", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-file-metadata", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("ReadableStream bodies are included in cache key", async () => {
const streamA = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-a"));
controller.close();
},
});
const streamB = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-b"));
controller.close();
},
});
const res1 = await fetch("https://api.example.com/body-stream", {
method: "POST",
body: streamA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-stream", {
method: "POST",
body: streamB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different stream = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Cache key: header inclusion (all headers minus blocklist) ──────
describe("cache key header inclusion", () => {
it("different Accept headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/accept-test", {
headers: { Accept: "application/json" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/accept-test", {
headers: { Accept: "text/html" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Accept = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("different Accept-Language headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/lang-test", {
headers: { "Accept-Language": "en-US" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/lang-test", {
headers: { "Accept-Language": "fr-FR" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different language = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("custom headers are included in cache key", async () => {
const res1 = await fetch("https://api.example.com/custom-hdr", {
headers: { "X-Feature-Flag": "variant-a" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/custom-hdr", {
headers: { "X-Feature-Flag": "variant-b" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different custom header = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("traceparent and tracestate headers are excluded from cache key", async () => {
const res1 = await fetch("https://api.example.com/trace-test", {
headers: {
traceparent: "00-trace-id-1-01",
tracestate: "vendor=value1",
"X-Custom": "same",
},
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Same request but different trace headers — should hit cache
const res2 = await fetch("https://api.example.com/trace-test", {
headers: {
traceparent: "00-trace-id-2-01",
tracestate: "vendor=value2",
"X-Custom": "same",
},
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached — trace headers excluded from key
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("same headers produce same cache entry regardless of order", async () => {
const res1 = await fetch("https://api.example.com/hdr-order", {
headers: new Headers([
["X-First", "1"],
["X-Second", "2"],
]),
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Headers in different construction order — Headers object normalizes
const res2 = await fetch("https://api.example.com/hdr-order", {
headers: new Headers([
["X-Second", "2"],
["X-First", "1"],
]),
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same cache entry
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("requests with no headers vs with headers get separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/hdr-vs-none", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/hdr-vs-none", {
headers: { "X-Extra": "present" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Body restoration after cache key generation ───────────────────
describe("body restoration (_ogBody)", () => {
it("ReadableStream body is correctly passed to real fetch after cache key generation", async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-body-content"));
controller.close();
},
});
await fetch("https://api.example.com/stream-restore", {
method: "POST",
body: stream,
next: { revalidate: 60 },
});
// Verify the mock was called and the body was preserved as a stream
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBeInstanceOf(ReadableStream);
const reader = (init.body as ReadableStream<Uint8Array>).getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const length = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
const full = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
full.set(chunk, offset);
offset += chunk.byteLength;
}
const decoded = new TextDecoder().decode(full);
expect(decoded).toBe("stream-body-content");
});
it("Blob body is correctly passed to real fetch after cache key generation", async () => {
const blob = new Blob(["blob-body-content"], { type: "text/plain" });
await fetch("https://api.example.com/blob-restore", {
method: "POST",
body: blob,
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
// The body should be a Blob (reconstructed)
expect(init.body).toBeInstanceOf(Blob);
const text = await (init.body as Blob).text();
expect(text).toBe("blob-body-content");
});
it("Uint8Array body is correctly passed to real fetch after cache key generation", async () => {
const body = new TextEncoder().encode("uint8-body-content");
await fetch("https://api.example.com/uint8-restore", {
method: "POST",
body: body,
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBeInstanceOf(Uint8Array);
const decoded = new TextDecoder().decode(init.body as Uint8Array);
expect(decoded).toBe("uint8-body-content");
});
it("string body is correctly passed to real fetch after cache key generation", async () => {
await fetch("https://api.example.com/string-restore", {
method: "POST",
body: '{"key":"value"}',
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBe('{"key":"value"}');
});
it("Request object body is still passed through after cache key generation", async () => {
const request = new Request("https://api.example.com/request-restore", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
await fetch(request, { next: { revalidate: 60 } });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const forwardedRequest = call[0] as Request;
expect(forwardedRequest).toBeInstanceOf(Request);
expect(await forwardedRequest.text()).toBe("request-body-content");
});
it("already-consumed Request bodies bypass cache key generation and defer to the underlying fetch", async () => {
fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => {
if (input instanceof Request && input.bodyUsed) {
throw new TypeError("body already used");
}
return defaultFetchMockImplementation(input, _init);
});
const request = new Request("https://api.example.com/request-used", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
await request.text();
await expect(fetch(request, { next: { revalidate: 60 } })).rejects.toThrow(
"body already used",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("cache key oversized body safeguards", () => {
it("oversized Blob body bypasses cache and still fetches", async () => {
const largeBlob = new Blob(["x".repeat(1024 * 1024 + 1)]);
const res1 = await fetch("https://api.example.com/large-blob", {
method: "POST",
body: largeBlob,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-blob", {
method: "POST",
body: largeBlob,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized ReadableStream body bypasses cache and preserves stream body", async () => {
const makeLargeStream = () =>
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(1024 * 1024 + 1));
controller.close();
},
});
await fetch("https://api.example.com/large-stream", {
method: "POST",
body: makeLargeStream(),
next: { revalidate: 60 },
});
await fetch("https://api.example.com/large-stream", {
method: "POST",
body: makeLargeStream(),
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.body).toBeInstanceOf(ReadableStream);
});
it("oversized Uint8Array body bypasses cache and still fetches", async () => {
const largeBuffer = new Uint8Array(1024 * 1024 + 1);
const res1 = await fetch("https://api.example.com/large-uint8", {
method: "POST",
body: largeBuffer,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-uint8", {
method: "POST",
body: largeBuffer,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized string body bypasses cache and still fetches", async () => {
const largeString = "x".repeat(1024 * 1024 + 1);
const res1 = await fetch("https://api.example.com/large-string", {
method: "POST",
body: largeString,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-string", {
method: "POST",
body: largeString,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized Request body bypasses cache without cloning the body when content-length exceeds the limit", async () => {
const cloneSpy = vi.spyOn(Request.prototype, "clone");
const request = new Request("https://api.example.com/large-request-stream", {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"content-length": String(1024 * 1024 + 1),
},
body: new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array([1]));
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
try {
const res = await fetch(request, { next: { revalidate: 60 } });
const data = await res.json();
expect(data.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(cloneSpy).not.toHaveBeenCalled();
expect(request.bodyUsed).toBe(false);
} finally {
cloneSpy.mockRestore();
}
});
it("ReadableStream with many small chunks accumulating past limit bypasses cache", async () => {
const chunkSize = 64 * 1024; // 64 KiB per chunk
const numChunks = 17; // 17 * 64 KiB = 1088 KiB > 1 MiB
const makeLargeMultiChunkStream = () =>
new ReadableStream({
start(controller) {
for (let i = 0; i < numChunks; i++) {
controller.enqueue(new Uint8Array(chunkSize));
}
controller.close();
},
});
const res1 = await fetch("https://api.example.com/large-multi-chunk", {
method: "POST",
body: makeLargeMultiChunkStream(),
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-multi-chunk", {
method: "POST",
body: makeLargeMultiChunkStream(),
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because cumulative size exceeds limit
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData with large File entry bypasses cache and still fetches", async () => {
const largeContent = "x".repeat(1024 * 1024 + 1);
const largeFile = new File([largeContent], "big.txt", { type: "text/plain" });
const form = new FormData();
form.append("file", largeFile);
const res1 = await fetch("https://api.example.com/large-formdata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-formdata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because file is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── URLSearchParams body ──────────────────────────────────────────
describe("URLSearchParams body", () => {
it("different URLSearchParams bodies produce separate cache entries", async () => {
const paramsA = new URLSearchParams({ q: "alpha" });
const paramsB = new URLSearchParams({ q: "beta" });
const res1 = await fetch("https://api.example.com/body-usp", {
method: "POST",
body: paramsA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp", {
method: "POST",
body: paramsB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different params = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same URLSearchParams bodies hit the same cache entry", async () => {
const params1 = new URLSearchParams({ q: "same" });
const params2 = new URLSearchParams({ q: "same" });
const res1 = await fetch("https://api.example.com/body-usp-same", {
method: "POST",
body: params1,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp-same", {
method: "POST",
body: params2,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same params = cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("explicit URLSearchParams charset headers remain part of the cache key", async () => {
const res1 = await fetch("https://api.example.com/body-usp-charset", {
method: "POST",
body: new URLSearchParams({ q: "same" }),
headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp-charset", {
method: "POST",
body: new URLSearchParams({ q: "same" }),
headers: { "content-type": "application/x-www-form-urlencoded; charset=shift_jis" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Set-Cookie stripping from cached responses ──────────────────────────
describe("Set-Cookie header stripping", () => {
it("does not include Set-Cookie in cached response headers", async () => {
fetchMock.mockImplementationOnce(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "session=abc123; Path=/; HttpOnly",
"x-custom": "keep-me",
},
}),
);
// First request — response has Set-Cookie
const res1 = await fetch("https://api.example.com/set-cookie-test", {
next: { revalidate: 300 },
});
expect(res1.headers.get("set-cookie")).toBe("session=abc123; Path=/; HttpOnly");
expect(res1.headers.get("x-custom")).toBe("keep-me");
// Second request — served from cache, Set-Cookie must be absent
const res2 = await fetch("https://api.example.com/set-cookie-test", {
next: { revalidate: 300 },
});
expect(res2.headers.get("set-cookie")).toBeNull();
expect(res2.headers.get("x-custom")).toBe("keep-me");
});
});
});