mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
0daddfde00
* fix: reconcile sticky update indicators with Anatomy preview Sidebar, Updates filter, and Fleet treated retained partial/failed scanner has_update as confirmed. Keep raw state for retention/notifications, project confirmed-only to APIs, show distinct incomplete indicators, and clear sticky rows only after an authoritative-negative preview. Closes #1685 * test: align sidebar truncate E2E with failed-over-retained precedence Purple update indicators are confirmed-only; hasUpdate with a failed check correctly shows the failed trailing icon. * fix: clear confirmed update rows on authoritative-negative preview Address audit SF-1/SF-2/SF-3: observation-watermark clears for older ok+has_update rows (DB + memory gens), Fleet checkability parity with backend not_checkable, and Updates chip confirmed-only regressions. * fix: tombstone equal-generation writers on preview clear Advance the per-stack write generation when clearing at the observation watermark so a scanner reserved before preview cannot recreate the row after an authoritative-negative reconcile. * fix: clear sticky updates with digest and tag preview parity Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear. * test: set digestUpdate on auto-update checkImage mocks Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path. * fix: clear unused lint errors on sticky update branch Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green. * fix: use inflight holder for CacheService prefer-const Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
/**
|
|
* Unit tests for CacheService: TTL expiry, inflight deduplication,
|
|
* stale-on-error fallback, namespace invalidation, per-namespace stats,
|
|
* the entry-cap safety guard, and singleton identity.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
|
|
|
|
describe('CacheService', () => {
|
|
let cache: CacheService;
|
|
|
|
beforeEach(() => {
|
|
cache = CacheService.getInstance();
|
|
cache.flush();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cache.flush();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// ─── singleton ────────────────────────────────────────────────────────
|
|
|
|
describe('getInstance', () => {
|
|
it('returns the same instance across calls', () => {
|
|
const a = CacheService.getInstance();
|
|
const b = CacheService.getInstance();
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|
|
|
|
// ─── getOrFetch: cache hits and misses ────────────────────────────────
|
|
|
|
describe('getOrFetch', () => {
|
|
it('calls fetcher on first access and caches the result', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue('fresh-value');
|
|
const result = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
expect(result).toBe('fresh-value');
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('returns cached value on subsequent access without calling fetcher', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue('cached');
|
|
await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
const second = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
expect(second).toBe('cached');
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refetches after TTL expiry', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce('v1')
|
|
.mockResolvedValueOnce('v2');
|
|
|
|
const first = await cache.getOrFetch('ns:key', 1_000, fetcher);
|
|
expect(first).toBe('v1');
|
|
|
|
await vi.advanceTimersByTimeAsync(1_100);
|
|
|
|
const second = await cache.getOrFetch('ns:key', 1_000, fetcher);
|
|
expect(second).toBe('v2');
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('supports different value types', async () => {
|
|
const obj = { a: 1, b: [2, 3] };
|
|
await cache.getOrFetch('obj:key', 60_000, async () => obj);
|
|
const out = await cache.getOrFetch<typeof obj>('obj:key', 60_000, async () => ({ a: 99, b: [] }));
|
|
expect(out).toEqual(obj);
|
|
});
|
|
});
|
|
|
|
// ─── getOrFetchWithMeta: observational outcomes ──────────────────────
|
|
|
|
describe('getOrFetchWithMeta', () => {
|
|
it('reports "computed" when this caller runs the fetcher', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue('fresh');
|
|
const res = await cache.getOrFetchWithMeta('ns:key', 60_000, fetcher);
|
|
expect(res).toEqual({ value: 'fresh', outcome: 'computed' });
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
expect(cache.getStats().ns).toEqual({ hits: 0, misses: 1, stale: 0, size: 1 });
|
|
});
|
|
|
|
it('reports "hit" for a fresh entry without waiting or re-running the fetcher', async () => {
|
|
await cache.getOrFetchWithMeta('ns:key', 60_000, async () => 'cached');
|
|
const fetcher = vi.fn().mockResolvedValue('should-not-run');
|
|
const res = await cache.getOrFetchWithMeta('ns:key', 60_000, fetcher);
|
|
expect(res).toEqual({ value: 'cached', outcome: 'hit' });
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
// First call: 1 miss (computed). Second call: 1 hit.
|
|
expect(cache.getStats().ns).toMatchObject({ hits: 1, misses: 1, stale: 0 });
|
|
});
|
|
|
|
it('reports "inflight" for a caller that joins an existing in-flight promise', async () => {
|
|
let resolveFetch!: (value: string) => void;
|
|
const fetcher = vi.fn(() => new Promise<string>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const p1 = cache.getOrFetchWithMeta('ns:key', 60_000, fetcher);
|
|
const p2 = cache.getOrFetchWithMeta('ns:key', 60_000, fetcher);
|
|
const p3 = cache.getOrFetchWithMeta('ns:key', 60_000, fetcher);
|
|
|
|
resolveFetch('shared');
|
|
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
|
|
|
expect(r1).toEqual({ value: 'shared', outcome: 'computed' });
|
|
expect(r2).toEqual({ value: 'shared', outcome: 'inflight' });
|
|
expect(r3).toEqual({ value: 'shared', outcome: 'inflight' });
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
// Every caller that did not hit a fresh entry recorded exactly one miss,
|
|
// including the two in-flight joins: 3 misses, no hits.
|
|
expect(cache.getStats().ns).toMatchObject({ hits: 0, misses: 3, stale: 0 });
|
|
});
|
|
|
|
it('reports "stale" when the fetcher rejects but a stale entry exists', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce('original')
|
|
.mockRejectedValueOnce(new Error('upstream down'));
|
|
|
|
const first = await cache.getOrFetchWithMeta('ns:key', 1_000, fetcher);
|
|
expect(first).toEqual({ value: 'original', outcome: 'computed' });
|
|
|
|
await vi.advanceTimersByTimeAsync(1_100);
|
|
|
|
const stale = await cache.getOrFetchWithMeta('ns:key', 1_000, fetcher);
|
|
expect(stale).toEqual({ value: 'original', outcome: 'stale' });
|
|
expect(cache.getStats().ns).toMatchObject({ stale: 1 });
|
|
});
|
|
|
|
it('propagates the error (no outcome) when the fetcher rejects with no stale entry', async () => {
|
|
const fetcher = vi.fn().mockRejectedValue(new Error('no fallback'));
|
|
await expect(cache.getOrFetchWithMeta('ns:key', 60_000, fetcher)).rejects.toThrow('no fallback');
|
|
});
|
|
|
|
it('narrows to the CacheFetchOutcome union', async () => {
|
|
const { outcome } = await cache.getOrFetchWithMeta('ns:key', 60_000, async () => 1);
|
|
const accepted: CacheFetchOutcome[] = ['hit', 'computed', 'inflight', 'stale'];
|
|
expect(accepted).toContain(outcome);
|
|
});
|
|
});
|
|
|
|
// ─── getOrFetch delegates to getOrFetchWithMeta (parity) ─────────────
|
|
|
|
describe('getOrFetch parity with getOrFetchWithMeta', () => {
|
|
it('returns only the value and records identical hit/miss counters', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue('v');
|
|
const first = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
const second = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
expect(first).toBe('v');
|
|
expect(second).toBe('v');
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
// One miss (compute) + one hit, matching the meta variant's accounting.
|
|
expect(cache.getStats().ns).toMatchObject({ hits: 1, misses: 1, stale: 0 });
|
|
});
|
|
|
|
it('records one miss per in-flight join, exactly as the meta variant', async () => {
|
|
let resolveFetch!: (value: string) => void;
|
|
const fetcher = vi.fn(() => new Promise<string>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const p1 = cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
const p2 = cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
resolveFetch('shared');
|
|
await Promise.all([p1, p2]);
|
|
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
expect(cache.getStats().ns).toMatchObject({ hits: 0, misses: 2, stale: 0 });
|
|
});
|
|
});
|
|
|
|
// ─── inflight deduplication ──────────────────────────────────────────
|
|
|
|
describe('inflight deduplication', () => {
|
|
it('deduplicates concurrent getOrFetch calls for the same key', async () => {
|
|
let resolveFetch!: (value: string) => void;
|
|
const fetcher = vi.fn(() => new Promise<string>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const p1 = cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
const p2 = cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
const p3 = cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
|
|
resolveFetch('shared');
|
|
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
|
|
|
expect(r1).toBe('shared');
|
|
expect(r2).toBe('shared');
|
|
expect(r3).toBe('shared');
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not deduplicate across different keys', async () => {
|
|
const fetcher = vi.fn(async (v: string) => v);
|
|
await Promise.all([
|
|
cache.getOrFetch('ns:a', 60_000, () => fetcher('a')),
|
|
cache.getOrFetch('ns:b', 60_000, () => fetcher('b')),
|
|
]);
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('clears inflight after successful fetch so next miss can refetch', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce('v1')
|
|
.mockResolvedValueOnce('v2');
|
|
|
|
await cache.getOrFetch('ns:key', 500, fetcher);
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
const again = await cache.getOrFetch('ns:key', 500, fetcher);
|
|
|
|
expect(again).toBe('v2');
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('clears inflight after rejection so a later call can retry', async () => {
|
|
const fetcher = vi.fn()
|
|
.mockRejectedValueOnce(new Error('boom'))
|
|
.mockResolvedValueOnce('recovered');
|
|
|
|
await expect(cache.getOrFetch('ns:key', 60_000, fetcher)).rejects.toThrow('boom');
|
|
const second = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
expect(second).toBe('recovered');
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// ─── stale-on-error fallback ─────────────────────────────────────────
|
|
|
|
describe('stale-on-error fallback', () => {
|
|
it('returns stale value when fetcher rejects after the entry expires', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce('original')
|
|
.mockRejectedValueOnce(new Error('upstream down'));
|
|
|
|
const fresh = await cache.getOrFetch('ns:key', 1_000, fetcher);
|
|
expect(fresh).toBe('original');
|
|
|
|
await vi.advanceTimersByTimeAsync(1_100);
|
|
|
|
const stale = await cache.getOrFetch('ns:key', 1_000, fetcher);
|
|
expect(stale).toBe('original');
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.stale).toBe(1);
|
|
});
|
|
|
|
it('propagates error when no stale entry exists', async () => {
|
|
const fetcher = vi.fn().mockRejectedValue(new Error('no fallback'));
|
|
await expect(cache.getOrFetch('ns:key', 60_000, fetcher)).rejects.toThrow('no fallback');
|
|
});
|
|
});
|
|
|
|
// ─── synchronous get ─────────────────────────────────────────────────
|
|
|
|
describe('get', () => {
|
|
it('returns undefined for missing keys and records a miss', () => {
|
|
expect(cache.get('ns:missing')).toBeUndefined();
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.misses).toBe(1);
|
|
});
|
|
|
|
it('returns the cached value and records a hit', () => {
|
|
cache.set('ns:key', 42, 60_000);
|
|
expect(cache.get<number>('ns:key')).toBe(42);
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.hits).toBe(1);
|
|
});
|
|
|
|
it('returns undefined and records a miss when the entry is expired', () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
cache.set('ns:key', 'v', 1_000);
|
|
vi.advanceTimersByTime(1_500);
|
|
expect(cache.get('ns:key')).toBeUndefined();
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.misses).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── invalidate ───────────────────────────────────────────────────────
|
|
|
|
describe('invalidate', () => {
|
|
it('removes a single key', async () => {
|
|
await cache.getOrFetch('ns:key', 60_000, async () => 'v');
|
|
cache.invalidate('ns:key');
|
|
const fetcher = vi.fn().mockResolvedValue('refetched');
|
|
const out = await cache.getOrFetch('ns:key', 60_000, fetcher);
|
|
expect(out).toBe('refetched');
|
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('is a no-op for missing keys', () => {
|
|
expect(() => cache.invalidate('ns:missing')).not.toThrow();
|
|
});
|
|
|
|
it('prevents an in-flight fetch started before invalidate from committing afterward', async () => {
|
|
let resolveFetch!: (value: string) => void;
|
|
const staleFetcher = vi.fn(() => new Promise<string>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}));
|
|
|
|
const stalePromise = cache.getOrFetch('fleet-updates', 60_000, staleFetcher);
|
|
cache.invalidate('fleet-updates');
|
|
|
|
const freshFetcher = vi.fn().mockResolvedValue('fresh');
|
|
const fresh = await cache.getOrFetch('fleet-updates', 60_000, freshFetcher);
|
|
expect(fresh).toBe('fresh');
|
|
expect(freshFetcher).toHaveBeenCalledTimes(1);
|
|
|
|
resolveFetch('stale');
|
|
await expect(stalePromise).resolves.toBe('stale');
|
|
// Stale writer must not overwrite the post-invalidate entry.
|
|
expect(cache.get<string>('fleet-updates')).toBe('fresh');
|
|
});
|
|
});
|
|
|
|
describe('invalidateNamespace', () => {
|
|
it('removes every key within the namespace', async () => {
|
|
await cache.getOrFetch('stats:1', 60_000, async () => 'a');
|
|
await cache.getOrFetch('stats:2', 60_000, async () => 'b');
|
|
await cache.getOrFetch('other:1', 60_000, async () => 'c');
|
|
|
|
cache.invalidateNamespace('stats');
|
|
|
|
const f1 = vi.fn().mockResolvedValue('refetched-1');
|
|
const f2 = vi.fn().mockResolvedValue('refetched-2');
|
|
const f3 = vi.fn().mockResolvedValue('kept-c');
|
|
|
|
expect(await cache.getOrFetch('stats:1', 60_000, f1)).toBe('refetched-1');
|
|
expect(await cache.getOrFetch('stats:2', 60_000, f2)).toBe('refetched-2');
|
|
expect(await cache.getOrFetch('other:1', 60_000, f3)).toBe('c');
|
|
expect(f3).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not remove keys with similar but distinct namespaces', async () => {
|
|
await cache.getOrFetch('stats:1', 60_000, async () => 'a');
|
|
await cache.getOrFetch('statsbar:1', 60_000, async () => 'b');
|
|
|
|
cache.invalidateNamespace('stats');
|
|
|
|
const f = vi.fn().mockResolvedValue('new');
|
|
expect(await cache.getOrFetch('statsbar:1', 60_000, f)).toBe('b');
|
|
expect(f).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('removes a namespace-only key (no colon suffix)', async () => {
|
|
await cache.getOrFetch('singleton', 60_000, async () => 'one');
|
|
cache.invalidateNamespace('singleton');
|
|
const f = vi.fn().mockResolvedValue('two');
|
|
expect(await cache.getOrFetch('singleton', 60_000, f)).toBe('two');
|
|
});
|
|
});
|
|
|
|
// ─── stats ────────────────────────────────────────────────────────────
|
|
|
|
describe('getStats', () => {
|
|
it('counts hits and misses per namespace', async () => {
|
|
await cache.getOrFetch('stats:1', 60_000, async () => 'a'); // miss
|
|
await cache.getOrFetch('stats:1', 60_000, async () => 'x'); // hit
|
|
await cache.getOrFetch('stats:2', 60_000, async () => 'b'); // miss
|
|
await cache.getOrFetch('other:1', 60_000, async () => 'c'); // miss
|
|
|
|
const stats = cache.getStats();
|
|
expect(stats.stats).toEqual({ hits: 1, misses: 2, stale: 0, size: 2 });
|
|
expect(stats.other).toEqual({ hits: 0, misses: 1, stale: 0, size: 1 });
|
|
});
|
|
|
|
it('only counts live (non-expired) entries in size', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
cache.set('ns:a', 1, 1_000);
|
|
cache.set('ns:b', 2, 10_000);
|
|
vi.advanceTimersByTime(2_000);
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.size).toBe(1);
|
|
});
|
|
|
|
it('returns an empty object after flush', () => {
|
|
cache.set('ns:a', 1, 60_000);
|
|
cache.flush();
|
|
expect(cache.getStats()).toEqual({});
|
|
});
|
|
});
|
|
|
|
// ─── flush ────────────────────────────────────────────────────────────
|
|
|
|
describe('flush', () => {
|
|
it('clears store, inflight, and stats', async () => {
|
|
await cache.getOrFetch('ns:a', 60_000, async () => 1);
|
|
cache.flush();
|
|
expect(cache.get('ns:a')).toBeUndefined();
|
|
// Reset stats count: after flush the previous miss counter is gone,
|
|
// the single get() call above registers one new miss.
|
|
const stats = cache.getStats();
|
|
expect(stats.ns?.misses).toBe(1);
|
|
expect(stats.ns?.hits).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── MAX_ENTRIES safety cap ──────────────────────────────────────────
|
|
|
|
describe('entry cap safety guard', () => {
|
|
it('refuses new entries once the cap is reached and no expired rows exist', () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
// Fill up to the cap with long-lived entries.
|
|
for (let i = 0; i < 1000; i++) {
|
|
cache.set(`bulk:${i}`, i, 3_600_000);
|
|
}
|
|
// Cap reached with all entries still live: insertion is rejected.
|
|
cache.set('bulk:overflow', 'x', 60_000);
|
|
expect(cache.get('bulk:overflow')).toBeUndefined();
|
|
expect(warn).toHaveBeenCalled();
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('purges expired entries to make room for new ones', () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
|
// Fill with short-lived entries.
|
|
for (let i = 0; i < 1000; i++) {
|
|
cache.set(`bulk:${i}`, i, 500);
|
|
}
|
|
// Expire them all.
|
|
vi.advanceTimersByTime(1_000);
|
|
// A new insertion should trigger a purge and succeed.
|
|
cache.set('bulk:new', 'accepted', 60_000);
|
|
expect(cache.get<string>('bulk:new')).toBe('accepted');
|
|
});
|
|
|
|
it('allows overwriting an existing key when the cap is reached', () => {
|
|
for (let i = 0; i < 1000; i++) {
|
|
cache.set(`bulk:${i}`, i, 3_600_000);
|
|
}
|
|
cache.set('bulk:5', 'updated', 60_000);
|
|
expect(cache.get<string>('bulk:5')).toBe('updated');
|
|
});
|
|
});
|
|
});
|