mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1d01d2989a
* 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
2416 lines
85 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|