godcrm/backend/services/notifications/__tests__/resolveChatPrefs.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

221 lines
9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
});
});