import { describe, it, expect, vi } from 'vitest'; import { DbService } from './db.service.js'; function makeService(): { service: DbService; mockQuery: ReturnType } { const mockQuery = vi.fn(); const service = new DbService({ query: mockQuery } as any); return { service, mockQuery }; } function makeTransactionalService(): { service: DbService; poolQuery: ReturnType; clientQuery: ReturnType } { 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'); }); }); });