// A1 Session 10 — getModelAggregate scoping. The public default MUST stay // `.is('user_id', null)` (the model record); the new `userId` option swaps // it for `.eq('user_id', uid)` (a user's own public-profile aggregate). // Same window, same n≥20 gate in both scopes. const ledgerService = require('../../src/services/ledgerService'); function makeSb(captured, rows) { return { from() { const b = { _filters: [] }; const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; }; b.select = () => b; b.eq = rec('eq'); b.is = rec('is'); b.not = rec('not'); b.gte = rec('gte'); b.gt = rec('gt'); b.order = () => b; b.limit = () => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 0 }); }; b.then = (resolve, reject) => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 2 }).then(resolve, reject); }; return b; }, }; } describe('getModelAggregate scoping', () => { test('default (no userId) scopes BOTH queries to the public record (user_id IS NULL)', async () => { const captured = []; const agg = await ledgerService.getModelAggregate({ sb: makeSb(captured, []) }); expect(captured.length).toBe(2); // settled + pending for (const filters of captured) { expect(filters).toContainEqual(['is', 'user_id', null]); expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false); } expect(agg.hit_pct).toBeNull(); expect(agg.min_sample).toBe(ledgerService.MIN_AGG_SAMPLE); // 2026-07 — projection<=0 grades (the degradation blast radius) are excluded // from the public record on BOTH queries (kept in the append-only ledger, // just never counted). `.gt('model_value', 0)` also drops NULL model_value. for (const filters of captured) { expect(filters).toContainEqual(['gt', 'model_value', 0]); } }); test('userId scopes BOTH queries to that user and never touches the public scope', async () => { const captured = []; await ledgerService.getModelAggregate({ sb: makeSb(captured, []), userId: 'u-42' }); expect(captured.length).toBe(2); for (const filters of captured) { expect(filters).toContainEqual(['eq', 'user_id', 'u-42']); expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id')).toBe(false); } }); test('user scope keeps the n≥20 gate — no percentage on a small sample', async () => { const rows = Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' })); const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' }); expect(agg.settled).toBe(5); expect(agg.hit_pct).toBeNull(); expect(agg.beat_close_pct).toBeNull(); }); test('user scope renders percentages at n≥20 like the public record', async () => { process.env.CLV_CAPTURE_RELIABLE = '1'; // exercise the CLV math (suppressed by default, item 7) const rows = [ ...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: 'beat', grade: 'A' })), ...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded', grade: 'B' })), ]; const agg = await ledgerService.getModelAggregate({ sb: makeSb([], rows), userId: 'u-42' }); expect(agg.settled).toBe(21); expect(agg.hit_pct).toBe(Math.round((14 / 21) * 100)); expect(agg.beat_close_pct).toBe(Math.round((14 / 21) * 100)); delete process.env.CLV_CAPTURE_RELIABLE; }); });