Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

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