250 lines
7.0 KiB
JavaScript
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
|
|
});
|
|
});
|
|
});
|