Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

112 lines
4.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { BrowserKVStore } from '@openmaic/storage';
import { getLearnerKey, LEARNER_KEY_KV_KEY } from '@/lib/runtime/learner-key';
function memoryStorage(): Storage {
const m = new Map<string, string>();
return {
get length() {
return m.size;
},
clear: () => m.clear(),
getItem: (k: string) => m.get(k) ?? null,
key: (i: number) => [...m.keys()][i] ?? null,
removeItem: (k: string) => void m.delete(k),
setItem: (k: string, v: string) => void m.set(k, String(v)),
} as Storage;
}
describe('getLearnerKey', () => {
it('mints an anon key once and returns the same key afterwards', async () => {
const kv = new BrowserKVStore({ storage: memoryStorage() });
const first = await getLearnerKey(kv);
expect(first).toMatch(/^anon:[0-9a-f-]{36}$/);
await expect(getLearnerKey(kv)).resolves.toBe(first);
});
it('persists under the device scope, never the account scope', async () => {
const storage = memoryStorage();
const kv = new BrowserKVStore({ storage });
const key = await getLearnerKey(kv);
// BrowserKVStore encodes the scope in the storage key: `maic:<scope>:<key>`
const entries = [...Array(storage.length).keys()].map((i) => storage.key(i));
const deviceEntry = entries.find((k) => k?.includes(':device:'));
expect(deviceEntry).toContain(LEARNER_KEY_KV_KEY);
expect(storage.getItem(deviceEntry!)).toContain(key);
expect(entries.find((k) => k?.includes(':account:'))).toBeUndefined();
});
it('two different devices (stores) mint different keys', async () => {
const a = await getLearnerKey(new BrowserKVStore({ storage: memoryStorage() }));
const b = await getLearnerKey(new BrowserKVStore({ storage: memoryStorage() }));
expect(a).not.toBe(b);
});
it('concurrent calls on one device converge on a single key', async () => {
const kv = new BrowserKVStore({ storage: memoryStorage() });
const keys = await Promise.all([getLearnerKey(kv), getLearnerKey(kv), getLearnerKey(kv)]);
expect(new Set(keys).size).toBe(1);
// and later calls agree with what actually persisted
await expect(getLearnerKey(kv)).resolves.toBe(keys[0]);
});
it('returns the persisted winner when another tab wins the write race', async () => {
const winner = 'anon:11111111-1111-4111-8111-111111111111';
const m = new Map<string, string>();
// Simulate a cross-tab race: whatever this caller persists, the storage
// ends up holding another tab's later write (last write wins). The caller
// must converge on the stored value, never keep an orphaned local mint.
const storage = {
get length() {
return m.size;
},
clear: () => m.clear(),
getItem: (k: string) => m.get(k) ?? null,
key: (i: number) => [...m.keys()][i] ?? null,
removeItem: (k: string) => void m.delete(k),
setItem: (k: string) => void m.set(k, JSON.stringify(winner)),
} as Storage;
await expect(getLearnerKey(new BrowserKVStore({ storage }))).resolves.toBe(winner);
});
});
describe('getLearnerKey cross-tab locking', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('mints inside a Web Lock when navigator.locks is available', async () => {
// A serializing fake: grants run strictly one at a time, like the real
// Web Locks API does for a single lock name.
const requestedNames: string[] = [];
let chain: Promise<unknown> = Promise.resolve();
const locks = {
request: (name: string, cb: () => Promise<unknown>) => {
requestedNames.push(name);
const granted = chain.then(() => cb());
chain = granted.catch(() => undefined);
return granted;
},
};
vi.stubGlobal('navigator', { locks });
const kv = new BrowserKVStore({ storage: memoryStorage() });
const keys = await Promise.all([getLearnerKey(kv), getLearnerKey(kv), getLearnerKey(kv)]);
expect(new Set(keys).size).toBe(1);
// every miss went through the lock, under one shared name
expect(requestedNames.length).toBe(3);
expect(new Set(requestedNames).size).toBe(1);
});
it('falls back to read-after-write when navigator.locks is unavailable', async () => {
vi.stubGlobal('navigator', {}); // e.g. an old browser: no Web Locks
const kv = new BrowserKVStore({ storage: memoryStorage() });
const key = await getLearnerKey(kv);
expect(key).toMatch(/^anon:[0-9a-f-]{36}$/);
await expect(getLearnerKey(kv)).resolves.toBe(key);
});
});