/** * 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); }); }); });