import { describe, expect, it, vi } from 'vitest'; import { createBobbinClient, type BobbinContext } from './client'; import { IdentityCache, type MiniDoc } from './identity'; const DOC: MiniDoc = { did: 'did:plc:x', handle: 'alice.test' }; const docResponse = (doc: MiniDoc): Response => new Response(JSON.stringify(doc), { status: 200, headers: { 'content-type': 'application/json' } }); const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); const deferred = (): { promise: Promise; resolve: (value: T) => void } => { let resolve!: (value: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; describe('IdentityCache.resolve', () => { it('fetches on the first miss then serves the cached document', async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); const first = await cache.resolve('alice.test'); const second = await cache.resolve('alice.test'); expect(first).toEqual(DOC); expect(second).toEqual(DOC); expect(fetchMock).toHaveBeenCalledTimes(1); expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('alice.test'); }); it('coalesces concurrent misses for the same key into one in-flight fetch', async () => { const gate = deferred(); const fetchMock = vi.fn().mockReturnValue(gate.promise); const cache = new IdentityCache(makeCtx(fetchMock)); const a = cache.resolve('alice.test'); const b = cache.resolve('alice.test'); expect(fetchMock).toHaveBeenCalledTimes(1); gate.resolve(docResponse(DOC)); const [ra, rb] = await Promise.all([a, b]); expect(ra).toEqual(DOC); expect(rb).toEqual(DOC); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('re-fetches after the in-flight promise settles (no permanent stampede lock)', async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); await cache.resolve('bob.test'); fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:y', handle: 'carol.test' })); await cache.resolve('carol.test'); expect(fetchMock).toHaveBeenCalledTimes(2); }); it('populates both directions of the index from a resolved document', async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); await cache.resolve('alice.test'); expect(cache.didFor('alice.test')).toBe('did:plc:x'); expect(cache.handleFor('did:plc:x')).toBe('alice.test'); }); }); describe('IdentityCache.prime & map selection', () => { it('seeds both directions without any fetch', () => { const fetchMock = vi.fn(); const cache = new IdentityCache(makeCtx(fetchMock)); cache.prime(DOC); expect(cache.didFor('alice.test')).toBe('did:plc:x'); expect(cache.handleFor('did:plc:x')).toBe('alice.test'); expect(fetchMock).not.toHaveBeenCalled(); }); it('resolves a DID against the did map and a handle against the handle map', async () => { const fetchMock = vi.fn(); const cache = new IdentityCache(makeCtx(fetchMock)); cache.prime(DOC); await expect(cache.resolve('did:plc:x')).resolves.toEqual(DOC); await expect(cache.resolve('alice.test')).resolves.toEqual(DOC); expect(fetchMock).not.toHaveBeenCalled(); // unknown dids miss the handle map and fetch. fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:z', handle: 'dan.test' })); await cache.resolve('did:plc:z'); expect(fetchMock).toHaveBeenCalledTimes(1); expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('did:plc:z'); }); });