Files
stack/apps/api/src/knowledge/services/cache.service.spec.ts
Jason Woltje 12abdfe81d 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>
2026-02-03 14:37:06 -06:00

327 lines
9.4 KiB
TypeScript

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", () => {
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";
const module: TestingModule = await Test.createTestingModule({
providers: [KnowledgeCacheService],
}).compile();
service = module.get<KnowledgeCacheService>(KnowledgeCacheService);
});
afterEach(async () => {
// Clean up
if (service && service.isEnabled()) {
await service.onModuleDestroy();
}
});
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";
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";
const entryData = {
id: "entry-id",
workspaceId,
slug,
title: "Test Entry",
content: "Test content",
tags: [],
};
it("should return null on cache miss", async () => {
if (!service.isEnabled()) {
return; // Skip if cache is disabled
}
await service.onModuleInit();
const result = await service.getEntry(workspaceId, slug);
expect(result).toBeNull();
});
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 () => {
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 };
const searchResults = {
data: [],
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
query,
};
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 () => {
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 () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
// Set multiple search caches
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", {});
expect(result1).toBeNull();
expect(result2).toBeNull();
});
});
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 },
nodes: [],
edges: [],
stats: { totalNodes: 1, totalEdges: 0, maxDepth },
};
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 () => {
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 () => {
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 () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
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();
expect(stats.hits).toBe(1);
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
});
it("should reset statistics", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
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);
expect(stats.deletes).toBe(0);
expect(stats.hitRate).toBe(0);
});
});
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";
// Set various caches
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);
expect(entry).toBeNull();
expect(search).toBeNull();
expect(graph).toBeNull();
});
});
});