Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
564
dashcaddy-api/__tests__/integration.test.js
Normal file
564
dashcaddy-api/__tests__/integration.test.js
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* Integration Tests
|
||||
*
|
||||
* Tests multi-component workflows and end-to-end scenarios
|
||||
* Validates that all DashCaddy components work together correctly
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Create test instance with isolated environment
|
||||
const testServicesFile = path.join(os.tmpdir(), `integration-services-${Date.now()}.json`);
|
||||
const testConfigFile = path.join(os.tmpdir(), `integration-config-${Date.now()}.json`);
|
||||
const testDnsCredsFile = path.join(os.tmpdir(), `integration-dns-${Date.now()}.json`);
|
||||
const testCaddyfile = path.join(os.tmpdir(), `integration-Caddyfile-${Date.now()}`);
|
||||
|
||||
// Set test environment
|
||||
process.env.SERVICES_FILE = testServicesFile;
|
||||
process.env.CONFIG_FILE = testConfigFile;
|
||||
process.env.DNS_CREDENTIALS_FILE = testDnsCredsFile;
|
||||
process.env.CADDYFILE_PATH = testCaddyfile;
|
||||
process.env.CADDY_ADMIN_URL = 'http://localhost:2019';
|
||||
process.env.ENABLE_HEALTH_CHECKER = 'false';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Initialize test files
|
||||
fs.writeFileSync(testServicesFile, '[]', 'utf8');
|
||||
fs.writeFileSync(testConfigFile, '{"domain": "test.local"}', 'utf8');
|
||||
fs.writeFileSync(testDnsCredsFile, '{}', 'utf8');
|
||||
fs.writeFileSync(testCaddyfile, '# Test Caddyfile\n', 'utf8');
|
||||
|
||||
// Require app after environment setup
|
||||
const app = require('../server');
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset state through the API to respect file locks
|
||||
await request(app).put('/api/services').send([]);
|
||||
fs.writeFileSync(testConfigFile, '{"domain": "test.local"}', 'utf8');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Cleanup test files
|
||||
try {
|
||||
fs.unlinkSync(testServicesFile);
|
||||
fs.unlinkSync(testConfigFile);
|
||||
fs.unlinkSync(testDnsCredsFile);
|
||||
fs.unlinkSync(testCaddyfile);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('End-to-End Service Deployment', () => {
|
||||
test('should complete full service lifecycle: add → configure → verify → delete', async () => {
|
||||
// Step 1: Add a new service
|
||||
const newService = {
|
||||
id: 'test-app',
|
||||
name: 'Test Application',
|
||||
logo: '/assets/test.png',
|
||||
url: 'https://test.test.local'
|
||||
};
|
||||
|
||||
const addRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send(newService);
|
||||
|
||||
expect(addRes.statusCode).toBe(200);
|
||||
expect(addRes.body.success).toBe(true);
|
||||
|
||||
// Step 2: Verify service appears in list
|
||||
const listRes = await request(app).get('/api/services');
|
||||
expect(listRes.statusCode).toBe(200);
|
||||
expect(listRes.body.length).toBe(1);
|
||||
expect(listRes.body[0].id).toBe('test-app');
|
||||
|
||||
// Step 3: Update service configuration
|
||||
const updatedServices = [{
|
||||
...newService,
|
||||
status: 'online',
|
||||
responseTime: 150
|
||||
}];
|
||||
|
||||
const updateRes = await request(app)
|
||||
.put('/api/services')
|
||||
.send(updatedServices);
|
||||
|
||||
expect(updateRes.statusCode).toBe(200);
|
||||
|
||||
// Step 4: Verify update
|
||||
const verifyRes = await request(app).get('/api/services');
|
||||
expect(verifyRes.body[0].status).toBe('online');
|
||||
|
||||
// Step 5: Delete service
|
||||
const deleteRes = await request(app).delete('/api/services/test-app');
|
||||
expect(deleteRes.statusCode).toBe(200);
|
||||
|
||||
// Step 6: Verify deletion
|
||||
const finalRes = await request(app).get('/api/services');
|
||||
expect(finalRes.body.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle app deployment workflow: template → configure → deploy', async () => {
|
||||
// Step 1: Get app template
|
||||
const templateRes = await request(app).get('/api/apps/templates/jellyfin');
|
||||
expect(templateRes.statusCode).toBe(200);
|
||||
expect(templateRes.body.success).toBe(true);
|
||||
const template = templateRes.body.template;
|
||||
|
||||
// Step 2: Configure app from template
|
||||
const appConfig = {
|
||||
id: 'jellyfin',
|
||||
name: template.name,
|
||||
logo: template.logo,
|
||||
port: 8096,
|
||||
subdomain: 'jellyfin'
|
||||
};
|
||||
|
||||
// Step 3: Add configured service
|
||||
const deployRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send(appConfig);
|
||||
|
||||
expect(deployRes.statusCode).toBe(200);
|
||||
|
||||
// Step 4: Verify service is listed
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body).toContainEqual(
|
||||
expect.objectContaining({ id: 'jellyfin' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Service Management', () => {
|
||||
test('should handle multiple services concurrently', async () => {
|
||||
// Deploy 5 services simultaneously (reduced from 10 to avoid overwhelming)
|
||||
const services = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `concurrent-${i}`,
|
||||
name: `Concurrent Service ${i}`,
|
||||
logo: `/assets/service-${i}.png`
|
||||
}));
|
||||
|
||||
const deployPromises = services.map(service =>
|
||||
request(app).post('/api/services').send(service)
|
||||
);
|
||||
|
||||
const results = await Promise.all(deployPromises);
|
||||
|
||||
// All deployments should succeed
|
||||
results.forEach((res, index) => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(`Service ${index} failed:`, res.body);
|
||||
}
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
// Verify all services are listed
|
||||
const listRes = await request(app).get('/api/services');
|
||||
expect(listRes.body.length).toBe(5);
|
||||
});
|
||||
|
||||
test('should handle bulk import and individual updates', async () => {
|
||||
// Step 1: Bulk import services
|
||||
const bulkServices = [
|
||||
{ id: 'plex', name: 'Plex' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin' },
|
||||
{ id: 'emby', name: 'Emby' }
|
||||
];
|
||||
|
||||
const importRes = await request(app)
|
||||
.put('/api/services')
|
||||
.send(bulkServices);
|
||||
|
||||
expect(importRes.statusCode).toBe(200);
|
||||
|
||||
// Step 2: Update individual service
|
||||
const updatedServices = [
|
||||
{ id: 'plex', name: 'Plex', status: 'online' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin' },
|
||||
{ id: 'emby', name: 'Emby' }
|
||||
];
|
||||
|
||||
await request(app).put('/api/services').send(updatedServices);
|
||||
|
||||
// Step 3: Verify specific service was updated
|
||||
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
const plexService = services.find(s => s.id === 'plex');
|
||||
expect(plexService.status).toBe('online');
|
||||
});
|
||||
|
||||
test('should maintain data consistency across operations', async () => {
|
||||
// Perform series of operations
|
||||
await request(app).post('/api/services').send({ id: 's1', name: 'Service 1' });
|
||||
await request(app).post('/api/services').send({ id: 's2', name: 'Service 2' });
|
||||
await request(app).post('/api/services').send({ id: 's3', name: 'Service 3' });
|
||||
|
||||
// Verify count
|
||||
let services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
expect(services.length).toBe(3);
|
||||
|
||||
// Delete one
|
||||
await request(app).delete('/api/services/s2');
|
||||
|
||||
// Verify count and content
|
||||
services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
expect(services.length).toBe(2);
|
||||
expect(services.find(s => s.id === 's2')).toBeUndefined();
|
||||
expect(services.find(s => s.id === 's1')).toBeDefined();
|
||||
expect(services.find(s => s.id === 's3')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Management Integration', () => {
|
||||
test('should coordinate config changes with service updates', async () => {
|
||||
// Step 1: Set initial config
|
||||
const config = {
|
||||
domain: 'example.local',
|
||||
theme: 'dark',
|
||||
enableHealthCheck: false
|
||||
};
|
||||
|
||||
const configRes = await request(app)
|
||||
.post('/api/config')
|
||||
.send(config);
|
||||
|
||||
expect(configRes.statusCode).toBe(200);
|
||||
|
||||
// Step 2: Add service that uses config
|
||||
const service = {
|
||||
id: 'test',
|
||||
name: 'Test Service',
|
||||
subdomain: 'test'
|
||||
};
|
||||
|
||||
await request(app).post('/api/services').send(service);
|
||||
|
||||
// Step 3: Verify config persists
|
||||
const getConfigRes = await request(app).get('/api/config');
|
||||
expect(getConfigRes.body.domain).toBe('example.local');
|
||||
|
||||
// Step 4: Update config
|
||||
const newConfig = { ...config, theme: 'light' };
|
||||
await request(app).post('/api/config').send(newConfig);
|
||||
|
||||
// Step 5: Verify service still exists and config updated
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
const configCheckRes = await request(app).get('/api/config');
|
||||
|
||||
expect(servicesRes.body.length).toBe(1);
|
||||
expect(configCheckRes.body.theme).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template Discovery and Deployment', () => {
|
||||
test('should list templates, select one, and deploy', async () => {
|
||||
// Step 1: Get all templates
|
||||
const templatesRes = await request(app).get('/api/apps/templates');
|
||||
expect(templatesRes.statusCode).toBe(200);
|
||||
expect(Object.keys(templatesRes.body.templates).length).toBeGreaterThan(50);
|
||||
|
||||
// Step 2: Verify categories exist (format may vary)
|
||||
expect(templatesRes.body).toHaveProperty('categories');
|
||||
const categories = templatesRes.body.categories;
|
||||
// Categories might be an array or object depending on implementation
|
||||
expect(categories).toBeTruthy();
|
||||
|
||||
// Step 3: Select a specific template
|
||||
const templateIds = Object.keys(templatesRes.body.templates);
|
||||
const firstTemplateId = templateIds[0];
|
||||
|
||||
const singleTemplateRes = await request(app)
|
||||
.get(`/api/apps/templates/${firstTemplateId}`);
|
||||
|
||||
expect(singleTemplateRes.statusCode).toBe(200);
|
||||
expect(singleTemplateRes.body.template).toHaveProperty('name');
|
||||
expect(singleTemplateRes.body.template).toHaveProperty('docker');
|
||||
|
||||
// Step 4: Deploy service from template
|
||||
const service = {
|
||||
id: firstTemplateId,
|
||||
name: singleTemplateRes.body.template.name,
|
||||
logo: singleTemplateRes.body.template.logo
|
||||
};
|
||||
|
||||
const deployRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send(service);
|
||||
|
||||
expect(deployRes.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should handle template with complex configuration', async () => {
|
||||
// Get a complex template (Plex has environment variables, volumes, etc.)
|
||||
const templateRes = await request(app).get('/api/apps/templates/plex');
|
||||
expect(templateRes.statusCode).toBe(200);
|
||||
|
||||
const template = templateRes.body.template;
|
||||
|
||||
// Verify template has complex config
|
||||
expect(template.docker).toHaveProperty('image');
|
||||
expect(template.docker).toHaveProperty('environment');
|
||||
expect(template.docker).toHaveProperty('volumes');
|
||||
|
||||
// Deploy with configuration
|
||||
const service = {
|
||||
id: 'plex-prod',
|
||||
name: 'Plex Production',
|
||||
logo: template.logo,
|
||||
port: 32400,
|
||||
subdomain: 'plex'
|
||||
};
|
||||
|
||||
const deployRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send(service);
|
||||
|
||||
expect(deployRes.statusCode).toBe(200);
|
||||
|
||||
// Verify service exists
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body).toContainEqual(
|
||||
expect.objectContaining({ id: 'plex-prod' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery and Resilience', () => {
|
||||
test('should recover from invalid service data', async () => {
|
||||
// Add valid service
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'valid', name: 'Valid Service' });
|
||||
|
||||
// Try to add invalid service
|
||||
const invalidRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'invalid' }); // Missing required 'name' field
|
||||
|
||||
expect(invalidRes.statusCode).toBe(400);
|
||||
|
||||
// Verify valid service still exists
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body.length).toBe(1);
|
||||
expect(servicesRes.body[0].id).toBe('valid');
|
||||
});
|
||||
|
||||
test('should handle file corruption gracefully', async () => {
|
||||
// Add some services
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 's1', name: 'Service 1' });
|
||||
|
||||
// Simulate file corruption (invalid JSON)
|
||||
fs.writeFileSync(testServicesFile, '{ invalid json }', 'utf8');
|
||||
|
||||
// API should handle this gracefully
|
||||
const res = await request(app).get('/api/services');
|
||||
|
||||
// Should either return error or empty array (depending on implementation)
|
||||
expect([200, 500]).toContain(res.statusCode);
|
||||
});
|
||||
|
||||
test('should maintain consistency during concurrent modifications', async () => {
|
||||
// Start with empty state
|
||||
const initialServices = [
|
||||
{ id: 'base1', name: 'Base 1' },
|
||||
{ id: 'base2', name: 'Base 2' }
|
||||
];
|
||||
|
||||
await request(app).put('/api/services').send(initialServices);
|
||||
|
||||
// Perform concurrent operations
|
||||
const operations = [
|
||||
request(app).post('/api/services').send({ id: 'new1', name: 'New 1' }),
|
||||
request(app).post('/api/services').send({ id: 'new2', name: 'New 2' }),
|
||||
request(app).delete('/api/services/base1'),
|
||||
request(app).post('/api/services').send({ id: 'new3', name: 'New 3' })
|
||||
];
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
// Verify final state is consistent
|
||||
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
|
||||
// Should have base2 + 3 new services = 4 total
|
||||
expect(services.length).toBe(4);
|
||||
expect(services.find(s => s.id === 'base1')).toBeUndefined();
|
||||
expect(services.find(s => s.id === 'base2')).toBeDefined();
|
||||
expect(services.find(s => s.id === 'new1')).toBeDefined();
|
||||
expect(services.find(s => s.id === 'new2')).toBeDefined();
|
||||
expect(services.find(s => s.id === 'new3')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Check Integration', () => {
|
||||
test('should verify API health before operations', async () => {
|
||||
// Check health
|
||||
const healthRes = await request(app).get('/api/health');
|
||||
expect(healthRes.statusCode).toBe(200);
|
||||
expect(healthRes.body.status).toBe('ok');
|
||||
|
||||
// Perform operation
|
||||
const addRes = await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'test', name: 'Test' });
|
||||
|
||||
expect(addRes.statusCode).toBe(200);
|
||||
|
||||
// Check health again
|
||||
const healthRes2 = await request(app).get('/api/health');
|
||||
expect(healthRes2.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-World Workflow Scenarios', () => {
|
||||
test('Scenario: User discovers and deploys multiple media apps', async () => {
|
||||
// Step 1: Browse templates
|
||||
const templatesRes = await request(app).get('/api/apps/templates');
|
||||
const templates = templatesRes.body.templates;
|
||||
|
||||
// Step 2: Find media apps
|
||||
const mediaApps = ['plex', 'jellyfin', 'emby'];
|
||||
const selectedApps = mediaApps.map(id => ({
|
||||
id,
|
||||
name: templates[id].name,
|
||||
logo: templates[id].logo
|
||||
}));
|
||||
|
||||
// Step 3: Deploy all media apps
|
||||
for (const serviceConfig of selectedApps) {
|
||||
const res = await request(app)
|
||||
.post('/api/services')
|
||||
.send(serviceConfig);
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
// Step 4: Verify all deployed
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body.length).toBe(3);
|
||||
|
||||
mediaApps.forEach(appId => {
|
||||
expect(servicesRes.body.find(s => s.id === appId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('Scenario: Admin configures system and imports existing services', async () => {
|
||||
// Step 1: Set system configuration
|
||||
const config = {
|
||||
domain: 'homelab.local',
|
||||
theme: 'dark',
|
||||
enableHealthCheck: true
|
||||
};
|
||||
|
||||
await request(app).post('/api/config').send(config);
|
||||
|
||||
// Step 2: Import existing services from backup
|
||||
const existingServices = [
|
||||
{ id: 'router', name: 'Router', logo: '/assets/router.png' },
|
||||
{ id: 'nas', name: 'NAS', logo: '/assets/nas.png' },
|
||||
{ id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' }
|
||||
];
|
||||
|
||||
await request(app).put('/api/services').send(existingServices);
|
||||
|
||||
// Step 3: Add new service
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'newapp', name: 'New App' });
|
||||
|
||||
// Step 4: Verify all services
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body.length).toBe(4);
|
||||
|
||||
// Step 5: Verify config persisted
|
||||
const configRes = await request(app).get('/api/config');
|
||||
expect(configRes.body.domain).toBe('homelab.local');
|
||||
});
|
||||
|
||||
test('Scenario: User reorganizes services (delete old, add new)', async () => {
|
||||
// Step 1: Start with existing services
|
||||
const oldServices = [
|
||||
{ id: 'old1', name: 'Old Service 1' },
|
||||
{ id: 'old2', name: 'Old Service 2' },
|
||||
{ id: 'keep', name: 'Keep This' }
|
||||
];
|
||||
|
||||
await request(app).put('/api/services').send(oldServices);
|
||||
|
||||
// Step 2: Delete old services
|
||||
await request(app).delete('/api/services/old1');
|
||||
await request(app).delete('/api/services/old2');
|
||||
|
||||
// Step 3: Add new services
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'new1', name: 'New Service 1' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'new2', name: 'New Service 2' });
|
||||
|
||||
// Step 4: Verify final state
|
||||
const servicesRes = await request(app).get('/api/services');
|
||||
expect(servicesRes.body.length).toBe(3);
|
||||
|
||||
const serviceIds = servicesRes.body.map(s => s.id);
|
||||
expect(serviceIds).toContain('keep');
|
||||
expect(serviceIds).toContain('new1');
|
||||
expect(serviceIds).toContain('new2');
|
||||
expect(serviceIds).not.toContain('old1');
|
||||
expect(serviceIds).not.toContain('old2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Persistence and State Management', () => {
|
||||
test('should persist data across multiple operations', async () => {
|
||||
// Create initial state
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'persistent', name: 'Persistent Service' });
|
||||
|
||||
// Read file directly
|
||||
const services1 = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
expect(services1.length).toBe(1);
|
||||
|
||||
// Modify through API
|
||||
await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: 'another', name: 'Another Service' });
|
||||
|
||||
// Read file again
|
||||
const services2 = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
expect(services2.length).toBe(2);
|
||||
|
||||
// Verify through API
|
||||
const apiRes = await request(app).get('/api/services');
|
||||
expect(apiRes.body.length).toBe(2);
|
||||
|
||||
// All three methods should show same data
|
||||
expect(services2).toEqual(apiRes.body);
|
||||
});
|
||||
|
||||
test('should handle rapid sequential operations', async () => {
|
||||
// Perform 10 rapid operations (sequential, not parallel)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/services')
|
||||
.send({ id: `rapid-${i}`, name: `Rapid ${i}` });
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(`Rapid operation ${i} failed:`, res.body);
|
||||
}
|
||||
expect(res.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
// Verify all 10 services exist
|
||||
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
|
||||
expect(services.length).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user