/** * 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('../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: [{}, {}] }); // active/available exclude reclaimable cache; used/free include it. The route // reports the cache-excluded figures, so the mock supplies the full shape: // active(400) + available(600) = total; used(500) = active + 100 cache. mockMem.mockResolvedValue({ total: 1000, used: 500, active: 400, free: 500, available: 600, buffcache: 100 }); 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); }); it('reports memory from the active working set, excluding reclaimable cache', async () => { const res = await request(app).get('/api/system/stats').set('Cookie', authCookie); expect(res.status).toBe(200); // Figures come from mem.active / mem.available (cache-excluded), not the // cache-inclusive mem.used / mem.free, so a busy host does not read ~100%. expect(res.body.memory).toMatchObject({ total: 1000, used: 400, // mem.active, not mem.used (500) free: 600, // mem.available, not mem.free (500) usagePercent: '40.0', // 400 / 1000, not 500 / 1000 }); }); }); // ── /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(); }); });