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:
@@ -146,9 +146,9 @@ describe("KnowledgeGraphController", () => {
|
||||
it("should throw error if entry not found", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
|
||||
|
||||
await expect(
|
||||
controller.getEntryGraph("workspace-1", "non-existent", {})
|
||||
).rejects.toThrow("Entry not found");
|
||||
await expect(controller.getEntryGraph("workspace-1", "non-existent", {})).rejects.toThrow(
|
||||
"Entry not found"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -48,10 +48,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.create(createDto, workspaceId);
|
||||
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(mockTagsService.create).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
createDto
|
||||
);
|
||||
expect(mockTagsService.create).toHaveBeenCalledWith(workspaceId, createDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -108,10 +105,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.findOne("architecture", workspaceId);
|
||||
|
||||
expect(result).toEqual(mockTagWithCount);
|
||||
expect(mockTagsService.findOne).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.findOne).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -138,18 +132,10 @@ describe("TagsController", () => {
|
||||
|
||||
mockTagsService.update.mockResolvedValue(updatedTag);
|
||||
|
||||
const result = await controller.update(
|
||||
"architecture",
|
||||
updateDto,
|
||||
workspaceId
|
||||
);
|
||||
const result = await controller.update("architecture", updateDto, workspaceId);
|
||||
|
||||
expect(result).toEqual(updatedTag);
|
||||
expect(mockTagsService.update).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId,
|
||||
updateDto
|
||||
);
|
||||
expect(mockTagsService.update).toHaveBeenCalledWith("architecture", workspaceId, updateDto);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -171,10 +157,7 @@ describe("TagsController", () => {
|
||||
|
||||
await controller.remove("architecture", workspaceId);
|
||||
|
||||
expect(mockTagsService.remove).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.remove).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
@@ -206,10 +189,7 @@ describe("TagsController", () => {
|
||||
const result = await controller.getEntries("architecture", workspaceId);
|
||||
|
||||
expect(result).toEqual(mockEntries);
|
||||
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith(
|
||||
"architecture",
|
||||
workspaceId
|
||||
);
|
||||
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith("architecture", workspaceId);
|
||||
});
|
||||
|
||||
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TagsService } from "./tags.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { NotFoundException, ConflictException, BadRequestException } from "@nestjs/common";
|
||||
import type { CreateTagDto, UpdateTagDto } from "./dto";
|
||||
|
||||
describe("TagsService", () => {
|
||||
@@ -113,9 +109,7 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag);
|
||||
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for invalid slug format", async () => {
|
||||
@@ -124,9 +118,7 @@ describe("TagsService", () => {
|
||||
slug: "Invalid_Slug!",
|
||||
};
|
||||
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
await expect(service.create(workspaceId, createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should generate slug from name with spaces and special chars", async () => {
|
||||
@@ -135,12 +127,10 @@ describe("TagsService", () => {
|
||||
};
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.knowledgeTag.create.mockImplementation(
|
||||
async ({ data }: any) => ({
|
||||
...mockTag,
|
||||
slug: data.slug,
|
||||
})
|
||||
);
|
||||
mockPrismaService.knowledgeTag.create.mockImplementation(async ({ data }: any) => ({
|
||||
...mockTag,
|
||||
slug: data.slug,
|
||||
}));
|
||||
|
||||
const result = await service.create(workspaceId, createDto);
|
||||
|
||||
@@ -183,9 +173,7 @@ describe("TagsService", () => {
|
||||
describe("findOne", () => {
|
||||
it("should return a tag by slug", async () => {
|
||||
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(
|
||||
mockTagWithCount
|
||||
);
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTagWithCount);
|
||||
|
||||
const result = await service.findOne("architecture", workspaceId);
|
||||
|
||||
@@ -208,9 +196,7 @@ describe("TagsService", () => {
|
||||
it("should throw NotFoundException if tag not found", async () => {
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findOne("nonexistent", workspaceId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,9 +231,9 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update("nonexistent", workspaceId, updateDto)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.update("nonexistent", workspaceId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException if new slug conflicts", async () => {
|
||||
@@ -263,9 +249,9 @@ describe("TagsService", () => {
|
||||
slug: "design",
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
service.update("architecture", workspaceId, updateDto)
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(service.update("architecture", workspaceId, updateDto)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,9 +278,7 @@ describe("TagsService", () => {
|
||||
it("should throw NotFoundException if tag not found", async () => {
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.remove("nonexistent", workspaceId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,9 +382,9 @@ describe("TagsService", () => {
|
||||
|
||||
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findOrCreateTags(workspaceId, slugs, false)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOrCreateTags(workspaceId, slugs, false)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@ The `wiki-link-parser.ts` utility provides parsing of wiki-style `[[links]]` fro
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { parseWikiLinks } from './utils/wiki-link-parser';
|
||||
import { parseWikiLinks } from "./utils/wiki-link-parser";
|
||||
|
||||
const content = 'See [[Main Page]] and [[Getting Started|start here]].';
|
||||
const content = "See [[Main Page]] and [[Getting Started|start here]].";
|
||||
const links = parseWikiLinks(content);
|
||||
|
||||
// Result:
|
||||
@@ -44,32 +44,41 @@ const links = parseWikiLinks(content);
|
||||
### Supported Link Formats
|
||||
|
||||
#### Basic Link (by title)
|
||||
|
||||
```markdown
|
||||
[[Page Name]]
|
||||
```
|
||||
|
||||
Links to a page by its title. Display text will be "Page Name".
|
||||
|
||||
#### Link with Display Text
|
||||
|
||||
```markdown
|
||||
[[Page Name|custom display]]
|
||||
```
|
||||
|
||||
Links to "Page Name" but displays "custom display".
|
||||
|
||||
#### Link by Slug
|
||||
|
||||
```markdown
|
||||
[[page-slug-name]]
|
||||
```
|
||||
|
||||
Links to a page by its URL slug (kebab-case).
|
||||
|
||||
### Edge Cases
|
||||
|
||||
#### Nested Brackets
|
||||
|
||||
```markdown
|
||||
[[Page [with] brackets]] ✓ Parsed correctly
|
||||
[[Page [with] brackets]] ✓ Parsed correctly
|
||||
```
|
||||
|
||||
Single brackets inside link text are allowed.
|
||||
|
||||
#### Code Blocks (Not Parsed)
|
||||
|
||||
```markdown
|
||||
Use `[[WikiLink]]` syntax for linking.
|
||||
|
||||
@@ -77,36 +86,41 @@ Use `[[WikiLink]]` syntax for linking.
|
||||
const link = "[[not parsed]]";
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Links inside inline code or fenced code blocks are ignored.
|
||||
|
||||
#### Escaped Brackets
|
||||
|
||||
```markdown
|
||||
\[[not a link]] but [[real link]] works
|
||||
```
|
||||
|
||||
Escaped brackets are not parsed as links.
|
||||
|
||||
#### Empty or Invalid Links
|
||||
|
||||
```markdown
|
||||
[[]] ✗ Empty link (ignored)
|
||||
[[ ]] ✗ Whitespace only (ignored)
|
||||
[[ Target ]] ✓ Trimmed to "Target"
|
||||
[[]] ✗ Whitespace only (ignored)
|
||||
[[Target]] ✓ Trimmed to "Target"
|
||||
```
|
||||
|
||||
### Return Type
|
||||
|
||||
```typescript
|
||||
interface WikiLink {
|
||||
raw: string; // Full matched text: "[[Page Name]]"
|
||||
target: string; // Target page: "Page Name"
|
||||
raw: string; // Full matched text: "[[Page Name]]"
|
||||
target: string; // Target page: "Page Name"
|
||||
displayText: string; // Display text: "Page Name" or custom
|
||||
start: number; // Start position in content
|
||||
end: number; // End position in content
|
||||
start: number; // Start position in content
|
||||
end: number; // End position in content
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Comprehensive test suite (100% coverage) includes:
|
||||
|
||||
- Basic parsing (single, multiple, consecutive links)
|
||||
- Display text variations
|
||||
- Edge cases (brackets, escapes, empty links)
|
||||
@@ -116,6 +130,7 @@ Comprehensive test suite (100% coverage) includes:
|
||||
- Malformed input handling
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test --filter=@mosaic/api -- wiki-link-parser.spec.ts
|
||||
```
|
||||
@@ -130,6 +145,7 @@ This parser is designed to work with the Knowledge Module's linking system:
|
||||
4. **Link Rendering**: Replace `[[links]]` with HTML anchors
|
||||
|
||||
See related issues:
|
||||
|
||||
- #59 - Wiki-link parser (this implementation)
|
||||
- Future: Link resolution and storage
|
||||
- Future: Backlink display and navigation
|
||||
@@ -151,33 +167,38 @@ The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Fl
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { renderMarkdown, markdownToPlainText } from './utils/markdown';
|
||||
import { renderMarkdown, markdownToPlainText } from "./utils/markdown";
|
||||
|
||||
// Render markdown to HTML (async)
|
||||
const html = await renderMarkdown('# Hello **World**');
|
||||
const html = await renderMarkdown("# Hello **World**");
|
||||
// Result: <h1 id="hello-world">Hello <strong>World</strong></h1>
|
||||
|
||||
// Extract plain text (for search indexing)
|
||||
const plainText = await markdownToPlainText('# Hello **World**');
|
||||
const plainText = await markdownToPlainText("# Hello **World**");
|
||||
// Result: "Hello World"
|
||||
```
|
||||
|
||||
### Supported Markdown Features
|
||||
|
||||
#### Basic Formatting
|
||||
|
||||
- **Bold**: `**text**` or `__text__`
|
||||
- *Italic*: `*text*` or `_text_`
|
||||
- _Italic_: `*text*` or `_text_`
|
||||
- ~~Strikethrough~~: `~~text~~`
|
||||
- `Inline code`: `` `code` ``
|
||||
|
||||
#### Headers
|
||||
|
||||
```markdown
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
```
|
||||
|
||||
#### Lists
|
||||
|
||||
```markdown
|
||||
- Unordered list
|
||||
- Nested item
|
||||
@@ -187,19 +208,22 @@ const plainText = await markdownToPlainText('# Hello **World**');
|
||||
```
|
||||
|
||||
#### Task Lists
|
||||
|
||||
```markdown
|
||||
- [ ] Unchecked task
|
||||
- [x] Completed task
|
||||
```
|
||||
|
||||
#### Tables
|
||||
|
||||
```markdown
|
||||
| Header 1 | Header 2 |
|
||||
|----------|----------|
|
||||
| -------- | -------- |
|
||||
| Cell 1 | Cell 2 |
|
||||
```
|
||||
|
||||
#### Code Blocks
|
||||
|
||||
````markdown
|
||||
```typescript
|
||||
const greeting: string = "Hello";
|
||||
@@ -208,12 +232,14 @@ console.log(greeting);
|
||||
````
|
||||
|
||||
#### Links and Images
|
||||
|
||||
```markdown
|
||||
[Link text](https://example.com)
|
||||

|
||||
```
|
||||
|
||||
#### Blockquotes
|
||||
|
||||
```markdown
|
||||
> This is a quote
|
||||
> Multi-line quote
|
||||
@@ -233,6 +259,7 @@ The renderer implements multiple layers of security:
|
||||
### Testing
|
||||
|
||||
Comprehensive test suite covers:
|
||||
|
||||
- Basic markdown rendering
|
||||
- GFM features (tables, task lists, strikethrough)
|
||||
- Code syntax highlighting
|
||||
@@ -240,6 +267,7 @@ Comprehensive test suite covers:
|
||||
- Edge cases (unicode, long content, nested structures)
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
pnpm test --filter=@mosaic/api -- markdown.spec.ts
|
||||
```
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
renderMarkdown,
|
||||
renderMarkdownSync,
|
||||
markdownToPlainText,
|
||||
} from "./markdown";
|
||||
import { renderMarkdown, renderMarkdownSync, markdownToPlainText } from "./markdown";
|
||||
|
||||
describe("Markdown Rendering", () => {
|
||||
describe("renderMarkdown", () => {
|
||||
@@ -77,7 +73,7 @@ describe("Markdown Rendering", () => {
|
||||
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<input');
|
||||
expect(html).toContain("<input");
|
||||
expect(html).toContain('type="checkbox"');
|
||||
expect(html).toContain('disabled="disabled"'); // Should be disabled for safety
|
||||
});
|
||||
@@ -145,16 +141,17 @@ plain text code
|
||||
const markdown = "";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('src="https://example.com/image.png"');
|
||||
expect(html).toContain('alt="Alt text"');
|
||||
});
|
||||
|
||||
it("should allow data URIs for images", async () => {
|
||||
const markdown = "";
|
||||
const markdown =
|
||||
"";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain("<img");
|
||||
expect(html).toContain('src="data:image/png;base64');
|
||||
});
|
||||
});
|
||||
@@ -164,7 +161,7 @@ plain text code
|
||||
const markdown = "# My Header Title";
|
||||
const html = await renderMarkdown(markdown);
|
||||
|
||||
expect(html).toContain('<h1');
|
||||
expect(html).toContain("<h1");
|
||||
expect(html).toContain('id="');
|
||||
});
|
||||
|
||||
@@ -282,7 +279,7 @@ plain text code
|
||||
});
|
||||
|
||||
it("should strip all HTML tags", async () => {
|
||||
const markdown = '[Link](https://example.com)\n\n';
|
||||
const markdown = "[Link](https://example.com)\n\n";
|
||||
const plainText = await markdownToPlainText(markdown);
|
||||
|
||||
expect(plainText).not.toContain("<a");
|
||||
|
||||
@@ -333,9 +333,7 @@ const link = "[[Not A Link]]";
|
||||
|
||||
expect(links[0].start).toBe(5);
|
||||
expect(links[0].end).toBe(23);
|
||||
expect(content.substring(links[0].start, links[0].end)).toBe(
|
||||
"[[Target|Display]]"
|
||||
);
|
||||
expect(content.substring(links[0].start, links[0].end)).toBe("[[Target|Display]]");
|
||||
});
|
||||
|
||||
it("should track positions in multiline content", () => {
|
||||
|
||||
Reference in New Issue
Block a user