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 }; } describe('DbService v2 methods', () => { 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('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'); }); }); });