feat(#93): implement agent spawn via federation

Implements FED-010: Agent Spawn via Federation feature that enables
spawning and managing Claude agents on remote federated Mosaic Stack
instances via COMMAND message type.

Features:
- Federation agent command types (spawn, status, kill)
- FederationAgentService for handling agent operations
- Integration with orchestrator's agent spawner/lifecycle services
- API endpoints for spawning, querying status, and killing agents
- Full command routing through federation COMMAND infrastructure
- Comprehensive test coverage (12/12 tests passing)

Architecture:
- Hub → Spoke: Spawn agents on remote instances
- Command flow: FederationController → FederationAgentService →
  CommandService → Remote Orchestrator
- Response handling: Remote orchestrator returns agent status/results
- Security: Connection validation, signature verification

Files created:
- apps/api/src/federation/types/federation-agent.types.ts
- apps/api/src/federation/federation-agent.service.ts
- apps/api/src/federation/federation-agent.service.spec.ts

Files modified:
- apps/api/src/federation/command.service.ts (agent command routing)
- apps/api/src/federation/federation.controller.ts (agent endpoints)
- apps/api/src/federation/federation.module.ts (service registration)
- apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint)
- apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration)

Testing:
- 12/12 tests passing for FederationAgentService
- All command service tests passing
- TypeScript compilation successful
- Linting passed

Refs #93

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:37:06 -06:00
parent a8c8af21e5
commit 12abdfe81d
405 changed files with 13545 additions and 2153 deletions

View File

