Files
dashcaddy/dashcaddy-api/__tests__/state-manager.test.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

250 lines
7.0 KiB
JavaScript

/**
* State Manager Tests
*
* Tests the thread-safe state management with file locking
*/
const StateManager = require('../state-manager');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
// Dedicated temp subdirectory avoids cross-test file collisions
const testDir = path.join(os.tmpdir(), `state-manager-test-${Date.now()}`);
const testFile = path.join(testDir, 'test-state.json');
describe('StateManager', () => {
let stateManager;
beforeAll(async () => {
await fs.mkdir(testDir, { recursive: true });
});
beforeEach(async () => {
// Clean up test file + stale lockfiles
for (const f of [testFile, `${testFile}.lock`]) {
try { await fs.unlink(f); } catch (e) { /* ignore */ }
}
stateManager = new StateManager(testFile, {
lockRetries: 20,
lockRetryInterval: 50,
lockTimeout: 15000
});
});
afterEach(async () => {
for (const f of [testFile, `${testFile}.lock`]) {
try { await fs.unlink(f); } catch (e) { /* ignore */ }
}
});
afterAll(async () => {
try { await fs.rm(testDir, { recursive: true }); } catch (e) { /* ignore */ }
});
describe('Basic Operations', () => {
test('creates file with empty array if not exists', async () => {
const data = await stateManager.read();
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(0);
});
test('write and read roundtrip', async () => {
const testData = [
{ id: '1', name: 'Test Service 1' },
{ id: '2', name: 'Test Service 2' }
];
await stateManager.write(testData);
const data = await stateManager.read();
expect(data).toEqual(testData);
});
test('update with callback function', async () => {
await stateManager.write([{ id: '1', name: 'Service 1' }]);
const updated = await stateManager.update(items => {
items.push({ id: '2', name: 'Service 2' });
return items;
});
expect(updated.length).toBe(2);
expect(updated[1].name).toBe('Service 2');
});
});
describe('Convenience Methods', () => {
test('addItem adds to array', async () => {
await stateManager.addItem({ id: '1', name: 'Service 1' });
await stateManager.addItem({ id: '2', name: 'Service 2' });
const items = await stateManager.read();
expect(items.length).toBe(2);
});
test('removeItem removes by ID', async () => {
await stateManager.write([
{ id: '1', name: 'Service 1' },
{ id: '2', name: 'Service 2' },
{ id: '3', name: 'Service 3' }
]);
await stateManager.removeItem('2');
const items = await stateManager.read();
expect(items.length).toBe(2);
expect(items.find(i => i.id === '2')).toBeUndefined();
});
test('updateItem updates by ID', async () => {
await stateManager.write([
{ id: '1', name: 'Service 1', status: 'offline' }
]);
await stateManager.updateItem('1', { status: 'online' });
const item = await stateManager.findItem('1');
expect(item.status).toBe('online');
expect(item.name).toBe('Service 1'); // Unchanged
});
test('findItem returns null for non-existent ID', async () => {
await stateManager.write([{ id: '1', name: 'Service 1' }]);
const item = await stateManager.findItem('999');
expect(item).toBeNull();
});
});
describe('Concurrent Access', () => {
test('concurrent writes do not corrupt data', async () => {
// Start with empty array
await stateManager.write([]);
// Simulate 10 concurrent writes
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(
stateManager.update(items => {
items.push({ id: `service-${i}`, name: `Service ${i}` });
return items;
})
);
}
await Promise.all(promises);
// Verify all items were added
const items = await stateManager.read();
expect(items.length).toBe(10);
// Verify JSON is valid (not corrupted)
const fileContent = await fs.readFile(testFile, 'utf8');
expect(() => JSON.parse(fileContent)).not.toThrow();
});
test('concurrent reads while writing', async () => {
await stateManager.write([{ id: '1', name: 'Initial' }]);
const writePromise = stateManager.update(async items => {
// Simulate slow operation
await new Promise(resolve => setTimeout(resolve, 100));
items.push({ id: '2', name: 'New' });
return items;
});
const readPromises = [];
for (let i = 0; i < 5; i++) {
readPromises.push(stateManager.read());
}
await Promise.all([writePromise, ...readPromises]);
// Should complete without errors
const final = await stateManager.read();
expect(final.length).toBe(2);
});
});
describe('Error Handling', () => {
test('throws error on invalid JSON', async () => {
// Write invalid JSON directly
await fs.writeFile(testFile, '{invalid json', 'utf8');
await expect(stateManager.read()).rejects.toThrow();
});
test('handles missing file gracefully', async () => {
await fs.unlink(testFile);
const data = await stateManager.read();
expect(Array.isArray(data)).toBe(true);
});
test('update callback errors are caught', async () => {
await expect(
stateManager.update(() => {
throw new Error('Test error');
})
).rejects.toThrow('Test error');
});
});
describe('Lock Management', () => {
test('isLocked detects locked state', async () => {
const lockfile = require('proper-lockfile');
// Manually lock the file
const release = await lockfile.lock(testFile);
const locked = await stateManager.isLocked();
expect(locked).toBe(true);
await release();
const unlocked = await stateManager.isLocked();
expect(unlocked).toBe(false);
});
test('forceUnlock removes stuck lock', async () => {
const lockfile = require('proper-lockfile');
// Create a stuck lock
await lockfile.lock(testFile);
await stateManager.forceUnlock();
// Should be able to write now
await expect(stateManager.write([])).resolves.not.toThrow();
});
});
describe('Performance', () => {
test('handles large datasets efficiently', async () => {
const largeDataset = [];
for (let i = 0; i < 1000; i++) {
largeDataset.push({
id: `service-${i}`,
name: `Service ${i}`,
url: `https://service-${i}.example.com`,
status: 'online'
});
}
const startTime = Date.now();
await stateManager.write(largeDataset);
const writeTime = Date.now() - startTime;
const readStart = Date.now();
const data = await stateManager.read();
const readTime = Date.now() - readStart;
expect(data.length).toBe(1000);
expect(writeTime).toBeLessThan(1000); // Should write in <1s
expect(readTime).toBeLessThan(100); // Should read in <100ms
});
});
});