Backfills consent_given=1 rows into user_consents as data:core before dropping the legacy columns. auth.ts now writes user_consents on signup; POST /consent writes user_consents; admin/user routes cleaned of the old fields. Migration is idempotent — DROP COLUMN is wrapped in try/catch so it no-ops on fresh DBs that never had the columns. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
/**
|
|
* 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<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;
|
|
}
|
|
});
|
|
});
|