183 lines
7.4 KiB
TypeScript
183 lines
7.4 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 };
|
|
}
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|