Files
sencho/backend/src/__tests__/cache-endpoints.test.ts
T
Anso c0c321227b perf: unify caching behind a single CacheService and enable HTTP compression (#468)
Replaces five ad-hoc in-process caches (project name map, templates, latest
version, fleet update status, remote node meta) with a single internal
CacheService that provides TTL, inflight-promise deduplication to protect
against thundering herd, stale-on-error fallback, and per-namespace
hit/miss/stale/size counters for observability.

Wraps the hot-path dashboard endpoints in the cache with write-path
invalidation: /api/stats (2s), /api/system/stats (3s), and
/api/stacks/statuses (3s). Keys are namespaced by nodeId so switching nodes
never serves another node's data. Every route that mutates container or
stack state calls invalidateNodeCaches(nodeId), which also drops the global
project-name-map, so user actions stay instantly reflected in the UI.

For /api/system/stats the cheap per-request network rx/tx block is kept
outside the cache so live-updating charts stay smooth while the expensive
systeminformation.currentLoad() CPU sample (~200ms) is reused across the
TTL.

Adds admin-only GET /api/system/cache-stats returning per-namespace
counters for operators who want to observe cache effectiveness.

Enables the compression middleware site-wide for JSON responses. Large
payloads like /api/templates shrink roughly 5x on the wire. SSE endpoints
are explicitly excluded via a Content-Type filter so live log tails and
metric streams are not buffered.

Bumps vitest hookTimeout to match testTimeout (15s) so parallel fork
workers do not hit the default 10s hook limit under CPU contention.

Adds 35 new tests (26 unit for CacheService, 9 integration for cached
endpoints) covering TTL expiry, inflight dedup, stale-on-error,
namespace invalidation, entry-cap safety guard, and write-path
invalidation end-to-end through Express routes.
2026-04-10 10:05:05 -04:00

230 lines
8.9 KiB
TypeScript

/**
* Integration tests for cached HTTP endpoints:
* - /api/stats (2s TTL, invalidated on writes)
* - /api/system/stats (3s TTL, no write invalidation)
* - /api/stacks/statuses (3s TTL, invalidated on writes)
* - /api/system/cache-stats (admin observability)
*
* Verifies cache hit behavior (second call does not re-invoke the
* underlying Docker / si / FileSystem work), write-path invalidation
* (POST /api/stacks resets the cache), and that the admin endpoint
* reports per-namespace counters.
*
* The tests mock DockerController / FileSystemService at the service
* layer rather than hitting the real Docker socket, so they run in CI
* without requiring any external daemon.
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb';
// ── Hoisted mocks (must come before importing the app) ─────────────────
const {
mockGetAllContainers,
mockGetBulkStackStatuses,
mockGetStacks,
mockCurrentLoad,
mockMem,
mockFsSize,
} = vi.hoisted(() => ({
mockGetAllContainers: vi.fn(),
mockGetBulkStackStatuses: vi.fn(),
mockGetStacks: vi.fn(),
mockCurrentLoad: vi.fn(),
mockMem: vi.fn(),
mockFsSize: vi.fn(),
}));
vi.mock('../services/DockerController', async () => {
const actual = await vi.importActual<typeof import('../services/DockerController')>('../services/DockerController');
return {
...actual,
default: {
...actual.default,
getInstance: () => ({
getAllContainers: mockGetAllContainers,
getBulkStackStatuses: mockGetBulkStackStatuses,
}),
},
globalDockerNetwork: { rxSec: 0, txSec: 0 },
};
});
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getStacks: mockGetStacks,
createStack: vi.fn().mockResolvedValue(undefined),
getBaseDir: () => '/tmp/compose',
}),
},
}));
vi.mock('systeminformation', () => ({
default: {
currentLoad: (...args: unknown[]) => mockCurrentLoad(...args),
mem: (...args: unknown[]) => mockMem(...args),
fsSize: (...args: unknown[]) => mockFsSize(...args),
},
currentLoad: (...args: unknown[]) => mockCurrentLoad(...args),
mem: (...args: unknown[]) => mockMem(...args),
fsSize: (...args: unknown[]) => mockFsSize(...args),
}));
// ── Setup ──────────────────────────────────────────────────────────────
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let CacheService: typeof import('../services/CacheService').CacheService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ CacheService } = await import('../services/CacheService'));
const login = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
authCookie = (login.headers['set-cookie'] as unknown as string[])[0];
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
CacheService.getInstance().flush();
mockGetAllContainers.mockReset();
mockGetBulkStackStatuses.mockReset();
mockGetStacks.mockReset();
mockCurrentLoad.mockReset();
mockMem.mockReset();
mockFsSize.mockReset();
mockGetAllContainers.mockResolvedValue([
{ State: 'running', Labels: { 'com.docker.compose.project.working_dir': '/tmp/compose/a' } },
{ State: 'exited', Labels: {} },
]);
mockGetBulkStackStatuses.mockResolvedValue({});
mockGetStacks.mockResolvedValue([]);
mockCurrentLoad.mockResolvedValue({ currentLoad: 42.5, cpus: [{}, {}] });
mockMem.mockResolvedValue({ total: 1000, used: 500, free: 500 });
mockFsSize.mockResolvedValue([{ fs: '/dev/sda1', mount: '/', size: 1000, used: 500, available: 500, use: 50 }]);
});
// ── /api/stats ─────────────────────────────────────────────────────────
describe('GET /api/stats caching', () => {
it('returns shape { active, managed, unmanaged, exited, total }', async () => {
const res = await request(app).get('/api/stats').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('active');
expect(res.body).toHaveProperty('managed');
expect(res.body).toHaveProperty('unmanaged');
expect(res.body).toHaveProperty('exited');
expect(res.body).toHaveProperty('total');
});
it('serves the second call from cache without re-invoking Docker', async () => {
await request(app).get('/api/stats').set('Cookie', authCookie);
await request(app).get('/api/stats').set('Cookie', authCookie);
expect(mockGetAllContainers).toHaveBeenCalledTimes(1);
});
it('invalidates on POST /api/stacks', async () => {
await request(app).get('/api/stats').set('Cookie', authCookie);
expect(mockGetAllContainers).toHaveBeenCalledTimes(1);
const create = await request(app)
.post('/api/stacks')
.set('Cookie', authCookie)
.send({ stackName: 'new-stack' });
expect(create.status).toBe(200);
await request(app).get('/api/stats').set('Cookie', authCookie);
expect(mockGetAllContainers).toHaveBeenCalledTimes(2);
});
});
// ── /api/system/stats ──────────────────────────────────────────────────
describe('GET /api/system/stats caching', () => {
it('collapses concurrent calls so si.currentLoad() runs once', async () => {
// Two back-to-back requests, the second should hit the cache.
await request(app).get('/api/system/stats').set('Cookie', authCookie);
await request(app).get('/api/system/stats').set('Cookie', authCookie);
expect(mockCurrentLoad).toHaveBeenCalledTimes(1);
expect(mockMem).toHaveBeenCalledTimes(1);
expect(mockFsSize).toHaveBeenCalledTimes(1);
});
it('response includes network block that is read per-request outside the cache', async () => {
const res1 = await request(app).get('/api/system/stats').set('Cookie', authCookie);
const res2 = await request(app).get('/api/system/stats').set('Cookie', authCookie);
expect(res1.body).toHaveProperty('network');
expect(res2.body).toHaveProperty('network');
// CPU/mem/disk sample is cached; network is fresh per request.
expect(mockCurrentLoad).toHaveBeenCalledTimes(1);
});
});
// ── /api/stacks/statuses ───────────────────────────────────────────────
describe('GET /api/stacks/statuses caching', () => {
it('serves repeat calls from cache without re-invoking the filesystem', async () => {
mockGetStacks.mockResolvedValue(['web', 'db']);
mockGetBulkStackStatuses.mockResolvedValue({
web: { status: 'running' },
db: { status: 'running' },
});
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(mockGetStacks).toHaveBeenCalledTimes(1);
expect(mockGetBulkStackStatuses).toHaveBeenCalledTimes(1);
});
it('invalidates on POST /api/stacks', async () => {
mockGetStacks.mockResolvedValue(['web']);
mockGetBulkStackStatuses.mockResolvedValue({ web: { status: 'running' } });
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(mockGetStacks).toHaveBeenCalledTimes(1);
await request(app)
.post('/api/stacks')
.set('Cookie', authCookie)
.send({ stackName: 'fresh-stack' });
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(mockGetStacks).toHaveBeenCalledTimes(2);
});
});
// ── /api/system/cache-stats ────────────────────────────────────────────
describe('GET /api/system/cache-stats', () => {
it('requires admin auth', async () => {
const res = await request(app).get('/api/system/cache-stats');
expect(res.status).toBe(401);
});
it('returns per-namespace hit/miss/stale counters', async () => {
// Generate some cache traffic first.
await request(app).get('/api/stats').set('Cookie', authCookie); // miss
await request(app).get('/api/stats').set('Cookie', authCookie); // hit
await request(app).get('/api/system/stats').set('Cookie', authCookie); // miss
const res = await request(app).get('/api/system/cache-stats').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body.stats).toBeDefined();
expect(res.body.stats.hits).toBeGreaterThanOrEqual(1);
expect(res.body.stats.misses).toBeGreaterThanOrEqual(1);
expect(res.body['system-stats']).toBeDefined();
});
});