diff --git a/packages/cfworkers/deno.json b/packages/cfworkers/deno.json index 2bafa6520..d5ac79828 100644 --- a/packages/cfworkers/deno.json +++ b/packages/cfworkers/deno.json @@ -13,6 +13,11 @@ "node_modules", "test" ], + "test": { + "exclude": [ + "src/**/*.test.ts" + ] + }, "publish": { "exclude": [ "**/*.test.ts", diff --git a/packages/cfworkers/src/mod.test.ts b/packages/cfworkers/src/mod.test.ts new file mode 100644 index 000000000..dfd1cf3b6 --- /dev/null +++ b/packages/cfworkers/src/mod.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { type WorkersKvNamespaceLike, WorkersKvStore } from "./mod.ts"; + +// Mock Temporal.Duration for testing in Cloudflare Workers environment +const mockDuration = (seconds: number) => ({ + total: (unit: string) => { + if (unit === "milliseconds" || unit === "millisecond") { + return seconds * 1000; + } + if (unit === "seconds" || unit === "second") return seconds; + return seconds; + }, +}); + +interface KvGetWithMetadataResult { + readonly value: Value | null; + readonly metadata: Metadata | null; +} + +interface KvPutOptions { + readonly expiration?: number; + readonly expirationTtl?: number; + readonly metadata?: unknown; +} + +interface KvListOptions { + readonly limit?: number; + readonly prefix?: string | null; + readonly cursor?: string | null; +} + +interface KvListResult { + readonly list_complete: boolean; + readonly keys: readonly { + readonly name: string; + readonly expiration?: number; + readonly metadata?: Metadata; + }[]; + readonly cursor?: string; +} + +interface StoredEntry { + readonly value: string; + readonly metadata: unknown; + readonly options?: KvPutOptions; +} + +class MockKvNamespace implements WorkersKvNamespaceLike { + readonly entries = new Map(); + listCalls = 0; + readonly #pageSize: number; + + constructor(pageSize = Infinity) { + this.#pageSize = pageSize; + } + + get(key: string): Promise; + get( + key: string, + type: "json", + ): Promise; + get(key: string, type?: "json") { + if (type !== "json") { + throw new Error("MockKvNamespace only uses the JSON mode of get()."); + } + + const entry = this.entries.get(key); + if (entry == null) return Promise.resolve(null); + return Promise.resolve(JSON.parse(entry.value)); + } + + getWithMetadata( + key: string, + ): Promise>; + getWithMetadata( + key: string, + type: "json", + ): Promise>; + getWithMetadata(key: string, type?: "json") { + if (type !== "json") { + throw new Error( + "MockKvNamespace only uses the JSON mode of getWithMetadata().", + ); + } + + const entry = this.entries.get(key); + if (entry == null) return Promise.resolve({ value: null, metadata: null }); + return Promise.resolve({ + value: JSON.parse(entry.value), + metadata: entry.metadata, + }); + } + + put(key: string, value: string, options?: KvPutOptions): Promise { + this.entries.set(key, { + value, + metadata: options?.metadata ?? null, + options, + }); + return Promise.resolve(); + } + + delete(key: string) { + this.entries.delete(key); + return Promise.resolve(); + } + + list( + options?: KvListOptions, + ): Promise> { + this.listCalls++; + const matches = [...this.entries] + .filter(([name]) => name.startsWith(options?.prefix ?? "")) + .map(([name, entry]) => ({ name, metadata: entry.metadata as Metadata })); + const start = options?.cursor == null ? 0 : Number(options.cursor); + const keys = matches.slice(start, start + this.#pageSize); + const nextStart = start + keys.length; + if (nextStart >= matches.length) { + return Promise.resolve({ list_complete: true, keys }); + } + return Promise.resolve({ + list_complete: false, + keys, + cursor: String(nextStart), + }); + } +} + +describe("WorkersKvStore", () => { + it("set() & get()", async () => { + const store = new WorkersKvStore(new MockKvNamespace()); + + await store.set(["foo", "bar"], { foo: 1, bar: 2 }); + + expect(await store.get(["foo", "bar"])).toEqual({ foo: 1, bar: 2 }); + expect(await store.get(["foo"])).toBeUndefined(); + }); + + it("get() - excludes expired entry", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + namespace.entries.set('["expired"]', { + value: JSON.stringify("stale"), + metadata: { expires: Date.now() - 1000 }, + }); + + expect(await store.get(["expired"])).toBeUndefined(); + }); + + it("get() - includes unexpired entry", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + namespace.entries.set('["fresh"]', { + value: JSON.stringify("live"), + metadata: { expires: Date.now() + 60000 }, + }); + + expect(await store.get(["fresh"])).toBe("live"); + }); + + it("set() - JSON-encodes key and value", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + await store.set(["foo", "bar"], { foo: 1 }); + + expect(namespace.entries.get('["foo","bar"]')?.value).toBe('{"foo":1}'); + }); + + it("set() - overwrites existing value", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + await store.set(["foo"], "first"); + await store.set(["foo"], "second"); + + expect(await store.get(["foo"])).toBe("second"); + }); + + it("delete()", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + await store.set(["delete", "me"], "value"); + await store.delete(["delete", "me"]); + + expect(await store.get(["delete", "me"])).toBeUndefined(); + }); + + it("delete() - missing key", async () => { + const store = new WorkersKvStore(new MockKvNamespace()); + + await store.delete(["missing"]); + + expect(await store.get(["missing"])).toBeUndefined(); + }); + + it("set() - no TTL omits expirationTtl", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + await store.set(["no-ttl"], "value"); + + const entry = namespace.entries.get('["no-ttl"]'); + expect(entry?.options?.expirationTtl).toBeUndefined(); + expect(entry?.metadata).toEqual({}); + }); + + it("set() - TTL over a minute passes through", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + await store.set(["long-ttl"], "value", { + ttl: mockDuration(3600) as unknown as Temporal.Duration, // 1 hour + }); + + expect(namespace.entries.get('["long-ttl"]')?.options?.expirationTtl) + .toBe(3600); + }); + + it("set() - clamps sub-minute TTL to 60 seconds", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + const before = Date.now(); + await store.set(["short-ttl"], "value", { + ttl: mockDuration(5) as unknown as Temporal.Duration, // 5 sec, + }); + + const entry = namespace.entries.get('["short-ttl"]'); + expect(entry?.options?.expirationTtl).toBe(60); + + const { expires } = entry?.metadata as { expires: number }; + expect(expires).toBeGreaterThanOrEqual(before + 5000); + expect(expires).toBeLessThanOrEqual(Date.now() + 5000); + }); + + it("list() - exact prefix key and child keys", async () => { + const store = new WorkersKvStore(new MockKvNamespace()); + + await store.set(["prefix"], "root"); + await store.set(["prefix", "a"], "value-a"); + await store.set(["prefix", "b"], "value-b"); + await store.set(["prefix", "nested", "c"], "value-c"); + await store.set(["other", "x"], "value-x"); + + const entries = await Array.fromAsync(store.list(["prefix"])); + + expect(entries).toHaveLength(4); + expect(entries).toContainEqual({ key: ["prefix"], value: "root" }); + expect(entries).toContainEqual({ key: ["prefix", "a"], value: "value-a" }); + expect(entries).toContainEqual({ key: ["prefix", "b"], value: "value-b" }); + expect(entries).toContainEqual({ + key: ["prefix", "nested", "c"], + value: "value-c", + }); + }); + + it("list() - no match for sibling string prefix", async () => { + const store = new WorkersKvStore(new MockKvNamespace()); + + await store.set(["prefix", "a"], "value-a"); + await store.set(["prefixextra", "b"], "value-b"); + + const entries = await Array.fromAsync(store.list(["prefix"])); + + expect(entries).toEqual([{ key: ["prefix", "a"], value: "value-a" }]); + }); + + it("list() - excludes expired entries", async () => { + const namespace = new MockKvNamespace(); + const store = new WorkersKvStore(namespace); + + namespace.entries.set('["prefix"]', { + value: JSON.stringify("stale-root"), + metadata: { expires: Date.now() - 1000 }, + }); + namespace.entries.set('["prefix","stale"]', { + value: JSON.stringify("stale-child"), + metadata: { expires: Date.now() - 1000 }, + }); + await store.set(["prefix", "fresh"], "fresh-child"); + + const entries = await Array.fromAsync(store.list(["prefix"])); + + expect(entries).toEqual([ + { key: ["prefix", "fresh"], value: "fresh-child" }, + ]); + }); + + it("list() - follows cursor across pages", async () => { + const namespace = new MockKvNamespace(2); + const store = new WorkersKvStore(namespace); + + await store.set(["page", "a"], "value-a"); + await store.set(["page", "b"], "value-b"); + await store.set(["page", "c"], "value-c"); + await store.set(["page", "d"], "value-d"); + await store.set(["page", "e"], "value-e"); + + const entries = await Array.fromAsync(store.list(["page"])); + + expect(entries).toHaveLength(5); + expect(entries).toContainEqual({ key: ["page", "a"], value: "value-a" }); + expect(entries).toContainEqual({ key: ["page", "e"], value: "value-e" }); + + expect(namespace.listCalls).toBe(3); + }); +});