mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
c0c321227b
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.
230 lines
8.9 KiB
TypeScript
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();
|
|
});
|
|
});
|