This repository has no description
1import { describe, expect, it, vi } from 'vitest';
2import { createBobbinClient, type BobbinContext } from './client';
3import { IdentityCache, type MiniDoc } from './identity';
4
5const DOC: MiniDoc = { did: 'did:plc:x', handle: 'alice.test' };
6
7const docResponse = (doc: MiniDoc): Response =>
8 new Response(JSON.stringify(doc), {
9 status: 200,
10 headers: { 'content-type': 'application/json' }
11 });
12
13const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext =>
14 createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock });
15
16const deferred = <T>(): { promise: Promise<T>; resolve: (value: T) => void } => {
17 let resolve!: (value: T) => void;
18 const promise = new Promise<T>((r) => {
19 resolve = r;
20 });
21 return { promise, resolve };
22};
23
24describe('IdentityCache.resolve', () => {
25 it('fetches on the first miss then serves the cached document', async () => {
26 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
27 const cache = new IdentityCache(makeCtx(fetchMock));
28
29 const first = await cache.resolve('alice.test');
30 const second = await cache.resolve('alice.test');
31
32 expect(first).toEqual(DOC);
33 expect(second).toEqual(DOC);
34 expect(fetchMock).toHaveBeenCalledTimes(1);
35 expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('alice.test');
36 });
37
38 it('coalesces concurrent misses for the same key into one in-flight fetch', async () => {
39 const gate = deferred<Response>();
40 const fetchMock = vi.fn<typeof globalThis.fetch>().mockReturnValue(gate.promise);
41 const cache = new IdentityCache(makeCtx(fetchMock));
42
43 const a = cache.resolve('alice.test');
44 const b = cache.resolve('alice.test');
45 expect(fetchMock).toHaveBeenCalledTimes(1);
46
47 gate.resolve(docResponse(DOC));
48 const [ra, rb] = await Promise.all([a, b]);
49 expect(ra).toEqual(DOC);
50 expect(rb).toEqual(DOC);
51 expect(fetchMock).toHaveBeenCalledTimes(1);
52 });
53
54 it('re-fetches after the in-flight promise settles (no permanent stampede lock)', async () => {
55 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
56 const cache = new IdentityCache(makeCtx(fetchMock));
57
58 await cache.resolve('bob.test');
59 fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:y', handle: 'carol.test' }));
60 await cache.resolve('carol.test');
61 expect(fetchMock).toHaveBeenCalledTimes(2);
62 });
63
64 it('populates both directions of the index from a resolved document', async () => {
65 const fetchMock = vi.fn<typeof globalThis.fetch>().mockResolvedValue(docResponse(DOC));
66 const cache = new IdentityCache(makeCtx(fetchMock));
67
68 await cache.resolve('alice.test');
69 expect(cache.didFor('alice.test')).toBe('did:plc:x');
70 expect(cache.handleFor('did:plc:x')).toBe('alice.test');
71 });
72});
73
74describe('IdentityCache.prime & map selection', () => {
75 it('seeds both directions without any fetch', () => {
76 const fetchMock = vi.fn<typeof globalThis.fetch>();
77 const cache = new IdentityCache(makeCtx(fetchMock));
78
79 cache.prime(DOC);
80 expect(cache.didFor('alice.test')).toBe('did:plc:x');
81 expect(cache.handleFor('did:plc:x')).toBe('alice.test');
82 expect(fetchMock).not.toHaveBeenCalled();
83 });
84
85 it('resolves a DID against the did map and a handle against the handle map', async () => {
86 const fetchMock = vi.fn<typeof globalThis.fetch>();
87 const cache = new IdentityCache(makeCtx(fetchMock));
88 cache.prime(DOC);
89
90 await expect(cache.resolve('did:plc:x')).resolves.toEqual(DOC);
91 await expect(cache.resolve('alice.test')).resolves.toEqual(DOC);
92 expect(fetchMock).not.toHaveBeenCalled();
93
94 // unknown dids miss the handle map and fetch.
95 fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:z', handle: 'dan.test' }));
96 await cache.resolve('did:plc:z');
97 expect(fetchMock).toHaveBeenCalledTimes(1);
98 expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('did:plc:z');
99 });
100});