163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
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' },
|
|
]);
|
|
});
|
|
});
|