feat(profile): event-driven invalidation (#81 phase B.2)

Features now declare invalidatedBy subjects in the registry; the new
profile/subscriber.ts subscribes to each unique subject and drops
matching stored rows for the userId in the payload. Next getProfile
call recomputes from current data instead of waiting up to ttlSec.

Wiring:
  completion_rate_30d, dismiss_rate_30d, mean_dwell_ms_30d,
  preferred_hour  ← signals.tip.feedback
  tip_volume_30d  ← signals.tip.served

TTL stays as a safety net for clock drift and dropped events.
Registration validates each declared subject against KNOWN_SUBJECTS
(mirror of EventMap) so typos throw at startup, not silently.

ADR-0011 updated.

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:38:45 +00:00
parent 4a42a6aabf
commit ee4eb15022
5 changed files with 219 additions and 6 deletions

View File

@@ -0,0 +1,122 @@
/**
* 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', consentGiven: true, consentAt: NOW, createdAt: NOW },
{ id: 'sub-user-2', email: 'sub2@test.com', role: 'user', consentGiven: true, consentAt: NOW, 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<string[]> {
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;
}
});
});