/** * Profile event subscriber tests (#81 phase B.2). * Constructs a fresh Bus per test, subscribes the profile invalidator, * publishes events, asserts that the corresponding stored rows are deleted. */ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; import { Bus } from '../../events/bus.js'; import { makeTestDb } from '../../test/db.js'; import { users, userProfileFeatures } from '../../db/schema.js'; import { eq } from 'drizzle-orm'; const testDb = makeTestDb(); vi.mock('../../db/index.js', () => ({ db: testDb, rawSqlite: testDb.rawSqlite })); const { registerProfileSubscriptions } = await import('../subscriber.js'); const NOW = new Date().toISOString(); const STALE_BASE = { value: 0.42, valueText: null as string | null, updatedAt: NOW, }; beforeAll(async () => { await testDb.insert(users).values([ { id: 'sub-user-1', email: 'sub1@test.com', role: 'user', createdAt: NOW }, { id: 'sub-user-2', email: 'sub2@test.com', role: 'user', createdAt: NOW }, ]); }); beforeEach(async () => { await testDb.delete(userProfileFeatures); // Pre-seed all 5 features for both users so we can prove which got invalidated. for (const userId of ['sub-user-1', 'sub-user-2']) { for (const name of [ 'completion_rate_30d', 'dismiss_rate_30d', 'mean_dwell_ms_30d', 'preferred_hour', 'tip_volume_30d', ]) { await testDb.insert(userProfileFeatures).values({ userId, name, ...STALE_BASE, ttlSec: 6 * 3600, }); } } }); async function namesFor(userId: string): Promise { const rows = await testDb .select({ name: userProfileFeatures.name }) .from(userProfileFeatures) .where(eq(userProfileFeatures.userId, userId)); return rows.map((r) => r.name).sort(); } describe('registerProfileSubscriptions', () => { it('signals.tip.feedback invalidates the 4 reaction-derived features for the affected user only', async () => { const bus = new Bus(); registerProfileSubscriptions(bus); bus.publish('signals.tip.feedback', { userId: 'sub-user-1', tipId: 'tip:x', action: 'done', reward: 1, dwellMs: 60_000, createdAt: NOW, }); // user-1 should have only tip_volume_30d remaining; others wiped expect(await namesFor('sub-user-1')).toEqual(['tip_volume_30d']); // user-2 untouched expect((await namesFor('sub-user-2')).length).toBe(5); }); it('signals.tip.served invalidates only tip_volume_30d', async () => { const bus = new Bus(); registerProfileSubscriptions(bus); bus.publish('signals.tip.served', { userId: 'sub-user-1', tipId: 'tip:y', policy: 'egreedy', servedAt: NOW, }); expect(await namesFor('sub-user-1')).toEqual([ 'completion_rate_30d', 'dismiss_rate_30d', 'mean_dwell_ms_30d', 'preferred_hour', ]); }); it('events without userId in payload are ignored silently', async () => { const bus = new Bus(); registerProfileSubscriptions(bus); // emit a feedback subject with no userId (bus as unknown as { emit: (s: string, p: unknown) => void }).emit('signals.tip.feedback', {}); expect((await namesFor('sub-user-1')).length).toBe(5); expect((await namesFor('sub-user-2')).length).toBe(5); }); it('throws at registration if a registry entry references an unknown subject', async () => { // Spike a bogus subject into one feature, attempt registration, expect throw. const { FEATURES } = await import('../registry.js'); const original = FEATURES[0]?.invalidatedBy; (FEATURES[0] as { invalidatedBy: readonly string[] }).invalidatedBy = ['signals.bogus']; try { const bus = new Bus(); expect(() => registerProfileSubscriptions(bus)).toThrow(/unknown subject/); } finally { (FEATURES[0] as { invalidatedBy: readonly string[] | undefined }).invalidatedBy = original; } }); });