Skip to content

Commit 3eba4db

Browse files
authored
Merge pull request #987 from kecan0406/cfworkers-kv-tests
Add unit tests for WorkersKvStore
2 parents 15ad151 + 362459a commit 3eba4db

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

‎packages/cfworkers/deno.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
"node_modules",
1414
"test"
1515
],
16+
"test": {
17+
"exclude": [
18+
"src/**/*.test.ts"
19+
]
20+
},
1621
"publish": {
1722
"exclude": [
1823
"**/*.test.ts",

‎packages/cfworkers/src/mod.test.ts‎

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { describe, expect, it } from "vitest";
2+
import { type WorkersKvNamespaceLike, WorkersKvStore } from "./mod.ts";
3+
4+
// Mock Temporal.Duration for testing in Cloudflare Workers environment
5+
const mockDuration = (seconds: number) => ({
6+
total: (unit: string) => {
7+
if (unit === "milliseconds" || unit === "millisecond") {
8+
return seconds * 1000;
9+
}
10+
if (unit === "seconds" || unit === "second") return seconds;
11+
return seconds;
12+
},
13+
});
14+
15+
interface KvGetWithMetadataResult<Value, Metadata> {
16+
readonly value: Value | null;
17+
readonly metadata: Metadata | null;
18+
}
19+
20+
interface KvPutOptions {
21+
readonly expiration?: number;
22+
readonly expirationTtl?: number;
23+
readonly metadata?: unknown;
24+
}
25+
26+
interface KvListOptions {
27+
readonly limit?: number;
28+
readonly prefix?: string | null;
29+
readonly cursor?: string | null;
30+
}
31+
32+
interface KvListResult<Metadata> {
33+
readonly list_complete: boolean;
34+
readonly keys: readonly {
35+
readonly name: string;
36+
readonly expiration?: number;
37+
readonly metadata?: Metadata;
38+
}[];
39+
readonly cursor?: string;
40+
}
41+
42+
interface StoredEntry {
43+
readonly value: string;
44+
readonly metadata: unknown;
45+
readonly options?: KvPutOptions;
46+
}
47+
48+
class MockKvNamespace implements WorkersKvNamespaceLike {
49+
readonly entries = new Map<string, StoredEntry>();
50+
listCalls = 0;
51+
readonly #pageSize: number;
52+
53+
constructor(pageSize = Infinity) {
54+
this.#pageSize = pageSize;
55+
}
56+
57+
get(key: string): Promise<string | null>;
58+
get<ExpectedValue = unknown>(
59+
key: string,
60+
type: "json",
61+
): Promise<ExpectedValue | null>;
62+
get(key: string, type?: "json") {
63+
if (type !== "json") {
64+
throw new Error("MockKvNamespace only uses the JSON mode of get().");
65+
}
66+
67+
const entry = this.entries.get(key);
68+
if (entry == null) return Promise.resolve(null);
69+
return Promise.resolve(JSON.parse(entry.value));
70+
}
71+
72+
getWithMetadata<Metadata = unknown>(
73+
key: string,
74+
): Promise<KvGetWithMetadataResult<string, Metadata>>;
75+
getWithMetadata<ExpectedValue = unknown, Metadata = unknown>(
76+
key: string,
77+
type: "json",
78+
): Promise<KvGetWithMetadataResult<ExpectedValue, Metadata>>;
79+
getWithMetadata(key: string, type?: "json") {
80+
if (type !== "json") {
81+
throw new Error(
82+
"MockKvNamespace only uses the JSON mode of getWithMetadata().",
83+
);
84+
}
85+
86+
const entry = this.entries.get(key);
87+
if (entry == null) return Promise.resolve({ value: null, metadata: null });
88+
return Promise.resolve({
89+
value: JSON.parse(entry.value),
90+
metadata: entry.metadata,
91+
});
92+
}
93+
94+
put(key: string, value: string, options?: KvPutOptions): Promise<void> {
95+
this.entries.set(key, {
96+
value,
97+
metadata: options?.metadata ?? null,
98+
options,
99+
});
100+
return Promise.resolve();
101+
}
102+
103+
delete(key: string) {
104+
this.entries.delete(key);
105+
return Promise.resolve();
106+
}
107+
108+
list<Metadata = unknown>(
109+
options?: KvListOptions,
110+
): Promise<KvListResult<Metadata>> {
111+
this.listCalls++;
112+
const matches = [...this.entries]
113+
.filter(([name]) => name.startsWith(options?.prefix ?? ""))
114+
.map(([name, entry]) => ({ name, metadata: entry.metadata as Metadata }));
115+
const start = options?.cursor == null ? 0 : Number(options.cursor);
116+
const keys = matches.slice(start, start + this.#pageSize);
117+
const nextStart = start + keys.length;
118+
if (nextStart >= matches.length) {
119+
return Promise.resolve({ list_complete: true, keys });
120+
}
121+
return Promise.resolve({
122+
list_complete: false,
123+
keys,
124+
cursor: String(nextStart),
125+
});
126+
}
127+
}
128+
129+
describe("WorkersKvStore", () => {
130+
it("set() & get()", async () => {
131+
const store = new WorkersKvStore(new MockKvNamespace());
132+
133+
await store.set(["foo", "bar"], { foo: 1, bar: 2 });
134+
135+
expect(await store.get(["foo", "bar"])).toEqual({ foo: 1, bar: 2 });
136+
expect(await store.get(["foo"])).toBeUndefined();
137+
});
138+
139+
it("get() - excludes expired entry", async () => {
140+
const namespace = new MockKvNamespace();
141+
const store = new WorkersKvStore(namespace);
142+
143+
namespace.entries.set('["expired"]', {
144+
value: JSON.stringify("stale"),
145+
metadata: { expires: Date.now() - 1000 },
146+
});
147+
148+
expect(await store.get(["expired"])).toBeUndefined();
149+
});
150+
151+
it("get() - includes unexpired entry", async () => {
152+
const namespace = new MockKvNamespace();
153+
const store = new WorkersKvStore(namespace);
154+
155+
namespace.entries.set('["fresh"]', {
156+
value: JSON.stringify("live"),
157+
metadata: { expires: Date.now() + 60000 },
158+
});
159+
160+
expect(await store.get(["fresh"])).toBe("live");
161+
});
162+
163+
it("set() - JSON-encodes key and value", async () => {
164+
const namespace = new MockKvNamespace();
165+
const store = new WorkersKvStore(namespace);
166+
167+
await store.set(["foo", "bar"], { foo: 1 });
168+
169+
expect(namespace.entries.get('["foo","bar"]')?.value).toBe('{"foo":1}');
170+
});
171+
172+
it("set() - overwrites existing value", async () => {
173+
const namespace = new MockKvNamespace();
174+
const store = new WorkersKvStore(namespace);
175+
176+
await store.set(["foo"], "first");
177+
await store.set(["foo"], "second");
178+
179+
expect(await store.get(["foo"])).toBe("second");
180+
});
181+
182+
it("delete()", async () => {
183+
const namespace = new MockKvNamespace();
184+
const store = new WorkersKvStore(namespace);
185+
186+
await store.set(["delete", "me"], "value");
187+
await store.delete(["delete", "me"]);
188+
189+
expect(await store.get(["delete", "me"])).toBeUndefined();
190+
});
191+
192+
it("delete() - missing key", async () => {
193+
const store = new WorkersKvStore(new MockKvNamespace());
194+
195+
await store.delete(["missing"]);
196+
197+
expect(await store.get(["missing"])).toBeUndefined();
198+
});
199+
200+
it("set() - no TTL omits expirationTtl", async () => {
201+
const namespace = new MockKvNamespace();
202+
const store = new WorkersKvStore(namespace);
203+
204+
await store.set(["no-ttl"], "value");
205+
206+
const entry = namespace.entries.get('["no-ttl"]');
207+
expect(entry?.options?.expirationTtl).toBeUndefined();
208+
expect(entry?.metadata).toEqual({});
209+
});
210+
211+
it("set() - TTL over a minute passes through", async () => {
212+
const namespace = new MockKvNamespace();
213+
const store = new WorkersKvStore(namespace);
214+
215+
await store.set(["long-ttl"], "value", {
216+
ttl: mockDuration(3600) as unknown as Temporal.Duration, // 1 hour
217+
});
218+
219+
expect(namespace.entries.get('["long-ttl"]')?.options?.expirationTtl)
220+
.toBe(3600);
221+
});
222+
223+
it("set() - clamps sub-minute TTL to 60 seconds", async () => {
224+
const namespace = new MockKvNamespace();
225+
const store = new WorkersKvStore(namespace);
226+
227+
const before = Date.now();
228+
await store.set(["short-ttl"], "value", {
229+
ttl: mockDuration(5) as unknown as Temporal.Duration, // 5 sec,
230+
});
231+
232+
const entry = namespace.entries.get('["short-ttl"]');
233+
expect(entry?.options?.expirationTtl).toBe(60);
234+
235+
const { expires } = entry?.metadata as { expires: number };
236+
expect(expires).toBeGreaterThanOrEqual(before + 5000);
237+
expect(expires).toBeLessThanOrEqual(Date.now() + 5000);
238+
});
239+
240+
it("list() - exact prefix key and child keys", async () => {
241+
const store = new WorkersKvStore(new MockKvNamespace());
242+
243+
await store.set(["prefix"], "root");
244+
await store.set(["prefix", "a"], "value-a");
245+
await store.set(["prefix", "b"], "value-b");
246+
await store.set(["prefix", "nested", "c"], "value-c");
247+
await store.set(["other", "x"], "value-x");
248+
249+
const entries = await Array.fromAsync(store.list(["prefix"]));
250+
251+
expect(entries).toHaveLength(4);
252+
expect(entries).toContainEqual({ key: ["prefix"], value: "root" });
253+
expect(entries).toContainEqual({ key: ["prefix", "a"], value: "value-a" });
254+
expect(entries).toContainEqual({ key: ["prefix", "b"], value: "value-b" });
255+
expect(entries).toContainEqual({
256+
key: ["prefix", "nested", "c"],
257+
value: "value-c",
258+
});
259+
});
260+
261+
it("list() - no match for sibling string prefix", async () => {
262+
const store = new WorkersKvStore(new MockKvNamespace());
263+
264+
await store.set(["prefix", "a"], "value-a");
265+
await store.set(["prefixextra", "b"], "value-b");
266+
267+
const entries = await Array.fromAsync(store.list(["prefix"]));
268+
269+
expect(entries).toEqual([{ key: ["prefix", "a"], value: "value-a" }]);
270+
});
271+
272+
it("list() - excludes expired entries", async () => {
273+
const namespace = new MockKvNamespace();
274+
const store = new WorkersKvStore(namespace);
275+
276+
namespace.entries.set('["prefix"]', {
277+
value: JSON.stringify("stale-root"),
278+
metadata: { expires: Date.now() - 1000 },
279+
});
280+
namespace.entries.set('["prefix","stale"]', {
281+
value: JSON.stringify("stale-child"),
282+
metadata: { expires: Date.now() - 1000 },
283+
});
284+
await store.set(["prefix", "fresh"], "fresh-child");
285+
286+
const entries = await Array.fromAsync(store.list(["prefix"]));
287+
288+
expect(entries).toEqual([
289+
{ key: ["prefix", "fresh"], value: "fresh-child" },
290+
]);
291+
});
292+
293+
it("list() - follows cursor across pages", async () => {
294+
const namespace = new MockKvNamespace(2);
295+
const store = new WorkersKvStore(namespace);
296+
297+
await store.set(["page", "a"], "value-a");
298+
await store.set(["page", "b"], "value-b");
299+
await store.set(["page", "c"], "value-c");
300+
await store.set(["page", "d"], "value-d");
301+
await store.set(["page", "e"], "value-e");
302+
303+
const entries = await Array.fromAsync(store.list(["page"]));
304+
305+
expect(entries).toHaveLength(5);
306+
expect(entries).toContainEqual({ key: ["page", "a"], value: "value-a" });
307+
expect(entries).toContainEqual({ key: ["page", "e"], value: "value-e" });
308+
309+
expect(namespace.listCalls).toBe(3);
310+
});
311+
});

0 commit comments

Comments
 (0)