import { mkdir, rm } from "node:fs/promises";
// src/services/compose-cache-memory.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ComposeProjectCache } from "./compose-cache.js";
describe("ComposeProjectCache - Memory Cache Integration", () => {
const testCacheDir = ".cache/test-compose-memory";
let cache: ComposeProjectCache;
beforeEach(async () => {
await mkdir(testCacheDir, { recursive: true });
// Create cache with memory cache enabled (size: 10 for testing)
cache = new ComposeProjectCache(testCacheDir, 24 * 60 * 60 * 1000, 10);
});
afterEach(async () => {
await rm(testCacheDir, { recursive: true, force: true });
});
describe("Memory Cache Hit Path", () => {
it("should return from memory cache without file access on second get", async () => {
// First, save a project (will write to file cache)
const project = {
path: "/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
};
await cache.updateProject("test-host", "plex", project);
// First get - should populate memory cache from file
const firstGet = await cache.getProject("test-host", "plex");
expect(firstGet).toBeDefined();
expect(firstGet?.name).toBe("plex");
// Delete the file cache to prove second get uses memory
await rm(testCacheDir, { recursive: true, force: true });
// Second get - should return from memory cache (file is gone!)
const secondGet = await cache.getProject("test-host", "plex");
expect(secondGet).toBeDefined();
expect(secondGet?.name).toBe("plex");
expect(secondGet?.path).toBe("/compose/plex/docker-compose.yaml");
});
it("should increment cache hit count on memory cache hits", async () => {
const project = {
path: "/compose/test/docker-compose.yaml",
name: "test",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
};
await cache.updateProject("test-host", "test", project);
// First get - file cache hit
await cache.getProject("test-host", "test");
// Second get - memory cache hit
await cache.getProject("test-host", "test");
const stats = cache.getCacheStats();
expect(stats.hits).toBeGreaterThanOrEqual(1);
});
});
describe("Memory Cache Miss - File Cache Fallback", () => {
it("should fall back to file cache on memory cache miss", async () => {
const project = {
path: "/compose/sonarr/docker-compose.yaml",
name: "sonarr",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
};
// Save to file cache directly (not in memory yet)
await cache.save("test-host", {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
sonarr: project,
},
});
// Create NEW cache instance (fresh memory, but file exists)
const freshCache = new ComposeProjectCache(testCacheDir, 24 * 60 * 60 * 1000, 10);
// Should find in file cache and populate memory
const result = await freshCache.getProject("test-host", "sonarr");
expect(result).toBeDefined();
expect(result?.name).toBe("sonarr");
});
it("should populate memory cache from file cache hit", async () => {
const project = {
path: "/compose/radarr/docker-compose.yaml",
name: "radarr",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
};
// Save to file only
await cache.save("test-host", {
lastScan: new Date().toISOString(),
searchPaths: [],
projects: { radarr: project },
});
// Create fresh cache (empty memory)
const freshCache = new ComposeProjectCache(testCacheDir, 24 * 60 * 60 * 1000, 10);
// First get - file cache hit, should populate memory
await freshCache.getProject("test-host", "radarr");
// Delete file
await rm(testCacheDir, { recursive: true, force: true });
// Second get - should come from memory
const fromMemory = await freshCache.getProject("test-host", "radarr");
expect(fromMemory).toBeDefined();
expect(fromMemory?.name).toBe("radarr");
});
});
describe("Two-Tier Cache Updates", () => {
it("should update both memory and file cache on updateProject", async () => {
const project = {
path: "/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
};
await cache.updateProject("test-host", "plex", project);
// Verify file cache has it
const fromFile = await cache.load("test-host");
expect(fromFile.projects.plex).toBeDefined();
// Verify memory cache has it (delete file first)
await rm(testCacheDir, { recursive: true, force: true });
const fromMemory = await cache.getProject("test-host", "plex");
expect(fromMemory).toBeDefined();
});
it("should keep caches in sync on multiple updates", async () => {
// Initial project
await cache.updateProject("test-host", "app", {
path: "/v1/docker-compose.yaml",
name: "app",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
// Update project
await cache.updateProject("test-host", "app", {
path: "/v2/docker-compose.yaml",
name: "app",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
});
// Check memory (delete file)
await rm(testCacheDir, { recursive: true, force: true });
const fromMemory = await cache.getProject("test-host", "app");
expect(fromMemory?.path).toBe("/v2/docker-compose.yaml");
expect(fromMemory?.discoveredFrom).toBe("docker-ls");
});
});
describe("Two-Tier Cache Removal", () => {
it("should remove from both memory and file cache", async () => {
const project = {
path: "/compose/test/docker-compose.yaml",
name: "test",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
};
await cache.updateProject("test-host", "test", project);
// Verify it's cached
const before = await cache.getProject("test-host", "test");
expect(before).toBeDefined();
// Remove it
await cache.removeProject("test-host", "test");
// Should be gone from memory
const afterMemory = await cache.getProject("test-host", "test");
expect(afterMemory).toBeUndefined();
// Should be gone from file
const afterFile = await cache.load("test-host");
expect(afterFile.projects.test).toBeUndefined();
});
});
describe("Cache Statistics", () => {
it("should return valid cache statistics", async () => {
const stats = cache.getCacheStats();
expect(stats).toHaveProperty("hits");
expect(stats).toHaveProperty("misses");
expect(stats).toHaveProperty("size");
expect(stats).toHaveProperty("maxSize");
expect(stats).toHaveProperty("evictions");
expect(typeof stats.hits).toBe("number");
expect(typeof stats.misses).toBe("number");
expect(typeof stats.size).toBe("number");
expect(typeof stats.maxSize).toBe("number");
expect(typeof stats.evictions).toBe("number");
});
it("should track cache size correctly", async () => {
const statsBefore = cache.getCacheStats();
expect(statsBefore.size).toBe(0);
// Add 3 projects
for (let i = 0; i < 3; i++) {
await cache.updateProject("test-host", `app-${i}`, {
path: `/compose/app-${i}/docker-compose.yaml`,
name: `app-${i}`,
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
}
// Should see size increase (might be 3 or more depending on cache key structure)
const statsAfter = cache.getCacheStats();
expect(statsAfter.size).toBeGreaterThan(0);
});
it("should report correct maxSize", async () => {
const stats = cache.getCacheStats();
expect(stats.maxSize).toBe(10); // We set it to 10 in beforeEach
});
it("should track evictions when cache is full", async () => {
// Fill cache beyond capacity (maxSize = 10)
for (let i = 0; i < 15; i++) {
await cache.updateProject("test-host", `app-${i}`, {
path: `/compose/app-${i}/docker-compose.yaml`,
name: `app-${i}`,
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
}
const stats = cache.getCacheStats();
// Should have some evictions
expect(stats.evictions).toBeGreaterThan(0);
});
it("should track hits and misses", async () => {
const project = {
path: "/compose/test/docker-compose.yaml",
name: "test",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
};
await cache.updateProject("test-host", "test", project);
// Hit
await cache.getProject("test-host", "test");
// Miss
await cache.getProject("test-host", "nonexistent");
const stats = cache.getCacheStats();
expect(stats.hits).toBeGreaterThanOrEqual(1);
expect(stats.misses).toBeGreaterThanOrEqual(1);
});
});
describe("LRU Eviction Behavior", () => {
it("should evict least recently used entries when full", async () => {
// Fill cache to capacity (10 entries)
for (let i = 0; i < 10; i++) {
await cache.updateProject("test-host", `app-${i}`, {
path: `/compose/app-${i}/docker-compose.yaml`,
name: `app-${i}`,
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
}
// Access app-0 to make it recently used
await cache.getProject("test-host", "app-0");
// Add new entry (should evict LRU, which is NOT app-0)
await cache.updateProject("test-host", "app-new", {
path: "/compose/app-new/docker-compose.yaml",
name: "app-new",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
// Delete file cache to test memory only
await rm(testCacheDir, { recursive: true, force: true });
// app-0 should still be in memory (was recently accessed)
const app0 = await cache.getProject("test-host", "app-0");
expect(app0).toBeDefined();
// app-new should be in memory (just added)
const appNew = await cache.getProject("test-host", "app-new");
expect(appNew).toBeDefined();
});
});
describe("Constructor Configuration", () => {
it("should use default memory cache size if not specified", async () => {
const defaultCache = new ComposeProjectCache(testCacheDir);
const stats = defaultCache.getCacheStats();
// Default size should be 50 (from LRUCacheLayer default)
expect(stats.maxSize).toBe(50);
});
it("should accept custom memory cache size", async () => {
const customCache = new ComposeProjectCache(testCacheDir, 24 * 60 * 60 * 1000, 100);
const stats = customCache.getCacheStats();
expect(stats.maxSize).toBe(100);
});
});
});