Files
muzick/backend/src/services/db.service.test.ts
T
kami 9eba247a58
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled
refactor(vibe): simplify durable session flow
2026-08-03 12:44:08 +04:00

846 lines
42 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { DbService } from './db.service.js';
function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn> } {
const mockQuery = vi.fn();
const service = new DbService({ query: mockQuery } as any);
return { service, mockQuery };
}
function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
const poolQuery = vi.fn();
const clientQuery = vi.fn();
const service = new DbService({
query: poolQuery,
connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
} as any);
return { service, poolQuery, clientQuery };
}
describe('DbService v2 methods', () => {
describe('durable Vibe sessions', () => {
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
type: 'completed', occurred_at: new Date(), client_event_id: null,
position_ms: null, duration_ms: null, payload: {},
} as any;
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
.mockResolvedValueOnce({ rows: [] }) // no audio targets
.mockResolvedValueOnce({ rows: [{
exploration_coefficient: 0.36, discovery_radius: 0.434,
goals: { type: 'familiar', target: 1, progress: 1 },
}] })
.mockResolvedValueOnce({ rows: [] }) // session_state projection
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
.mockResolvedValueOnce({ rows: [] }) // marker conflict
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
await service.projectVibeSessionFeedback(event);
await service.projectVibeSessionFeedback(event);
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history');
expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence');
expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient');
expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END');
expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
});
it('creates, reads, and ends sessions scoped to their user', async () => {
const { service, poolQuery, clientQuery } = makeTransactionalService();
const session = {
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
context: { activity: 'focus' }, policy_version: 'v2.1',
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
poolQuery
.mockResolvedValueOnce({ rows: [session] })
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session);
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
'user-1', null, expect.any(String), 'v2.1',
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]));
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({});
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
});
it('starts sessions without inventing client context', async () => {
const { service, clientQuery } = makeTransactionalService();
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' });
const insertParameters = clientQuery.mock.calls[3][1];
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
expect(JSON.parse(insertParameters[2])).toEqual({});
expect(insertParameters.slice(3)).toEqual([
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]);
});
it('replaces an owned active session and writes its terminal event before starting another', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [replacement] }) // new session
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
.resolves.toEqual(replacement);
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
});
it('records retry-safe events and reports whether the event was inserted', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
.mockResolvedValueOnce({ rows: [event] }) // existing retry
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
payload: { reason: 'next' },
});
expect(result.inserted).toBe(false);
expect(result.event.id).toBe('event-1');
const [sql, values] = clientQuery.mock.calls[1];
expect(sql).toContain('FOR UPDATE');
expect(values).toEqual([
'session-1', 'user-1',
]);
expect(clientQuery.mock.calls).toHaveLength(5);
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('UPDATE vibe_sessions')
);
});
it('records a non-material event without mutating session context', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { source: 'player' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [event] }) // insert
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' },
});
const values = clientQuery.mock.calls[2][1];
const storedPayload = JSON.parse(values[8]);
expect(storedPayload).toEqual({ source: 'player' });
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
expect.stringContaining('SET context = $3::jsonb'),
);
});
it('does not apply a retry body to an existing event', async () => {
const { service, clientQuery } = makeTransactionalService();
const canonicalEvent = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { source: 'player' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress',
payload: { source: 'retry' },
});
expect(result).toEqual({ event: canonicalEvent, inserted: false });
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
expect.stringContaining('SET context = $3::jsonb'),
);
});
it('projects material feedback once with the durable event transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
.mockResolvedValueOnce({ rows: [event] }) // event insert
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
.mockResolvedValueOnce({ rows: [] }) // play history
.mockResolvedValueOnce({ rows: [] }) // track counter
.mockResolvedValueOnce({ rows: [] }) // session timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
type: 'completed', trackId: 'track-1',
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
expect.stringContaining('vibe_event_projections'),
expect.stringContaining('INSERT INTO play_history'),
]));
expect(evidence).toHaveBeenCalledTimes(1);
});
it('rejects an event when no owned session is returned', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // session lookup
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'other-user', type: 'completed',
})).rejects.toThrow('not found or is not owned');
});
it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [event] })
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [] }) // no matching retry
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
})).resolves.toEqual({ event, inserted: false });
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
})).rejects.toThrow('Cannot record a new event for ended Vibe session');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('INSERT INTO vibe_events')
);
});
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
const ended = { ...active, status: 'ended', ended_at: new Date() };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [active] }) // lock
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [ended] }) // status transition
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
.resolves.toEqual({ session: ended, ended: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
});
it('resumes an owned session once and records session_resumed in the same lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
.mockResolvedValueOnce({ rows: [] }) // no old resume event
.mockResolvedValueOnce({ rows: [active] }) // activate
.mockResolvedValueOnce({ rows: [] }) // ledger event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.resumeVibeSession('session-1', 'user-1'))
.resolves.toEqual({ session: active, resumed: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
});
});
describe('durable Vibe plans', () => {
it('publishes a revision and its plan_published event in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }) // plan_published
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
});
it('rolls back the plan header and items if writing plan_published fails', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockRejectedValueOnce(new Error('ledger write failed'))
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).rejects.toThrow('ledger write failed');
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
});
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
});
it('reads every durable session track as a replacement-plan exclusion', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
.resolves.toEqual(['served', 'skipped', 'disliked']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
});
it('writes a header and all items in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
}] })
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const plan = await service.persistVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
items: [{
ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
}],
});
expect(plan.items[0].plan_version_id).toBe('plan-1');
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
});
it('serves and commits one next item under the session lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [item] }) // commit item
.mockResolvedValueOnce({ rows: [] }) // track_served event
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
});
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item: null, stale: true });
expect(clientQuery.mock.calls).toHaveLength(4);
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('returns the original item when a version-aware next request is retried', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
.mockResolvedValueOnce({ rows: [item] }) // prior served item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = {
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
.mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor
.mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event
.mockResolvedValueOnce({ rows: [replacement] }) // commit replacement
.mockResolvedValueOnce({ rows: [] }) // replacement track_served event
.mockResolvedValueOnce({ rows: [] }) // playback_error result payload
.mockResolvedValueOnce({ rows: [] }) // session timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).resolves.toEqual({ item: replacement, stale: false });
expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'");
expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true');
expect(clientQuery.mock.calls[7][0]).toContain("'track_served'");
expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload');
});
it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
// The lower-ordinal event can have a lexically greater UUID at the
// same occurred_at. The cursor must still be the highest immutable
// plan ordinal, never whichever UUID sorts last.
.mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).rejects.toThrow('not the current served cursor');
expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items');
expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC');
expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('retries an unplayable advancement with the same event id without consuming another item', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = {
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: {
planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1',
advancedTo: { planVersionId: 'plan-1', ordinal: 1 },
} }] }) // prior explicit advancement
.mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).resolves.toEqual({ item: replacement, stale: false });
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
expect(clientQuery.mock.calls).toHaveLength(6);
});
it('reads the latest revision and reconstructs ordered plan items', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
explanation: [{ because: 'similar artist' }], committed: true,
}, {
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
explanation: [], committed: false,
}] });
const plan = await service.getVibePlan('session-1', 'user-1');
expect(plan?.version).toBe(2);
expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
});
});
describe('createAlbum', () => {
it('persists the canonical release date instead of discarding it', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'album-1' }] });
await service.createAlbum({
id: 'album-1', artist_id: 'artist-1', title: 'Album',
year: 2026, release_date: '2026-07-15', artwork_id: null,
});
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('release_date');
expect(params[3]).toBe('2026-07-15');
});
});
describe('upsertClaim', () => {
it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'claim-1' }] });
const id = await service.upsertClaim({
subject_type: 'track',
subject_id: 'track-1',
predicate: 'credited_main_on',
object_type: 'artist',
object_id: 'artist-1',
source: 'mb',
confidence: 1.0,
});
expect(id).toBe('claim-1');
expect(mockQuery).toHaveBeenCalledTimes(1);
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO claims');
expect(sql).toContain('ON CONFLICT');
expect(params).toContain('track');
expect(params).toContain('track-1');
expect(params).toContain('credited_main_on');
});
it('handles user_id null for objective claims', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'c1' }] });
await service.upsertClaim({
subject_type: 'artist', subject_id: 'a1', predicate: 'alias_of',
object_type: 'artist', object_id: 'a2', source: 'listener_behavior',
user_id: 'user-1',
});
const params = mockQuery.mock.calls[0][1];
expect(params[0]).toBe('user-1');
});
});
describe('getClaimsBySubject', () => {
it('filters by subject type and id', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [] });
await service.getClaimsBySubject('track', 'track-1');
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('subject_type = $1');
expect(sql).toContain('subject_id = $2');
expect(params).toEqual(['track', 'track-1']);
});
it('optionally filters by predicate and user_id', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [] });
await service.getClaimsBySubject('artist', 'a1', 'alias_of', 'user-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('predicate');
expect(sql).toContain('user_id IS NULL');
});
});
describe('recordEvidence', () => {
it('appends evidence row', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'ev-1' }] });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
signal: 'playback_completed', profile: 'longterm', weight: 0.10,
});
expect(id).toBe('ev-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO evidence');
});
});
describe('updateListenerBelief', () => {
it('UPSERTs with delta formula', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rowCount: 1 });
await service.updateListenerBelief({
user_id: 'user-1', profile: 'longterm',
entity_type: 'track', entity_id: 'track-1',
dimension: 'affinity', value_delta: 0.10,
});
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('INSERT INTO listener_beliefs');
expect(sql).toContain('ON CONFLICT');
expect(sql).toContain('GREATEST(-1.0, LEAST(1.0');
});
});
describe('recordEvidence → belief derivation wiring', () => {
it('derives a fresh longterm affinity belief after playback_completed', async () => {
const { service, mockQuery } = makeService();
// first call: INSERT evidence → returns id; second call: UPSERT belief → rowCount 1
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-1' }] })
.mockResolvedValueOnce({ rowCount: 1 });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-1',
signal: 'playback_completed', profile: 'longterm', weight: 0.10,
});
expect(id).toBe('ev-1');
expect(mockQuery).toHaveBeenCalledTimes(2);
// First call INSERTs the evidence row.
const [evidSql, evidParams] = mockQuery.mock.calls[0];
expect(evidSql).toContain('INSERT INTO evidence');
expect(evidParams[4]).toBe('longterm'); // profile
expect(evidParams[5]).toBe(0.10); // weight
// Second call UPSERTs the matching listener_belief. For a fresh
// belief the INSERT path sets value = weight directly (per spec §B.4
// INSERT branch), so the resulting row has value=0.10, confidence=0.05.
const [beliefSql, beliefParams] = mockQuery.mock.calls[1];
expect(beliefSql).toContain('INSERT INTO listener_beliefs');
expect(beliefSql).toContain('ON CONFLICT');
// [user_id, profile, entity_type, entity_id, dimension, value_delta, confidence_delta]
expect(beliefParams[0]).toBe('user-1');
expect(beliefParams[1]).toBe('longterm');
expect(beliefParams[2]).toBe('track');
expect(beliefParams[3]).toBe('track-1');
expect(beliefParams[4]).toBe('affinity');
expect(beliefParams[5]).toBe(0.10); // value_delta = weight
expect(beliefParams[6]).toBe(0.05); // confidence_delta default
});
it('maps play_of_never_seen to the novelty_tolerance dimension', async () => {
const { service, mockQuery } = makeService();
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-2' }] })
.mockResolvedValueOnce({ rowCount: 1 });
await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-2',
signal: 'play_of_never_seen', profile: 'discovery', weight: 0.05,
});
const beliefParams = mockQuery.mock.calls[1][1] as unknown[];
expect(beliefParams[4]).toBe('novelty_tolerance');
expect(beliefParams[1]).toBe('discovery');
expect(beliefParams[5]).toBe(0.05);
});
it('still appends an evidence row before deriving the belief', async () => {
const { service, mockQuery } = makeService();
mockQuery
.mockResolvedValueOnce({ rows: [{ id: 'ev-3' }] })
.mockResolvedValueOnce({ rowCount: 1 });
const id = await service.recordEvidence({
user_id: 'user-1', entity_type: 'track', entity_id: 'track-3',
signal: 'skip_quick', profile: 'negative', weight: -0.20,
});
expect(id).toBe('ev-3');
expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO evidence');
expect(mockQuery.mock.calls[1][0]).toContain('listener_beliefs');
expect(mockQuery.mock.calls[1][1][4]).toBe('affinity');
});
});
describe('track evidence propagation', () => {
it('projects a favourite onto artist, genre, and present audio dimensions', async () => {
const { service, mockQuery } = makeService();
let evidenceNumber = 0;
mockQuery.mockImplementation((sql: string) => {
if (sql.includes('INSERT INTO evidence')) return Promise.resolve({ rows: [{ id: `ev-${++evidenceNumber}` }] });
if (sql.includes('WITH artist_ids')) {
return Promise.resolve({ rows: [
{ entity_type: 'artist', entity_id: 'artist-1' },
{ entity_type: 'genre', entity_id: 'genre-1' },
] });
}
if (sql.includes('SELECT energy, bpm, valence')) {
return Promise.resolve({ rows: [{ energy: 0.81, bpm: 128, valence: 0.22 }] });
}
return Promise.resolve({ rowCount: 1, rows: [] });
});
await service.recordTrackEvidence({
user_id: 'user-1', track_id: 'track-1', signal: 'add_to_favorites',
profile: 'longterm', weight: 0.60,
});
const evidenceWrites = mockQuery.mock.calls
.filter(([sql]) => (sql as string).includes('INSERT INTO evidence'))
.map(([, params]) => params as unknown[]);
expect(evidenceWrites.map(params => params[1])).toEqual([
'track', 'artist', 'genre', 'audio', 'audio', 'audio',
]);
const beliefWrites = mockQuery.mock.calls
.filter(([sql]) => (sql as string).includes('INSERT INTO listener_beliefs'))
.map(([, params]) => params as unknown[]);
const artistBelief = beliefWrites.find(params => params[2] === 'artist');
const genreBelief = beliefWrites.find(params => params[2] === 'genre');
expect(artistBelief?.[3]).toBe('artist-1');
expect(artistBelief?.[5]).toBeCloseTo(0.54); // one favourite is a usable comfort signal
expect(genreBelief?.[5]).toBeCloseTo(0.27);
expect(beliefWrites.filter(params => params[2] === 'audio')).toHaveLength(3);
});
it('rebuilds shared beliefs from local completed plays and feedback without appending evidence', async () => {
const clientQuery = vi.fn((sql: string) => {
if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return Promise.resolve({ rows: [] });
if (sql.includes('DELETE FROM listener_beliefs')) return Promise.resolve({ rowCount: 0, rows: [] });
if (sql.includes('FROM play_history ph')) {
return Promise.resolve({ rows: [{
track_id: 'track-1', signal: 'playback_completed', profile: 'longterm', weight: 0.10,
}] });
}
if (sql.includes('WITH artist_ids')) return Promise.resolve({ rows: [{ entity_type: 'artist', entity_id: 'artist-1' }] });
if (sql.includes('SELECT energy, bpm, valence')) return Promise.resolve({ rows: [] });
return Promise.resolve({ rowCount: 1, rows: [] });
});
const connect = vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() });
const service = new DbService({ query: vi.fn(), connect } as any);
const result = await service.rebuildDerivedListenerBeliefs('user-1');
expect(result).toEqual({ interactions: 1, beliefs: 1 });
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('FROM feedback f'))).toBe(true);
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('INSERT INTO evidence'))).toBe(false);
const beliefCall = (clientQuery.mock.calls as unknown as Array<[string, unknown[]]>).
find(([sql]) => sql.includes('INSERT INTO listener_beliefs'));
const beliefParams = beliefCall?.[1] ?? [];
expect(beliefParams.slice(1, 5)).toEqual(['longterm', 'artist', 'artist-1', 'affinity']);
});
});
describe('getFusedTrackArtists', () => {
it('reads from claim_fusion view', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'a1', name: 'Artist 1', role: 'main', confidence: 0.9 }] });
const result = await service.getFusedTrackArtists('track-1');
const [sql] = mockQuery.mock.calls[0];
expect(sql).toContain('claim_fusion');
expect(result).toHaveLength(1);
expect(result[0].role).toBe('main');
});
});
});