All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
240 lines
7.1 KiB
TypeScript
240 lines
7.1 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
|
|
import { randomUUID as uuid } from "crypto";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { ConflictException } from "@nestjs/common";
|
|
import { PrismaClient, Prisma } from "@prisma/client";
|
|
import { EMBEDDING_DIMENSION } from "@mosaic/shared";
|
|
import { ConversationArchiveService } from "./conversation-archive.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { EmbeddingService } from "../knowledge/services/embedding.service";
|
|
|
|
const shouldRunDbIntegrationTests =
|
|
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
|
|
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
|
|
|
|
function vector(value: number): number[] {
|
|
return Array.from({ length: EMBEDDING_DIMENSION }, () => value);
|
|
}
|
|
|
|
function toVectorLiteral(input: number[]): string {
|
|
return `[${input.join(",")}]`;
|
|
}
|
|
|
|
describeFn("ConversationArchiveService Integration", () => {
|
|
let moduleRef: TestingModule;
|
|
let prisma: PrismaClient;
|
|
let service: ConversationArchiveService;
|
|
let workspaceId: string;
|
|
let ownerId: string;
|
|
let setupComplete = false;
|
|
|
|
const embeddingServiceMock = {
|
|
isConfigured: vi.fn(),
|
|
generateEmbedding: vi.fn(),
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
prisma = new PrismaClient();
|
|
await prisma.$connect();
|
|
|
|
const workspace = await prisma.workspace.create({
|
|
data: {
|
|
name: `Conversation Archive Integration ${Date.now()}`,
|
|
owner: {
|
|
create: {
|
|
email: `conversation-archive-integration-${Date.now()}@example.com`,
|
|
name: "Conversation Archive Integration Owner",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
workspaceId = workspace.id;
|
|
ownerId = workspace.ownerId;
|
|
|
|
moduleRef = await Test.createTestingModule({
|
|
providers: [
|
|
ConversationArchiveService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: prisma,
|
|
},
|
|
{
|
|
provide: EmbeddingService,
|
|
useValue: embeddingServiceMock,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = moduleRef.get<ConversationArchiveService>(ConversationArchiveService);
|
|
setupComplete = true;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
embeddingServiceMock.isConfigured.mockReturnValue(false);
|
|
|
|
if (!setupComplete) {
|
|
return;
|
|
}
|
|
|
|
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!prisma) {
|
|
return;
|
|
}
|
|
|
|
if (workspaceId) {
|
|
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
|
|
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
|
|
}
|
|
if (ownerId) {
|
|
await prisma.user.deleteMany({ where: { id: ownerId } });
|
|
}
|
|
|
|
if (moduleRef) {
|
|
await moduleRef.close();
|
|
}
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
it("ingests a conversation log", async () => {
|
|
if (!setupComplete) {
|
|
return;
|
|
}
|
|
|
|
const sessionId = `session-${uuid()}`;
|
|
|
|
const result = await service.ingest(workspaceId, {
|
|
sessionId,
|
|
agentId: "agent-conversation-ingest",
|
|
messages: [
|
|
{ role: "user", content: "Can you summarize deployment issues?" },
|
|
{ role: "assistant", content: "Yes, three retries timed out in staging." },
|
|
],
|
|
summary: "Deployment retry failures discussed",
|
|
startedAt: "2026-02-28T21:00:00.000Z",
|
|
endedAt: "2026-02-28T21:05:00.000Z",
|
|
metadata: { source: "integration-test" },
|
|
});
|
|
|
|
expect(result.id).toBeDefined();
|
|
|
|
const stored = await prisma.conversationArchive.findUnique({
|
|
where: {
|
|
id: result.id,
|
|
},
|
|
});
|
|
|
|
expect(stored).toBeTruthy();
|
|
expect(stored?.workspaceId).toBe(workspaceId);
|
|
expect(stored?.sessionId).toBe(sessionId);
|
|
expect(stored?.messageCount).toBe(2);
|
|
expect(stored?.summary).toBe("Deployment retry failures discussed");
|
|
});
|
|
|
|
it("rejects duplicate session ingest per workspace", async () => {
|
|
if (!setupComplete) {
|
|
return;
|
|
}
|
|
|
|
const sessionId = `session-${uuid()}`;
|
|
const dto = {
|
|
sessionId,
|
|
agentId: "agent-conversation-duplicate",
|
|
messages: [{ role: "user", content: "hello" }],
|
|
summary: "simple conversation",
|
|
startedAt: "2026-02-28T22:00:00.000Z",
|
|
};
|
|
|
|
await service.ingest(workspaceId, dto);
|
|
|
|
await expect(service.ingest(workspaceId, dto)).rejects.toThrow(ConflictException);
|
|
});
|
|
|
|
it("rejects semantic search when embeddings are disabled", async () => {
|
|
if (!setupComplete) {
|
|
return;
|
|
}
|
|
|
|
embeddingServiceMock.isConfigured.mockReturnValue(false);
|
|
|
|
await expect(
|
|
service.search(workspaceId, {
|
|
query: "deployment retries",
|
|
})
|
|
).rejects.toThrow(ConflictException);
|
|
});
|
|
|
|
it("searches archived conversations by vector similarity", async () => {
|
|
if (!setupComplete) {
|
|
return;
|
|
}
|
|
|
|
const near = vector(0.02);
|
|
const far = vector(0.85);
|
|
|
|
const matching = await prisma.conversationArchive.create({
|
|
data: {
|
|
workspaceId,
|
|
sessionId: `session-search-${uuid()}`,
|
|
agentId: "agent-conversation-search-a",
|
|
messages: [
|
|
{ role: "user", content: "What caused deployment retries?" },
|
|
{ role: "assistant", content: "A connection pool timeout." },
|
|
] as unknown as Prisma.InputJsonValue,
|
|
messageCount: 2,
|
|
summary: "Deployment retries caused by connection pool timeout",
|
|
startedAt: new Date("2026-02-28T23:00:00.000Z"),
|
|
metadata: { channel: "cli" } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
const nonMatching = await prisma.conversationArchive.create({
|
|
data: {
|
|
workspaceId,
|
|
sessionId: `session-search-${uuid()}`,
|
|
agentId: "agent-conversation-search-b",
|
|
messages: [
|
|
{ role: "user", content: "How is billing configured?" },
|
|
] as unknown as Prisma.InputJsonValue,
|
|
messageCount: 1,
|
|
summary: "Billing and quotas conversation",
|
|
startedAt: new Date("2026-02-28T23:10:00.000Z"),
|
|
metadata: { channel: "cli" } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
await prisma.$executeRaw`
|
|
UPDATE conversation_archives
|
|
SET embedding = ${toVectorLiteral(near)}::vector(${EMBEDDING_DIMENSION})
|
|
WHERE id = ${matching.id}::uuid
|
|
`;
|
|
await prisma.$executeRaw`
|
|
UPDATE conversation_archives
|
|
SET embedding = ${toVectorLiteral(far)}::vector(${EMBEDDING_DIMENSION})
|
|
WHERE id = ${nonMatching.id}::uuid
|
|
`;
|
|
|
|
embeddingServiceMock.isConfigured.mockReturnValue(true);
|
|
embeddingServiceMock.generateEmbedding.mockResolvedValue(near);
|
|
|
|
const result = await service.search(workspaceId, {
|
|
query: "deployment retries timeout",
|
|
agentId: "agent-conversation-search-a",
|
|
similarityThreshold: 0,
|
|
limit: 10,
|
|
});
|
|
|
|
const rows = result.data as Array<{ id: string; agent_id: string; similarity: number }>;
|
|
|
|
expect(result.pagination.total).toBe(1);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.id).toBe(matching.id);
|
|
expect(rows[0]?.agent_id).toBe("agent-conversation-search-a");
|
|
expect(rows[0]?.similarity).toBeGreaterThan(0);
|
|
});
|
|
});
|