mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
324 lines
13 KiB
TypeScript
324 lines
13 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';
|
|
import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsMock } from './helpers/arcstatsFsMock';
|
|
import { GitSourceService } from '../services/GitSourceService';
|
|
import type { PublicGitSource } from '../services/GitSourceService';
|
|
|
|
// ── 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;
|
|
let arcFs: ArcstatsFsMock;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
// Host memory reads ZFS ARC stats; intercept those reads so results do not
|
|
// depend on whether the CI host is itself ZFS. Default: no ARC present.
|
|
arcFs = installArcstatsFsMock();
|
|
({ 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();
|
|
arcFs.clear();
|
|
|
|
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);
|
|
// With no ARC present, effective used is total - available (which equals
|
|
// mem.active), 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
|
|
});
|
|
});
|
|
|
|
it('adds reclaimable ZFS ARC back into available memory', async () => {
|
|
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
|
|
const res = await request(app).get('/api/system/stats').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
// available 600 + 200 reclaimable ARC = 800 effective free; used drops to 200.
|
|
expect(res.body.memory).toMatchObject({
|
|
total: 1000,
|
|
used: 200,
|
|
free: 800,
|
|
usagePercent: '20.0',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── /api/fleet/overview (local node) ───────────────────────────────────
|
|
|
|
describe('GET /api/fleet/overview local-node memory', () => {
|
|
it('reports ARC-adjusted memory for the local node', async () => {
|
|
arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(300, 100)); // reclaimable 200
|
|
const res = await request(app).get('/api/fleet/overview').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
const local = res.body.find((n: { type: string }) => n.type === 'local');
|
|
expect(local?.systemStats?.memory).toMatchObject({
|
|
total: 1000,
|
|
used: 200,
|
|
free: 800,
|
|
usagePercent: '20.0',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── /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);
|
|
});
|
|
|
|
it('labels each stack with its git/local source, computed outside the cache', async () => {
|
|
mockGetStacks.mockResolvedValue(['web.yml', 'db.yml']);
|
|
mockGetBulkStackStatuses.mockResolvedValue({
|
|
web: { status: 'running' },
|
|
db: { status: 'running' },
|
|
});
|
|
// Only `web` is linked to a Git source.
|
|
const listSpy = vi
|
|
.spyOn(GitSourceService.getInstance(), 'list')
|
|
.mockReturnValue([{ stack_name: 'web' } as PublicGitSource]);
|
|
|
|
const first = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
expect(first.body['web.yml'].source).toBe('git');
|
|
expect(first.body['db.yml'].source).toBe('local');
|
|
|
|
// Source is recomputed live even when the Docker-status payload is cached:
|
|
// unlinking `web` flips it to local on the next request without a cache flush.
|
|
listSpy.mockReturnValue([]);
|
|
const second = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
expect(second.body['web.yml'].source).toBe('local');
|
|
expect(mockGetBulkStackStatuses).toHaveBeenCalledTimes(1); // status portion served from cache
|
|
|
|
listSpy.mockRestore();
|
|
});
|
|
|
|
it('falls back to local labels (200, not 500) when the git-source lookup throws', async () => {
|
|
mockGetStacks.mockResolvedValue(['web.yml']);
|
|
mockGetBulkStackStatuses.mockResolvedValue({ web: { status: 'running' } });
|
|
const listSpy = vi
|
|
.spyOn(GitSourceService.getInstance(), 'list')
|
|
.mockImplementation(() => { throw new Error('db locked'); });
|
|
|
|
const res = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body['web.yml'].source).toBe('local');
|
|
|
|
listSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── /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();
|
|
});
|
|
});
|