@@ -1,17 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { KnowledgeCacheService } from './cache.service';
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { KnowledgeCacheService } from "./cache.service";
// Integration tests - require running Valkey instance
// Skip in unit test runs, enable with: INTEGRATION_TESTS=true pnpm test
describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
describe.skipIf(!process.env.INTEGRATION_TESTS)("KnowledgeCacheService", () => {
let service: KnowledgeCacheService;
beforeEach(async () => {
// Set environment variables for testing
process.env.KNOWLEDGE_CACHE_ENABLED = 'true';
process.env.KNOWLEDGE_CACHE_TTL = '300';
process.env.VALKEY_URL = 'redis://localhost:6379';
process.env.KNOWLEDGE_CACHE_ENABLED = "true";
process.env.KNOWLEDGE_CACHE_TTL = "300";
process.env.VALKEY_URL = "redis://localhost:6379";
const module: TestingModule = await Test.createTestingModule({
providers: [KnowledgeCacheService],
@@ -27,35 +27,35 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
}
});
describe('Cache Enabled/Disabled', () => {
it('should be enabled by default', () => {
describe("Cache Enabled/Disabled", () => {
it("should be enabled by default", () => {
expect(service.isEnabled()).toBe(true);
});
it('should be disabled when KNOWLEDGE_CACHE_ENABLED=false', async () => {
process.env.KNOWLEDGE_CACHE_ENABLED = 'false';
it("should be disabled when KNOWLEDGE_CACHE_ENABLED=false", async () => {
process.env.KNOWLEDGE_CACHE_ENABLED = "false";
const module = await Test.createTestingModule({
providers: [KnowledgeCacheService],
}).compile();
const disabledService = module.get<KnowledgeCacheService>(KnowledgeCacheService);
expect(disabledService.isEnabled()).toBe(false);
});
});
describe('Entry Caching', () => {
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
describe("Entry Caching", () => {
const workspaceId = "test-workspace-id";
const slug = "test-entry";
const entryData = {
id: 'entry-id',
id: "entry-id",
workspaceId,
slug,
title: 'Test Entry',
content: 'Test content',
title: "Test Entry",
content: "Test content",
tags: [],
};
it('should return null on cache miss', async () => {
it("should return null on cache miss", async () => {
if (!service.isEnabled()) {
return; // Skip if cache is disabled
}
@@ -65,206 +65,206 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(result).toBeNull();
});
it('should cache and retrieve entry data', async () => {
it("should cache and retrieve entry data", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setEntry(workspaceId, slug, entryData);
// Get from cache
const result = await service.getEntry(workspaceId, slug);
expect(result).toEqual(entryData);
});
it('should invalidate entry cache', async () => {
it("should invalidate entry cache", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setEntry(workspaceId, slug, entryData);
// Verify it's cached
let result = await service.getEntry(workspaceId, slug);
expect(result).toEqual(entryData);
// Invalidate
await service.invalidateEntry(workspaceId, slug);
// Verify it's gone
result = await service.getEntry(workspaceId, slug);
expect(result).toBeNull();
});
});
describe('Search Caching', () => {
const workspaceId = 'test-workspace-id';
const query = 'test search';
const filters = { status: 'PUBLISHED', page: 1, limit: 20 };
describe("Search Caching", () => {
const workspaceId = "test-workspace-id";
const query = "test search";
const filters = { status: "PUBLISHED", page: 1, limit: 20 };
const searchResults = {
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query,
};
it('should cache and retrieve search results', async () => {
it("should cache and retrieve search results", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setSearch(workspaceId, query, filters, searchResults);
// Get from cache
const result = await service.getSearch(workspaceId, query, filters);
expect(result).toEqual(searchResults);
});
it('should differentiate search results by filters', async () => {
it("should differentiate search results by filters", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const filters1 = { page: 1, limit: 20 };
const filters2 = { page: 2, limit: 20 };
const results1 = { ...searchResults, pagination: { ...searchResults.pagination, page: 1 } };
const results2 = { ...searchResults, pagination: { ...searchResults.pagination, page: 2 } };
await service.setSearch(workspaceId, query, filters1, results1);
await service.setSearch(workspaceId, query, filters2, results2);
const result1 = await service.getSearch(workspaceId, query, filters1);
const result2 = await service.getSearch(workspaceId, query, filters2);
expect(result1.pagination.page).toBe(1);
expect(result2.pagination.page).toBe(2);
});
it('should invalidate all search caches for workspace', async () => {
it("should invalidate all search caches for workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set multiple search caches
await service.setSearch(workspaceId, 'query1', {}, searchResults);
await service.setSearch(workspaceId, 'query2', {}, searchResults);
await service.setSearch(workspaceId, "query1", {}, searchResults);
await service.setSearch(workspaceId, "query2", {}, searchResults);
// Invalidate all
await service.invalidateSearches(workspaceId);
// Verify both are gone
const result1 = await service.getSearch(workspaceId, 'query1', {});
const result2 = await service.getSearch(workspaceId, 'query2', {});
const result1 = await service.getSearch(workspaceId, "query1", {});
const result2 = await service.getSearch(workspaceId, "query2", {});
expect(result1).toBeNull();
expect(result2).toBeNull();
});
});
describe('Graph Caching', () => {
const workspaceId = 'test-workspace-id';
const entryId = 'entry-id';
describe("Graph Caching", () => {
const workspaceId = "test-workspace-id";
const entryId = "entry-id";
const maxDepth = 2;
const graphData = {
centerNode: { id: entryId, slug: 'test', title: 'Test', tags: [], depth: 0 },
centerNode: { id: entryId, slug: "test", title: "Test", tags: [], depth: 0 },
nodes: [],
edges: [],
stats: { totalNodes: 1, totalEdges: 0, maxDepth },
};
it('should cache and retrieve graph data', async () => {
it("should cache and retrieve graph data", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
// Get from cache
const result = await service.getGraph(workspaceId, entryId, maxDepth);
expect(result).toEqual(graphData);
});
it('should differentiate graphs by maxDepth', async () => {
it("should differentiate graphs by maxDepth", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const graph1 = { ...graphData, stats: { ...graphData.stats, maxDepth: 1 } };
const graph2 = { ...graphData, stats: { ...graphData.stats, maxDepth: 2 } };
await service.setGraph(workspaceId, entryId, 1, graph1);
await service.setGraph(workspaceId, entryId, 2, graph2);
const result1 = await service.getGraph(workspaceId, entryId, 1);
const result2 = await service.getGraph(workspaceId, entryId, 2);
expect(result1.stats.maxDepth).toBe(1);
expect(result2.stats.maxDepth).toBe(2);
});
it('should invalidate all graph caches for workspace', async () => {
it("should invalidate all graph caches for workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set cache
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
// Invalidate
await service.invalidateGraphs(workspaceId);
// Verify it's gone
const result = await service.getGraph(workspaceId, entryId, maxDepth);
expect(result).toBeNull();
});
});
describe('Cache Statistics', () => {
it('should track hits and misses', async () => {
describe("Cache Statistics", () => {
it("should track hits and misses", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
const entryData = { id: '1', slug, title: 'Test' };
const workspaceId = "test-workspace-id";
const slug = "test-entry";
const entryData = { id: "1", slug, title: "Test" };
// Reset stats
service.resetStats();
// Miss
await service.getEntry(workspaceId, slug);
let stats = service.getStats();
expect(stats.misses).toBe(1);
expect(stats.hits).toBe(0);
// Set
await service.setEntry(workspaceId, slug, entryData);
stats = service.getStats();
expect(stats.sets).toBe(1);
// Hit
await service.getEntry(workspaceId, slug);
stats = service.getStats();
@@ -272,21 +272,21 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
});
it('should reset statistics', async () => {
it("should reset statistics", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
const workspaceId = "test-workspace-id";
const slug = "test-entry";
await service.getEntry(workspaceId, slug); // miss
service.resetStats();
const stats = service.getStats();
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
expect(stats.sets).toBe(0);
@@ -295,29 +295,29 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
});
});
describe('Clear Workspace Cache', () => {
it('should clear all caches for a workspace', async () => {
describe("Clear Workspace Cache", () => {
it("should clear all caches for a workspace", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const workspaceId = "test-workspace-id";
// Set various caches
await service.setEntry(workspaceId, 'entry1', { id: '1' });
await service.setSearch(workspaceId, 'query', {}, { data: [] });
await service.setGraph(workspaceId, 'entry-id', 1, { nodes: [] });
await service.setEntry(workspaceId, "entry1", { id: "1" });
await service.setSearch(workspaceId, "query", {}, { data: [] });
await service.setGraph(workspaceId, "entry-id", 1, { nodes: [] });
// Clear all
await service.clearWorkspaceCache(workspaceId);
// Verify all are gone
const entry = await service.getEntry(workspaceId, 'entry1');
const search = await service.getSearch(workspaceId, 'query', {});
const graph = await service.getGraph(workspaceId, 'entry-id', 1);
const entry = await service.getEntry(workspaceId, "entry1");
const search = await service.getSearch(workspaceId, "query", {});
const graph = await service.getGraph(workspaceId, "entry-id", 1);
expect(entry).toBeNull();
expect(search).toBeNull();
expect(graph).toBeNull();

View File

@@ -271,9 +271,7 @@ describe("GraphService", () => {
});
it("should filter by status", async () => {
const entries = [
{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] },
];
const entries = [{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] }];
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
@@ -351,9 +349,7 @@ describe("GraphService", () => {
{ id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" },
{ id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
{ id: "orphan-1" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([{ id: "orphan-1" }]);
const result = await service.getGraphStats("workspace-1");

View File

@@ -170,9 +170,9 @@ This is the content of the entry.`;
path: "",
};
await expect(
service.importEntries(workspaceId, userId, file)
).rejects.toThrow(BadRequestException);
await expect(service.importEntries(workspaceId, userId, file)).rejects.toThrow(
BadRequestException
);
});
it("should handle import errors gracefully", async () => {
@@ -195,9 +195,7 @@ Content`;
path: "",
};
mockKnowledgeService.create.mockRejectedValue(
new Error("Database error")
);
mockKnowledgeService.create.mockRejectedValue(new Error("Database error"));
const result = await service.importEntries(workspaceId, userId, file);
@@ -240,10 +238,7 @@ title: Empty Entry
it("should export entries as markdown format", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
const result = await service.exportEntries(
workspaceId,
ExportFormat.MARKDOWN
);
const result = await service.exportEntries(workspaceId, ExportFormat.MARKDOWN);
expect(result.filename).toMatch(/knowledge-export-\d{4}-\d{2}-\d{2}\.zip/);
expect(result.stream).toBeDefined();
@@ -289,9 +284,9 @@ title: Empty Entry
it("should throw error when no entries found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
await expect(
service.exportEntries(workspaceId, ExportFormat.MARKDOWN)
).rejects.toThrow(BadRequestException);
await expect(service.exportEntries(workspaceId, ExportFormat.MARKDOWN)).rejects.toThrow(
BadRequestException
);
});
});
});

View File

@@ -88,27 +88,20 @@ describe("LinkResolutionService", () => {
describe("resolveLink", () => {
describe("Exact title match", () => {
it("should resolve link by exact title match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
{
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
}
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith({
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
});
});
it("should be case-sensitive for exact title match", async () => {
@@ -116,10 +109,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBeNull();
});
@@ -128,41 +118,29 @@ describe("LinkResolutionService", () => {
describe("Slug match", () => {
it("should resolve link by slug", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"typescript-guide"
);
const result = await service.resolveLink(workspaceId, "typescript-guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
{
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
select: {
id: true,
},
}
);
},
select: {
id: true,
},
});
});
it("should prioritize exact title match over slug match", async () => {
// If exact title matches, slug should not be checked
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
@@ -173,14 +151,9 @@ describe("LinkResolutionService", () => {
it("should resolve link by case-insensitive fuzzy match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
mockEntries[0],
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([mockEntries[0]]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
@@ -216,10 +189,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"Non-existent Entry"
);
const result = await service.resolveLink(workspaceId, "Non-existent Entry");
expect(result).toBeNull();
});
@@ -266,14 +236,9 @@ describe("LinkResolutionService", () => {
});
it("should trim whitespace from target before resolving", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
" TypeScript Guide "
);
const result = await service.resolveLink(workspaceId, " TypeScript Guide ");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
@@ -291,23 +256,19 @@ describe("LinkResolutionService", () => {
it("should resolve multiple links in batch", async () => {
// First link: "TypeScript Guide" -> exact title match
// Second link: "react-hooks" -> slug match
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
return null;
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
return null;
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
@@ -344,9 +305,7 @@ describe("LinkResolutionService", () => {
});
it("should deduplicate targets", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLinks(workspaceId, [
"TypeScript Guide",
@@ -357,9 +316,7 @@ describe("LinkResolutionService", () => {
"TypeScript Guide": "entry-1",
});
// Should only be called once for the deduplicated target
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
1
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(1);
});
});
@@ -370,10 +327,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-3", title: "React Hooks Advanced" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"react hooks"
);
const result = await service.getAmbiguousMatches(workspaceId, "react hooks");
expect(result).toHaveLength(2);
expect(result).toEqual([
@@ -385,10 +339,7 @@ describe("LinkResolutionService", () => {
it("should return empty array when no matches found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.getAmbiguousMatches(
workspaceId,
"Non-existent"
);
const result = await service.getAmbiguousMatches(workspaceId, "Non-existent");
expect(result).toEqual([]);
});
@@ -398,10 +349,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-1", title: "TypeScript Guide" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"typescript guide"
);
const result = await service.getAmbiguousMatches(workspaceId, "typescript guide");
expect(result).toHaveLength(1);
});
@@ -409,8 +357,7 @@ describe("LinkResolutionService", () => {
describe("resolveLinksFromContent", () => {
it("should parse and resolve wiki links from content", async () => {
const content =
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
const content = "Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
// Mock resolveLink for each target
mockPrismaService.knowledgeEntry.findFirst
@@ -522,9 +469,7 @@ describe("LinkResolutionService", () => {
},
];
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
mockBacklinks
);
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(mockBacklinks);
const result = await service.getBacklinks(targetEntryId);

View File

@@ -26,7 +26,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Initialize services
prisma = new PrismaClient();
const prismaService = prisma as unknown as PrismaService;
// Mock cache service for testing
cacheService = {
getSearch: async () => null,
@@ -37,11 +37,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
} as unknown as KnowledgeCacheService;
embeddingService = new EmbeddingService(prismaService);
searchService = new SearchService(
prismaService,
cacheService,
embeddingService
);
searchService = new SearchService(prismaService, cacheService, embeddingService);
// Create test workspace and user
const workspace = await prisma.workspace.create({
@@ -84,10 +80,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
const title = "Introduction to PostgreSQL";
const content = "PostgreSQL is a powerful open-source database.";
const prepared = embeddingService.prepareContentForEmbedding(
title,
content
);
const prepared = embeddingService.prepareContentForEmbedding(title, content);
// Title should appear twice for weighting
expect(prepared).toContain(title);
@@ -122,10 +115,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it("should skip semantic search if OpenAI not configured", async () => {
if (!embeddingService.isConfigured()) {
await expect(
searchService.semanticSearch(
"database performance",
testWorkspaceId
)
searchService.semanticSearch("database performance", testWorkspaceId)
).rejects.toThrow();
} else {
// If configured, this is expected to work (tested below)
@@ -156,10 +146,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
entry.title,
entry.content
);
await embeddingService.generateAndStoreEmbedding(
created.id,
preparedContent
);
await embeddingService.generateAndStoreEmbedding(created.id, preparedContent);
}
// Wait a bit for embeddings to be stored
@@ -175,9 +162,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
expect(results.data.length).toBeGreaterThan(0);
// PostgreSQL entry should rank high for "relational database"
const postgresEntry = results.data.find(
(r) => r.slug === "postgresql-intro"
);
const postgresEntry = results.data.find((r) => r.slug === "postgresql-intro");
expect(postgresEntry).toBeDefined();
expect(postgresEntry!.rank).toBeGreaterThan(0);
},
@@ -187,18 +172,13 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it.skipIf(!process.env["OPENAI_API_KEY"])(
"should perform hybrid search combining vector and keyword",
async () => {
const results = await searchService.hybridSearch(
"indexing",
testWorkspaceId
);
const results = await searchService.hybridSearch("indexing", testWorkspaceId);
// Should return results
expect(results.data.length).toBeGreaterThan(0);
// Should find the indexing entry
const indexingEntry = results.data.find(
(r) => r.slug === "database-indexing"
);
const indexingEntry = results.data.find((r) => r.slug === "database-indexing");
expect(indexingEntry).toBeDefined();
},
30000
@@ -230,15 +210,10 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Batch generate embeddings
const entriesForEmbedding = entries.map((e) => ({
id: e.id,
content: embeddingService.prepareContentForEmbedding(
e.title,
e.content
),
content: embeddingService.prepareContentForEmbedding(e.title, e.content),
}));
const successCount = await embeddingService.batchGenerateEmbeddings(
entriesForEmbedding
);
const successCount = await embeddingService.batchGenerateEmbeddings(entriesForEmbedding);
expect(successCount).toBe(3);