test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
213
dashcaddy-api/__tests__/state-manager.test.js
Normal file
213
dashcaddy-api/__tests__/state-manager.test.js
Normal file
@@ -0,0 +1,213 @@
|
||||
jest.mock('proper-lockfile');
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn(),
|
||||
promises: {
|
||||
readFile: jest.fn().mockResolvedValue('[]'),
|
||||
writeFile: jest.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const StateManager = require('../state-manager');
|
||||
|
||||
describe('StateManager', () => {
|
||||
let sm;
|
||||
const TEST_PATH = '/tmp/test-state.json';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.promises.readFile.mockResolvedValue('[]');
|
||||
fs.promises.writeFile.mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
lockfile.unlock.mockResolvedValue();
|
||||
|
||||
sm = new StateManager(TEST_PATH);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('creates file with [] if it does not exist', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
new StateManager('/tmp/new-state.json');
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith('/tmp/new-state.json', '[]', 'utf8');
|
||||
});
|
||||
|
||||
it('creates directory recursively if needed', () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
new StateManager('/tmp/deep/nested/state.json');
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
||||
});
|
||||
|
||||
it('does not create file if it exists', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.writeFileSync.mockClear();
|
||||
new StateManager(TEST_PATH);
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('returns parsed JSON from file', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'svc1' }]));
|
||||
const data = await sm.read();
|
||||
expect(data).toEqual([{ id: 'svc1' }]);
|
||||
});
|
||||
|
||||
it('returns [] and recreates file on ENOENT', async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
fs.promises.readFile.mockRejectedValue(err);
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
|
||||
const data = await sm.read();
|
||||
expect(data).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on invalid JSON', async () => {
|
||||
fs.promises.readFile.mockResolvedValue('{bad json}');
|
||||
await expect(sm.read()).rejects.toThrow('Failed to read state file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('acquires lock, writes JSON, releases lock', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
|
||||
await sm.write([{ id: 'new' }]);
|
||||
|
||||
expect(lockfile.lock).toHaveBeenCalledWith(TEST_PATH, expect.any(Object));
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
||||
TEST_PATH,
|
||||
JSON.stringify([{ id: 'new' }], null, 2),
|
||||
'utf8'
|
||||
);
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on ELOCKED', async () => {
|
||||
const err = new Error('locked');
|
||||
err.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(err);
|
||||
|
||||
await expect(sm.write([])).rejects.toThrow('locked by another process');
|
||||
});
|
||||
|
||||
it('releases lock even on write error', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.writeFile.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
await expect(sm.write([])).rejects.toThrow();
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('atomic read-modify-write cycle', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
||||
|
||||
const result = await sm.update(items => {
|
||||
items.push({ id: '2' });
|
||||
return items;
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
expect(fs.promises.writeFile).toHaveBeenCalled();
|
||||
expect(releaseFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes current data to updateFn', async () => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: 'existing' }]));
|
||||
|
||||
const updateFn = jest.fn(data => data);
|
||||
await sm.update(updateFn);
|
||||
|
||||
expect(updateFn).toHaveBeenCalledWith([{ id: 'existing' }]);
|
||||
});
|
||||
|
||||
it('throws on ELOCKED', async () => {
|
||||
const err = new Error('locked');
|
||||
err.code = 'ELOCKED';
|
||||
lockfile.lock.mockRejectedValue(err);
|
||||
|
||||
await expect(sm.update(d => d)).rejects.toThrow('locked by another process');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convenience methods', () => {
|
||||
beforeEach(() => {
|
||||
const releaseFn = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(releaseFn);
|
||||
});
|
||||
|
||||
it('addItem appends to array', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }]));
|
||||
const result = await sm.addItem({ id: '2', name: 'New' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2', name: 'New' }]);
|
||||
});
|
||||
|
||||
it('removeItem filters by id', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1' }, { id: '2' }]));
|
||||
const result = await sm.removeItem('1');
|
||||
expect(result).toEqual([{ id: '2' }]);
|
||||
});
|
||||
|
||||
it('updateItem merges updates for matching id', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Old' }]));
|
||||
const result = await sm.updateItem('1', { name: 'New', port: 8080 });
|
||||
expect(result).toEqual([{ id: '1', name: 'New', port: 8080 }]);
|
||||
});
|
||||
|
||||
it('findItem returns matching item or null', async () => {
|
||||
fs.promises.readFile.mockResolvedValue(JSON.stringify([{ id: '1', name: 'Found' }]));
|
||||
const found = await sm.findItem('1');
|
||||
expect(found).toEqual({ id: '1', name: 'Found' });
|
||||
|
||||
const missing = await sm.findItem('999');
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocked', () => {
|
||||
it('returns lockfile.check result', async () => {
|
||||
lockfile.check.mockResolvedValue(true);
|
||||
expect(await sm.isLocked()).toBe(true);
|
||||
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
expect(await sm.isLocked()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
lockfile.check.mockRejectedValue(new Error('fail'));
|
||||
expect(await sm.isLocked()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceUnlock', () => {
|
||||
it('calls lockfile.unlock', async () => {
|
||||
await sm.forceUnlock();
|
||||
expect(lockfile.unlock).toHaveBeenCalledWith(TEST_PATH);
|
||||
});
|
||||
|
||||
it('ignores ENOTACQUIRED error', async () => {
|
||||
const err = new Error('not locked');
|
||||
err.code = 'ENOTACQUIRED';
|
||||
lockfile.unlock.mockRejectedValue(err);
|
||||
await expect(sm.forceUnlock()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws other errors', async () => {
|
||||
lockfile.unlock.mockRejectedValue(new Error('other'));
|
||||
await expect(sm.forceUnlock()).rejects.toThrow('other');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user