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:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user