import { beforeEach, describe, expect, it, vi } from 'vitest'; const { poolQueryMock, embedMock, chatMock } = vi.hoisted(() => ({ poolQueryMock: vi.fn(), embedMock: vi.fn(), chatMock: vi.fn(), })); vi.mock('../../src/infra/database/database.manager', () => ({ DatabaseManager: { getInstance: vi.fn(() => ({ getPool: vi.fn(() => ({ query: poolQueryMock })) })), }, })); vi.mock('../../src/services/ai/embedding.service', () => ({ EmbeddingService: { getInstance: vi.fn(() => ({ createEmbeddings: embedMock })) }, })); vi.mock('../../src/services/ai/chat-completion.service', () => ({ ChatCompletionService: { getInstance: vi.fn(() => ({ chat: chatMock })) }, })); import { MemoryService } from '../../src/services/memory/memory.service'; // Route pool.query by SQL shape so each test controls the "similar" rows. let similarRows: Array<{ id: string; kind: string; title: string; content: string; similarity: number; }> = []; function installPool() { poolQueryMock.mockImplementation(async (sql: string) => { if (sql.includes('INSERT INTO memory.memories')) return { rows: [{ id: 'new-id-0000' }] }; if (sql.includes('UPDATE memory.memories')) return { rows: [] }; if (sql.includes('WITH q AS')) return { rows: [] }; // recall (overridden per-test) if (sql.includes('ORDER BY updated_at DESC')) return { rows: [] }; // index return { rows: similarRows }; // findSimilar }); } const service = MemoryService.getInstance(); beforeEach(() => { vi.clearAllMocks(); similarRows = []; embedMock.mockResolvedValue({ data: [{ embedding: [0.1, 0.2, 0.3] }] }); installPool(); }); describe('MemoryService.remember — reconcile', () => { it('ADDs a new memory when nothing similar exists', async () => { const res = await service.remember({ scope: 's', kind: 'fact', title: 't', content: 'c' }); expect(res).toEqual([{ action: 'ADD', id: 'new-id-0000', title: 't' }]); expect(poolQueryMock.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO'))).toBe( true ); }); it('NOOPs when the reconciler says the fact is already known', async () => { similarRows = [{ id: 'a1', kind: 'fact', title: 'x', content: 'y', similarity: 0.9 }]; chatMock.mockResolvedValue({ text: '{"action":"NOOP"}' }); const res = await service.remember({ scope: 's', kind: 'fact', title: 't', content: 'c' }); expect(res[0].action).toBe('NOOP'); expect(poolQueryMock.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO'))).toBe( false ); }); it('UPDATEs in place (and sets kind) when the reconciler targets a real matched id', async () => { const matchId = '11111111-1111-4111-8111-111111111111'; similarRows = [{ id: matchId, kind: 'fact', title: 'old', content: 'old', similarity: 0.8 }]; chatMock.mockResolvedValue({ text: `{"action":"UPDATE","target_id":"${matchId}","title":"new","content":"new merged"}`, }); const res = await service.remember({ scope: 's', kind: 'decision', title: 't', content: 'c' }); expect(res[0]).toEqual({ action: 'UPDATE', id: matchId, title: 'new' }); const updateCall = poolQueryMock.mock.calls.find(([sql]) => String(sql).includes('UPDATE memory.memories') ); expect(updateCall).toBeDefined(); expect(updateCall![1][0]).toBe('decision'); // kind is updated, not retained }); it('falls back to ADD when the reconciler returns a hallucinated target_id', async () => { similarRows = [{ id: 'a1', kind: 'fact', title: 'old', content: 'old', similarity: 0.8 }]; chatMock.mockResolvedValue({ text: '{"action":"UPDATE","target_id":"does-not-exist"}' }); const res = await service.remember({ scope: 's', kind: 'fact', title: 't', content: 'c' }); expect(res[0].action).toBe('ADD'); expect(poolQueryMock.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO'))).toBe( true ); }); }); describe('MemoryService.remember — extraction sanitization & failure isolation', () => { it('coerces an invalid kind to fact and drops candidates missing title/content', async () => { chatMock.mockResolvedValue({ text: JSON.stringify({ memories: [ { kind: 'banana', title: 'keep me', content: 'valid' }, // bad kind -> fact { kind: 'fact', title: '', content: 'no title' }, // dropped { kind: 'fact', title: 'no content', content: '' }, // dropped ], }), }); const res = await service.remember({ scope: 's', transcript: 'some transcript' }); expect(res).toHaveLength(1); expect(res[0].title).toBe('keep me'); const insert = poolQueryMock.mock.calls.find(([sql]) => String(sql).includes('INSERT INTO')); expect(insert![1][1]).toBe('fact'); // coerced kind }); it('isolates a per-candidate failure without discarding the rest', async () => { chatMock.mockResolvedValue({ text: JSON.stringify({ memories: [ { kind: 'fact', title: 'good', content: 'a' }, { kind: 'fact', title: 'bad', content: 'b' }, ], }), }); embedMock .mockResolvedValueOnce({ data: [{ embedding: [0.1] }] }) // candidate 1 ok .mockRejectedValueOnce(new Error('embed boom')); // candidate 2 fails const res = await service.remember({ scope: 's', transcript: 't' }); expect(res).toHaveLength(2); expect(res[0].action).toBe('ADD'); expect(res[1]).toEqual({ action: 'NOOP', title: 'bad' }); }); }); describe('MemoryService.recall / index', () => { it('maps recall rows and normalizes the date', async () => { poolQueryMock.mockImplementationOnce(async () => ({ rows: [ { id: 'r1', kind: 'fact', title: 'T', content: 'C', similarity: 0.71, updated_at: new Date('2026-01-01T00:00:00Z'), }, ], })); const res = await service.recall({ scope: 's', query: 'q', limit: 5 }); expect(res[0]).toMatchObject({ id: 'r1', similarity: 0.71 }); expect(res[0].updated_at).toBe('2026-01-01T00:00:00.000Z'); }); it('returns the title index for a scope', async () => { poolQueryMock.mockImplementationOnce(async () => ({ rows: [ { id: 'i1', kind: 'decision', title: 'D', updated_at: new Date('2026-02-02T00:00:00Z') }, ], })); const res = await service.index('s'); expect(res).toEqual([ { id: 'i1', kind: 'decision', title: 'D', updated_at: '2026-02-02T00:00:00.000Z' }, ]); }); });