Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
221 lines
9 KiB
JavaScript
221 lines
9 KiB
JavaScript
// resolveChatPrefs.test.js — ADR-0064 WP-A.
|
||
//
|
||
// Covers the 4-layer hierarchy resolver:
|
||
// - Pure-merge correctness (declared-keys-win) — 16-combination matrix
|
||
// of (per-chat × personal × space × global) declared y/n.
|
||
// - DB-fetch wiring via injectable fetchers.
|
||
// - Cache TTL + scope-based invalidation.
|
||
// - pg_notify shape (mocked dbRun).
|
||
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
||
// Mock dbGet/dbRun BEFORE importing the module under test so the injected
|
||
// fetchers don't accidentally hit a real database.
|
||
const mockDbGet = vi.fn();
|
||
const mockDbRun = vi.fn();
|
||
vi.mock('../../../database/connection.js', () => ({
|
||
dbGet: (...args) => mockDbGet(...args),
|
||
dbRun: (...args) => mockDbRun(...args),
|
||
safeJsonParse: (value, defaultValue = null) => {
|
||
if (value === null || value === undefined) return defaultValue;
|
||
if (typeof value === 'object') return value;
|
||
try { return JSON.parse(value); } catch { return defaultValue; }
|
||
},
|
||
}));
|
||
|
||
import {
|
||
mergeDeclared,
|
||
resolveChatPrefs,
|
||
invalidateCache,
|
||
notifyInvalidate,
|
||
CHAT_PREFS_DEFAULTS,
|
||
__setFetchers,
|
||
__resetFetchers,
|
||
__cacheSize,
|
||
__cacheClear,
|
||
} from '../resolveChatPrefs.js';
|
||
|
||
beforeEach(() => {
|
||
__resetFetchers();
|
||
__cacheClear();
|
||
mockDbGet.mockReset();
|
||
mockDbRun.mockReset();
|
||
});
|
||
|
||
// ─── Pure merge ─────────────────────────────────────────────────────────────
|
||
|
||
describe('mergeDeclared — declared-keys-win semantics', () => {
|
||
it('innermost layer wins for declared keys', () => {
|
||
const perChat = { enabled: false };
|
||
const personal = { enabled: true, sound_enabled: true };
|
||
const space = { sound_enabled: false };
|
||
const global = { sound_volume: 0.6 };
|
||
const out = mergeDeclared(perChat, personal, space, global, CHAT_PREFS_DEFAULTS);
|
||
expect(out.enabled).toBe(false); // per-chat declared, wins
|
||
expect(out.sound_enabled).toBe(true); // personal declared, wins over space
|
||
expect(out.sound_volume).toBe(0.6); // only global declared
|
||
});
|
||
|
||
it('undeclared keys fall through; nested per-key independence', () => {
|
||
const personal = { humans: { sound: false } }; // declares humans.sound only
|
||
const space = { humans: { sound: true, popup: false } }; // declares humans.popup
|
||
const out = mergeDeclared(null, personal, space, null, CHAT_PREFS_DEFAULTS);
|
||
expect(out.humans.sound).toBe(false); // personal wins
|
||
expect(out.humans.popup).toBe(false); // space wins (personal didn't declare)
|
||
expect(out.humans.badge).toBe(true); // default
|
||
});
|
||
|
||
it('returns full defaults when all layers null', () => {
|
||
const out = mergeDeclared(null, null, null, null, CHAT_PREFS_DEFAULTS);
|
||
expect(out).toEqual(CHAT_PREFS_DEFAULTS);
|
||
});
|
||
|
||
it('null values within a layer count as undeclared (fall through)', () => {
|
||
const personal = { enabled: null }; // explicit null → undeclared
|
||
const space = { enabled: false };
|
||
const out = mergeDeclared(null, personal, space, null, CHAT_PREFS_DEFAULTS);
|
||
expect(out.enabled).toBe(false); // falls through to space layer
|
||
});
|
||
});
|
||
|
||
// ─── 16-combination matrix ─────────────────────────────────────────────────
|
||
// Each layer either DECLARES the marker key (`enabled`) with its own distinct
|
||
// value or stays absent. We expect the resolver to surface the value of the
|
||
// innermost declaring layer — or, if no layer declares it, the baked default.
|
||
|
||
describe('mergeDeclared — 16 declared y/n combinations for `enabled`', () => {
|
||
const PER_CHAT = { enabled: 'per_chat' };
|
||
const PERSONAL = { enabled: 'personal' };
|
||
const SPACE = { enabled: 'space' };
|
||
const GLOBAL = { enabled: 'global' };
|
||
|
||
// 16 rows = 2^4 — every subset of layers declares `enabled`.
|
||
const cases = [];
|
||
for (let mask = 0; mask < 16; mask++) {
|
||
const a = (mask & 0b1000) ? PER_CHAT : null;
|
||
const b = (mask & 0b0100) ? PERSONAL : null;
|
||
const c = (mask & 0b0010) ? SPACE : null;
|
||
const d = (mask & 0b0001) ? GLOBAL : null;
|
||
let expected;
|
||
if (a) expected = 'per_chat';
|
||
else if (b) expected = 'personal';
|
||
else if (c) expected = 'space';
|
||
else if (d) expected = 'global';
|
||
else expected = CHAT_PREFS_DEFAULTS.enabled; // true (baked)
|
||
cases.push({ mask, perChat: a, personal: b, space: c, global: d, expected });
|
||
}
|
||
|
||
it.each(cases)('mask=$mask → enabled=$expected', ({ perChat, personal, space, global, expected }) => {
|
||
const out = mergeDeclared(perChat, personal, space, global, CHAT_PREFS_DEFAULTS);
|
||
expect(out.enabled).toBe(expected);
|
||
});
|
||
|
||
it('matrix is exhaustive', () => {
|
||
expect(cases).toHaveLength(16);
|
||
});
|
||
});
|
||
|
||
// ─── Resolver with injected fetchers ───────────────────────────────────────
|
||
|
||
describe('resolveChatPrefs — DB integration via injected fetchers', () => {
|
||
it('walks all 4 layers and applies defaults', async () => {
|
||
__setFetchers({
|
||
perChat: vi.fn().mockResolvedValue({ enabled: false }),
|
||
personal: vi.fn().mockResolvedValue(null),
|
||
space: vi.fn().mockResolvedValue({ sound_volume: 0.3 }),
|
||
global: vi.fn().mockResolvedValue({ humans: { sound: false } }),
|
||
});
|
||
const out = await resolveChatPrefs({ userId: 7, conversationId: 42 });
|
||
expect(out.enabled).toBe(false); // per-chat
|
||
expect(out.sound_enabled).toBe(true); // default
|
||
expect(out.sound_volume).toBe(0.3); // space
|
||
expect(out.humans.sound).toBe(false); // global
|
||
expect(out.humans.popup).toBe(true); // default
|
||
expect(out.agents.sound).toBe(true); // default
|
||
});
|
||
|
||
it('caches on second call', async () => {
|
||
const perChat = vi.fn().mockResolvedValue(null);
|
||
const personal = vi.fn().mockResolvedValue({ enabled: true });
|
||
const space = vi.fn().mockResolvedValue(null);
|
||
const global = vi.fn().mockResolvedValue(null);
|
||
__setFetchers({ perChat, personal, space, global });
|
||
|
||
const a = await resolveChatPrefs({ userId: 1, conversationId: 10 });
|
||
const b = await resolveChatPrefs({ userId: 1, conversationId: 10 });
|
||
expect(a).toBe(b); // identity — same cached object
|
||
expect(personal).toHaveBeenCalledTimes(1);
|
||
expect(__cacheSize()).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ─── Cache invalidation scopes ─────────────────────────────────────────────
|
||
|
||
describe('invalidateCache — scope-based eviction', () => {
|
||
async function seedCache() {
|
||
__setFetchers({
|
||
perChat: vi.fn().mockResolvedValue(null),
|
||
personal: vi.fn().mockResolvedValue(null),
|
||
space: vi.fn().mockResolvedValue(null),
|
||
global: vi.fn().mockResolvedValue(null),
|
||
});
|
||
await resolveChatPrefs({ userId: 1, conversationId: 100 });
|
||
await resolveChatPrefs({ userId: 1, conversationId: 200 });
|
||
await resolveChatPrefs({ userId: 2, conversationId: 100 });
|
||
await resolveChatPrefs({ userId: 2, conversationId: 300 });
|
||
}
|
||
|
||
it('user_id evicts all entries for that user', async () => {
|
||
await seedCache();
|
||
expect(__cacheSize()).toBe(4);
|
||
invalidateCache({ user_id: 1 });
|
||
expect(__cacheSize()).toBe(2);
|
||
});
|
||
|
||
it('conversation_id evicts all entries for that conv', async () => {
|
||
await seedCache();
|
||
invalidateCache({ conversation_id: 100 });
|
||
expect(__cacheSize()).toBe(2); // (1,200) and (2,300) survive
|
||
});
|
||
|
||
it('space_id clears the entire cache (no in-process userId→space map)', async () => {
|
||
await seedCache();
|
||
invalidateCache({ space_id: 11 });
|
||
expect(__cacheSize()).toBe(0);
|
||
});
|
||
|
||
it('empty scope clears everything', async () => {
|
||
await seedCache();
|
||
invalidateCache({});
|
||
expect(__cacheSize()).toBe(0);
|
||
});
|
||
});
|
||
|
||
// ─── pg_notify wrapper ─────────────────────────────────────────────────────
|
||
|
||
describe('notifyInvalidate — fires pg_notify with stringified scope', () => {
|
||
it('writes the right channel + payload', async () => {
|
||
mockDbRun.mockResolvedValue();
|
||
await notifyInvalidate({ user_id: 9, conversation_id: 123 });
|
||
expect(mockDbRun).toHaveBeenCalledTimes(1);
|
||
const [sql, params] = mockDbRun.mock.calls[0];
|
||
expect(sql).toMatch(/pg_notify/);
|
||
expect(params[0]).toBe('chat_prefs_invalidate');
|
||
expect(JSON.parse(params[1])).toEqual({ user_id: 9, conversation_id: 123 });
|
||
});
|
||
|
||
it('falls back to local cache eviction on dbRun failure', async () => {
|
||
__setFetchers({
|
||
perChat: vi.fn().mockResolvedValue(null),
|
||
personal: vi.fn().mockResolvedValue(null),
|
||
space: vi.fn().mockResolvedValue(null),
|
||
global: vi.fn().mockResolvedValue(null),
|
||
});
|
||
await resolveChatPrefs({ userId: 5, conversationId: 50 });
|
||
expect(__cacheSize()).toBe(1);
|
||
|
||
mockDbRun.mockRejectedValue(new Error('db down'));
|
||
await notifyInvalidate({ user_id: 5 });
|
||
expect(__cacheSize()).toBe(0);
|
||
});
|
||
});
|