mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e9fdc7b5b0
* fix: bound fetch cache key body serialization * Safeguard cache key serialization from large bodies Bump cache key version to v2 and add safeguards to serializeBody to avoid OOM when processing large request bodies. Track cumulative raw byte size of streamed chunks and throw BodyTooLargeForCacheKeyError when exceeding the 1 MiB limit, call reader.cancel on errors, and add a cheap string-length pre-check. Adds tests covering oversized Uint8Array, string, multi-chunk ReadableStream, and FormData/File cases to verify such requests bypass the cache and still perform fetches. --------- Co-authored-by: Sunil Pai <spai@cloudflare.com>
1247 lines
44 KiB
TypeScript
1247 lines
44 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 "vitest";
|
|
|
|
// 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 fetchMock = vi.fn(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" },
|
|
});
|
|
});
|
|
|
|
// 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, getOriginalFetch } = await import("../packages/vinext/src/shims/fetch-cache.js");
|
|
const { getCacheHandler, revalidateTag, MemoryCacheHandler, setCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
|
|
|
|
describe("fetch cache shim", () => {
|
|
let cleanup: (() => void) | null = null;
|
|
|
|
beforeEach(() => {
|
|
// Reset state
|
|
requestCount = 0;
|
|
fetchMock.mockClear();
|
|
// Reset the cache handler to a fresh instance for each test
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
// 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);
|
|
});
|
|
|
|
// ── No caching (no-store, revalidate: 0, revalidate: false) ─────────
|
|
|
|
it("cache: 'no-store' bypasses cache entirely", 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(2); // Fresh fetch each time
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("next.revalidate: 0 skips caching", 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(2); // Not cached
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("next.revalidate: false skips caching", 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(2); // Not cached
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("no cache or next options passes through without caching", 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(2); // Pass-through, no caching
|
|
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");
|
|
|
|
// 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");
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
});
|
|
|
|
// ── 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);
|
|
});
|
|
|
|
// ── 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
|
|
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);
|
|
});
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// 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 passes through", 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(2); // Not cached
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// ── 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(2); // Not cached — safety bypass
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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(2); // Fresh fetch each time
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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(2); // Always fresh
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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("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"}');
|
|
});
|
|
});
|
|
|
|
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("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);
|
|
});
|
|
});
|
|
});
|