accounts
Version:
Tempo Accounts SDK
255 lines (222 loc) • 7.37 kB
text/typescript
import { describe, expect, test } from 'vp/test'
import * as Kv from './Kv.js'
describe('memory', () => {
test('default: round-trips set/get/delete', async () => {
const kv = Kv.memory()
await kv.set('a', { value: 1 })
expect(await kv.get('a')).toMatchInlineSnapshot(`
{
"value": 1,
}
`)
await kv.delete('a')
expect(await kv.get('a')).toMatchInlineSnapshot(`undefined`)
})
test('ttl: returns value before expiry', async () => {
let now = 1_000_000
const kv = Kv.memory({ now: () => now })
await kv.set('a', 'v', { ttl: 60 })
now += 30_000
expect(await kv.get('a')).toMatchInlineSnapshot(`"v"`)
})
test('ttl: returns undefined after expiry', async () => {
let now = 1_000_000
const kv = Kv.memory({ now: () => now })
await kv.set('a', 'v', { ttl: 60 })
now += 60_001
expect(await kv.get('a')).toMatchInlineSnapshot(`undefined`)
})
test('ttl: expiry deletes the entry (lazy eviction)', async () => {
let now = 1_000_000
const kv = Kv.memory({ now: () => now })
await kv.set('a', 'v', { ttl: 1 })
now += 2_000
await kv.get('a')
// Re-set without TTL; previous expired entry should be gone, not lingering.
await kv.set('a', 'v2')
expect(await kv.get('a')).toMatchInlineSnapshot(`"v2"`)
})
test('take: returns the value and removes the entry', async () => {
const kv = Kv.memory()
await kv.set('n', { nonce: 'abc' })
expect(await kv.take!('n')).toMatchInlineSnapshot(`
{
"nonce": "abc",
}
`)
expect(await kv.get('n')).toMatchInlineSnapshot(`undefined`)
})
test('take: returns undefined for missing or expired keys', async () => {
let now = 1_000_000
const kv = Kv.memory({ now: () => now })
expect(await kv.take!('missing')).toMatchInlineSnapshot(`undefined`)
await kv.set('e', 'v', { ttl: 1 })
now += 2_000
expect(await kv.take!('e')).toMatchInlineSnapshot(`undefined`)
})
test('take: concurrent callers — only one observes the value', async () => {
// The whole point: read+delete is atomic across awaits, so two
// verifies racing on the same nonce can never both succeed.
const kv = Kv.memory()
await kv.set('nonce', 'one-time')
const [a, b] = await Promise.all([kv.take!('nonce'), kv.take!('nonce')])
const winners = [a, b].filter((v) => v !== undefined)
expect(winners).toMatchInlineSnapshot(`
[
"one-time",
]
`)
})
})
describe('durableObject + NonceStorage', () => {
/**
* In-process simulation: a single `NonceStorage` instance backed by a
* Map, exposed via the same fetch protocol the real DO would use.
* This is exactly what the CF runtime gives us — single-actor, no
* concurrent requests interleaving inside the DO — so the same
* atomicity guarantees apply.
*/
function fakeDurableObject() {
const map = new Map<string, unknown>()
const storage = {
async get<T>(key: string) {
return map.get(key) as T | undefined
},
async put(key: string, value: unknown) {
map.set(key, value)
},
async delete(key: string) {
map.delete(key)
},
}
const instance = new Kv.NonceStorage({ storage })
// Mimic the CF DO runtime's actor model: requests to the same DO
// instance are serialized — never two `fetch` invocations executing
// interleaved in the same actor.
let queue: Promise<unknown> = Promise.resolve()
const serialized = (req: Request) => {
const next = queue.then(() => instance.fetch(req))
queue = next.catch(() => {})
return next
}
return {
idFromName: (name: string) => name,
get: () => ({
fetch: (input: string, init?: unknown) =>
serialized(new Request(input, init as RequestInit)),
}),
}
}
test('round-trips set/get/delete via the DO fetch protocol', async () => {
const kv = Kv.durableObject(fakeDurableObject())
await kv.set('a', { value: 1 })
expect(await kv.get('a')).toMatchInlineSnapshot(`
{
"value": 1,
}
`)
await kv.delete('a')
expect(await kv.get('a')).toMatchInlineSnapshot(`undefined`)
})
test('take: removes the entry and returns the value', async () => {
const kv = Kv.durableObject(fakeDurableObject())
await kv.set('n', 'one-time')
expect(await kv.take!('n')).toMatchInlineSnapshot(`"one-time"`)
expect(await kv.get('n')).toMatchInlineSnapshot(`undefined`)
})
test('take: concurrent callers — only one wins', async () => {
// The whole point of the DO adapter: even with parallel `take` calls
// only one observer receives the value. The DO actor serializes
// requests, so this is guaranteed by the runtime.
const kv = Kv.durableObject(fakeDurableObject())
await kv.set('nonce', 'consume-once')
const [a, b] = await Promise.all([kv.take!('nonce'), kv.take!('nonce')])
const winners = [a, b].filter((v) => v !== undefined)
expect(winners).toMatchInlineSnapshot(`
[
"consume-once",
]
`)
})
test('take: missing key returns undefined', async () => {
const kv = Kv.durableObject(fakeDurableObject())
expect(await kv.take!('missing')).toMatchInlineSnapshot(`undefined`)
})
})
describe('cloudflare', () => {
test('default: forwards set/get/delete to underlying KV', async () => {
const calls: { method: string; args: unknown[] }[] = []
const fakeKv = {
get: async (key: string, format: 'json') => {
calls.push({ method: 'get', args: [key, format] })
return 'value' as never
},
put: async (key: string, value: string, options?: unknown) => {
calls.push({ method: 'put', args: [key, value, options] })
},
delete: async (key: string) => {
calls.push({ method: 'delete', args: [key] })
},
}
const kv = Kv.cloudflare(fakeKv)
await kv.set('a', { value: 1 })
await kv.get('a')
await kv.delete('a')
expect(calls).toMatchInlineSnapshot(`
[
{
"args": [
"a",
"{"value":1}",
undefined,
],
"method": "put",
},
{
"args": [
"a",
"json",
],
"method": "get",
},
{
"args": [
"a",
],
"method": "delete",
},
]
`)
})
test('take: NOT implemented (CF KV is not linearizable)', () => {
const kv = Kv.cloudflare({
get: async () => null,
put: async () => {},
delete: async () => {},
})
expect(kv.take).toBeUndefined()
})
test('ttl: passes expirationTtl seconds to underlying put', async () => {
const puts: { key: string; value: string; options: unknown }[] = []
const fakeKv = {
get: async () => undefined as never,
put: async (key: string, value: string, options?: unknown) => {
puts.push({ key, value, options })
},
delete: async () => {},
}
const kv = Kv.cloudflare(fakeKv)
await kv.set('a', 'v', { ttl: 60 })
expect(puts).toMatchInlineSnapshot(`
[
{
"key": "a",
"options": {
"expirationTtl": 60,
},
"value": ""v"",
},
]
`)
})
})