Files
muzick/backend/src/services/db.service.test.ts
T

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');
});
});
});