refactor: enterprise-grade project structure

- Move test files to tests/
- Archive session notes to docs/archive/
- Remove temp/diagnostic files
- Clean src/ to only contain production code
This commit is contained in:
Oussama Douhou
2026-01-10 12:32:54 +01:00
parent b83f253582
commit e617114310
15 changed files with 0 additions and 774 deletions

68
tests/test-api-formats.ts Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bun
/**
* Test script to verify Dokploy API endpoint formats
* Tests the three core operations needed for deployment
*/
import { createDokployClient } from './api/dokploy.js';
const TEST_PROJECT_NAME = `test-api-${Date.now()}`;
const TEST_APP_NAME = `test-app-${Date.now()}`;
console.log('═══════════════════════════════════════');
console.log(' Dokploy API Format Tests');
console.log('═══════════════════════════════════════\n');
try {
const client = createDokployClient();
// Test 1: project.create
console.log('📋 Test 1: project.create');
console.log('Request: { name, description }');
const project = await client.createProject(
TEST_PROJECT_NAME,
'API format test project'
);
console.log('Response format:');
console.log(JSON.stringify(project, null, 2));
console.log(`✅ Project created: ${project.projectId}\n`);
// Test 2: application.create
console.log('📋 Test 2: application.create');
console.log('Request: { name, appName, projectId, dockerImage, sourceType }');
const application = await client.createApplication(
TEST_APP_NAME,
project.projectId,
'nginx:alpine'
);
console.log('Response format:');
console.log(JSON.stringify(application, null, 2));
console.log(`✅ Application created: ${application.applicationId}\n`);
// Test 3: application.deploy
console.log('📋 Test 3: application.deploy');
console.log('Request: { applicationId }');
await client.deployApplication(application.applicationId);
console.log('Response: void (no return value)');
console.log(`✅ Deployment triggered\n`);
// Cleanup
console.log('🧹 Cleaning up test resources...');
await client.deleteApplication(application.applicationId);
console.log(`✅ Deleted application: ${application.applicationId}`);
await client.deleteProject(project.projectId);
console.log(`✅ Deleted project: ${project.projectId}`);
console.log('\n═══════════════════════════════════════');
console.log(' All API Format Tests Passed!');
console.log('═══════════════════════════════════════');
} catch (error) {
console.error('\n❌ Test failed:');
console.error(error);
if (error instanceof Error) {
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
}
process.exit(1);
}

64
tests/test-clients.ts Normal file
View File

@@ -0,0 +1,64 @@
import { createHetznerClient } from './api/hetzner';
import { createDokployClient } from './api/dokploy';
async function testHetznerClient() {
console.log('\n🔍 Testing Hetzner DNS Client...');
try {
const client = createHetznerClient();
const result = await client.testConnection();
if (result.success) {
console.log(`✅ Hetzner: ${result.message}`);
return true;
} else {
console.log(`❌ Hetzner: ${result.message}`);
return false;
}
} catch (error) {
console.log(`❌ Hetzner: ${error instanceof Error ? error.message : 'Unknown error'}`);
return false;
}
}
async function testDokployClient() {
console.log('\n🔍 Testing Dokploy Client...');
try {
const client = createDokployClient();
const result = await client.testConnection();
if (result.success) {
console.log(`✅ Dokploy: ${result.message}`);
return true;
} else {
console.log(`❌ Dokploy: ${result.message}`);
return false;
}
} catch (error) {
console.log(`❌ Dokploy: ${error instanceof Error ? error.message : 'Unknown error'}`);
return false;
}
}
async function main() {
console.log('═══════════════════════════════════════');
console.log(' AI Stack Deployer - API Client Tests');
console.log('═══════════════════════════════════════');
const hetznerOk = await testHetznerClient();
const dokployOk = await testDokployClient();
console.log('\n═══════════════════════════════════════');
console.log(' Test Summary');
console.log('═══════════════════════════════════════');
console.log(`Hetzner DNS: ${hetznerOk ? '✅ PASS' : '❌ FAIL'}`);
console.log(`Dokploy API: ${dokployOk ? '✅ PASS' : '❌ FAIL'}`);
console.log('═══════════════════════════════════════\n');
if (!hetznerOk || !dokployOk) {
process.exit(1);
}
}
main();

116
tests/test-deploy-persistent.ts Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bun
/**
* Deploy persistent resources for verification
* Skips health check and rollback - leaves resources in Dokploy
*/
import { createProductionDokployClient } from './api/dokploy-production.js';
const STACK_NAME = `verify-${Date.now()}`;
const DOCKER_IMAGE = 'nginx:alpine';
const DOMAIN_SUFFIX = 'ai.flexinit.nl';
console.log('═══════════════════════════════════════');
console.log(' PERSISTENT DEPLOYMENT TEST');
console.log('═══════════════════════════════════════\n');
async function main() {
const client = createProductionDokployClient();
const projectName = `ai-stack-${STACK_NAME}`;
console.log(`Deploying: ${STACK_NAME}`);
console.log(`Project: ${projectName}`);
console.log(`URL: https://${STACK_NAME}.${DOMAIN_SUFFIX}\n`);
// Phase 1: Create Project
console.log('[1/6] Creating project...');
const { project, environment } = await client.createProject(
projectName,
`Verification deployment for ${STACK_NAME}`
);
console.log(`✅ Project: ${project.projectId}`);
console.log(`✅ Environment: ${environment.environmentId}\n`);
// Phase 2: Create Application
console.log('[2/6] Creating application...');
const application = await client.createApplication(
`opencode-${STACK_NAME}`,
environment.environmentId
);
console.log(`✅ Application: ${application.applicationId}\n`);
// Phase 3: Configure Docker Image
console.log('[3/6] Configuring Docker image...');
await client.updateApplication(application.applicationId, {
dockerImage: DOCKER_IMAGE,
sourceType: 'docker',
});
console.log(`✅ Configured: ${DOCKER_IMAGE}\n`);
// Phase 4: Create Domain
console.log('[4/6] Creating domain...');
const domain = await client.createDomain(
`${STACK_NAME}.${DOMAIN_SUFFIX}`,
application.applicationId,
true,
80
);
console.log(`✅ Domain: ${domain.domainId}`);
console.log(`✅ Host: ${domain.host}\n`);
// Phase 5: Trigger Deployment
console.log('[5/6] Triggering deployment...');
await client.deployApplication(application.applicationId);
console.log(`✅ Deployment triggered\n`);
// Phase 6: Summary
console.log('[6/6] Deployment complete!\n');
console.log('═══════════════════════════════════════');
console.log(' DEPLOYMENT SUCCESSFUL');
console.log('═══════════════════════════════════════\n');
console.log(`📦 Resources:`);
console.log(` Project ID: ${project.projectId}`);
console.log(` Environment ID: ${environment.environmentId}`);
console.log(` Application ID: ${application.applicationId}`);
console.log(` Domain ID: ${domain.domainId}`);
console.log(`\n🌐 URLs:`);
console.log(` Application: https://${STACK_NAME}.${DOMAIN_SUFFIX}`);
console.log(` Dokploy UI: https://app.flexinit.nl/project/${project.projectId}`);
console.log(`\n⏱ Note: Application will be accessible in 1-2 minutes (SSL provisioning)`);
console.log(`\n🧹 Cleanup command:`);
console.log(` source .env && curl -X POST -H "x-api-key: \${DOKPLOY_API_TOKEN}" \\`);
console.log(` https://app.flexinit.nl/api/application.delete \\`);
console.log(` -H "Content-Type: application/json" \\`);
console.log(` -d '{"applicationId":"${application.applicationId}"}'`);
console.log('\n');
// Output machine-readable format for verification
const output = {
success: true,
stackName: STACK_NAME,
resources: {
projectId: project.projectId,
environmentId: environment.environmentId,
applicationId: application.applicationId,
domainId: domain.domainId,
},
url: `https://${STACK_NAME}.${DOMAIN_SUFFIX}`,
dokployUrl: `https://app.flexinit.nl/project/${project.projectId}`,
};
console.log('JSON OUTPUT:');
console.log(JSON.stringify(output, null, 2));
process.exit(0);
}
main().catch(error => {
console.error('\n❌ Deployment failed:', error);
process.exit(1);
});

177
tests/test-deployment-proof.ts Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bun
/**
* Deployment Proof Test
*
* Executes a complete deployment and captures evidence at each phase.
* Does not fail on health check timeout (SSL provisioning can take time).
* Does not cleanup - leaves resources for manual verification.
*/
import { createProductionDokployClient } from './api/dokploy-production.js';
import { ProductionDeployer } from './orchestrator/production-deployer.js';
const TEST_STACK_NAME = `proof-${Date.now()}`;
const DOCKER_IMAGE = 'nginx:alpine'; // Fast to deploy
const DOMAIN_SUFFIX = process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl';
console.log('═══════════════════════════════════════');
console.log(' DEPLOYMENT PROOF TEST');
console.log('═══════════════════════════════════════\n');
console.log(`Configuration:`);
console.log(` Stack Name: ${TEST_STACK_NAME}`);
console.log(` Docker Image: ${DOCKER_IMAGE}`);
console.log(` Domain: ${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
console.log(` Expected URL: https://${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
console.log();
async function main() {
const client = createProductionDokployClient();
const deployer = new ProductionDeployer(client);
console.log('🚀 Starting deployment...\n');
const result = await deployer.deploy({
stackName: TEST_STACK_NAME,
dockerImage: DOCKER_IMAGE,
domainSuffix: DOMAIN_SUFFIX,
port: 80,
healthCheckTimeout: 30000, // 30 seconds only (don't wait forever for SSL)
healthCheckInterval: 5000,
});
console.log('\n═══════════════════════════════════════');
console.log(' DEPLOYMENT RESULT');
console.log('═══════════════════════════════════════\n');
console.log(`Final Phase: ${result.state.phase}`);
console.log(`Status: ${result.state.status}`);
console.log(`Progress: ${result.state.progress}%`);
console.log(`Message: ${result.state.message}`);
if (result.state.url) {
console.log(`\n🌐 Application URL: ${result.state.url}`);
}
console.log(`\n📦 Resources Created:`);
console.log(` ✓ Project ID: ${result.state.resources.projectId || 'NONE'}`);
console.log(` ✓ Environment ID: ${result.state.resources.environmentId || 'NONE'}`);
console.log(` ✓ Application ID: ${result.state.resources.applicationId || 'NONE'}`);
console.log(` ✓ Domain ID: ${result.state.resources.domainId || 'NONE'}`);
console.log(`\n⏱ Timestamps:`);
console.log(` Started: ${result.state.timestamps.started}`);
console.log(` Completed: ${result.state.timestamps.completed || 'IN PROGRESS'}`);
if (result.state.timestamps.completed && result.state.timestamps.started) {
const start = new Date(result.state.timestamps.started).getTime();
const end = new Date(result.state.timestamps.completed).getTime();
const duration = ((end - start) / 1000).toFixed(2);
console.log(` Duration: ${duration}s`);
}
if (result.state.error) {
console.log(`\n⚠ Error Details:`);
console.log(` Phase: ${result.state.error.phase}`);
console.log(` Message: ${result.state.error.message}`);
}
console.log(`\n🔄 Circuit Breaker: ${client.getCircuitBreakerState()}`);
console.log(`\n═══════════════════════════════════════`);
console.log(' PHASE EXECUTION LOG');
console.log('═══════════════════════════════════════\n');
const logs = client.getLogs();
let phaseNum = 1;
let lastPhase = '';
logs.forEach(log => {
if (log.phase !== lastPhase) {
console.log(`\n[PHASE ${phaseNum}] ${log.phase.toUpperCase()}`);
phaseNum++;
lastPhase = log.phase;
}
const level = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : '✅';
const duration = log.duration_ms ? ` (${log.duration_ms}ms)` : '';
console.log(` ${level} ${log.action}: ${log.message}${duration}`);
if (log.error) {
console.log(` ↳ Error: ${log.error.message}`);
}
});
// Check deployment success criteria
console.log(`\n═══════════════════════════════════════`);
console.log(' SUCCESS CRITERIA');
console.log('═══════════════════════════════════════\n');
const checks = {
'Project Created': !!result.state.resources.projectId,
'Environment Retrieved': !!result.state.resources.environmentId,
'Application Created': !!result.state.resources.applicationId,
'Domain Configured': !!result.state.resources.domainId,
'Deployment Triggered': result.state.phase !== 'creating_domain',
'URL Generated': !!result.state.url,
};
let passCount = 0;
Object.entries(checks).forEach(([name, passed]) => {
console.log(` ${passed ? '✅' : '❌'} ${name}`);
if (passed) passCount++;
});
const healthCheckNote = result.state.error?.phase === 'verifying_health'
? '\n Note: Health check timeout is expected for new SSL certificates'
: '';
console.log(`\n Score: ${passCount}/${Object.keys(checks).length} checks passed${healthCheckNote}`);
console.log(`\n═══════════════════════════════════════`);
console.log(' VERIFICATION INSTRUCTIONS');
console.log('═══════════════════════════════════════\n');
if (result.state.resources.projectId) {
console.log(`1. Verify in Dokploy UI:`);
console.log(` https://app.flexinit.nl/project/${result.state.resources.projectId}`);
console.log();
}
if (result.state.resources.applicationId) {
console.log(`2. Check application status:`);
console.log(` https://app.flexinit.nl/project/${result.state.resources.projectId}/services/application/${result.state.resources.applicationId}`);
console.log();
}
if (result.state.url) {
console.log(`3. Test application (may take 1-2 min for SSL):`);
console.log(` ${result.state.url}`);
console.log();
}
console.log(`4. Cleanup command:`);
console.log(` curl -X POST -H "x-api-key: \${DOKPLOY_API_TOKEN}" \\`);
console.log(` https://app.flexinit.nl/api/application.delete \\`);
console.log(` -d '{"applicationId":"${result.state.resources.applicationId}"}'`);
console.log();
// Determine overall success
const corePhases = passCount >= 5; // At least 5/6 core checks
const noBlockingErrors = !result.state.error || result.state.error.phase === 'verifying_health';
console.log(`\n═══════════════════════════════════════`);
if (corePhases && noBlockingErrors) {
console.log(' ✅ DEPLOYMENT WORKING - ALL CORE PHASES PASSED');
} else {
console.log(' ❌ DEPLOYMENT BLOCKED');
}
console.log('═══════════════════════════════════════\n');
process.exit(corePhases && noBlockingErrors ? 0 : 1);
}
main().catch(error => {
console.error('\n❌ Fatal error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bun
/**
* Production Deployment Integration Test
*
* Tests the complete deployment flow with real Dokploy API:
* - Project creation
* - Environment retrieval
* - Application creation
* - Configuration
* - Domain setup
* - Deployment
* - Health verification
* - Cleanup/Rollback
*/
import { createProductionDokployClient } from './api/dokploy-production.js';
import { ProductionDeployer } from './orchestrator/production-deployer.js';
console.log('═══════════════════════════════════════');
console.log(' Production Deployment Integration Test');
console.log('═══════════════════════════════════════\n');
const TEST_STACK_NAME = `test-prod-${Date.now()}`;
const DOCKER_IMAGE = process.env.STACK_IMAGE || 'nginx:alpine';
const DOMAIN_SUFFIX = process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl';
console.log(`Test Configuration:`);
console.log(` Stack Name: ${TEST_STACK_NAME}`);
console.log(` Docker Image: ${DOCKER_IMAGE}`);
console.log(` Domain: ${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
console.log();
async function main() {
const client = createProductionDokployClient();
const deployer = new ProductionDeployer(client);
console.log('🚀 Starting deployment...\n');
const result = await deployer.deploy({
stackName: TEST_STACK_NAME,
dockerImage: DOCKER_IMAGE,
domainSuffix: DOMAIN_SUFFIX,
port: 80, // nginx default port
healthCheckTimeout: 120000, // 2 minutes
healthCheckInterval: 5000, // 5 seconds
});
console.log('\n═══════════════════════════════════════');
console.log(' Deployment Result');
console.log('═══════════════════════════════════════\n');
console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILURE'}`);
console.log(`Deployment ID: ${result.state.id}`);
console.log(`Final Phase: ${result.state.phase}`);
console.log(`Progress: ${result.state.progress}%`);
console.log(`Message: ${result.state.message}`);
if (result.state.url) {
console.log(`URL: ${result.state.url}`);
}
console.log(`\nResources Created:`);
console.log(` Project ID: ${result.state.resources.projectId || 'N/A'}`);
console.log(` Environment ID: ${result.state.resources.environmentId || 'N/A'}`);
console.log(` Application ID: ${result.state.resources.applicationId || 'N/A'}`);
console.log(` Domain ID: ${result.state.resources.domainId || 'N/A'}`);
console.log(`\nTimestamps:`);
console.log(` Started: ${result.state.timestamps.started}`);
console.log(` Completed: ${result.state.timestamps.completed || 'N/A'}`);
if (result.state.error) {
console.log(`\nError Details:`);
console.log(` Phase: ${result.state.error.phase}`);
console.log(` Message: ${result.state.error.message}`);
if (result.state.error.code) {
console.log(` Code: ${result.state.error.code}`);
}
}
console.log(`\nCircuit Breaker State: ${client.getCircuitBreakerState()}`);
console.log(`\n═══════════════════════════════════════`);
console.log(' Deployment Logs');
console.log('═══════════════════════════════════════\n');
const logs = client.getLogs();
logs.forEach(log => {
const level = log.level.toUpperCase().padEnd(5);
const phase = log.phase.padEnd(15);
const action = log.action.padEnd(10);
const duration = log.duration_ms ? ` (${log.duration_ms}ms)` : '';
console.log(`[${level}] ${phase} ${action} ${log.message}${duration}`);
if (log.error) {
console.log(` Error: ${log.error.message}`);
}
});
// Cleanup test resources
if (result.success && result.state.resources.applicationId) {
console.log('\n🧹 Cleaning up test resources...');
try {
await client.deleteApplication(result.state.resources.applicationId);
console.log('✅ Test application deleted');
if (result.state.resources.projectId) {
await client.deleteProject(result.state.resources.projectId);
console.log('✅ Test project deleted');
}
} catch (error) {
console.error('❌ Cleanup failed:', error);
}
}
console.log('\n═══════════════════════════════════════');
console.log(result.success ? ' ✅ Test PASSED' : ' ❌ Test FAILED');
console.log('═══════════════════════════════════════\n');
process.exit(result.success ? 0 : 1);
}
main().catch(error => {
console.error('\n❌ Test execution failed:', error);
process.exit(1);
});

103
tests/validation.test.ts Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bun
/**
* Unit tests for validation logic
* Tests name validation without external dependencies
*/
import { expect, test, describe } from 'bun:test';
// Extracted validation function for testing
function validateStackName(name: string, reservedNames: string[] = []): { valid: boolean; error?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmedName = name.trim().toLowerCase();
if (trimmedName.length < 3 || trimmedName.length > 20) {
return { valid: false, error: 'Name must be between 3 and 20 characters' };
}
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' };
}
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
return { valid: false, error: 'Name cannot start or end with a hyphen' };
}
if (reservedNames.includes(trimmedName)) {
return { valid: false, error: `Name "${trimmedName}" is reserved` };
}
return { valid: true };
}
describe('validateStackName', () => {
const reservedNames = ['admin', 'api', 'www', 'root', 'system', 'test', 'demo', 'portal'];
test('accepts valid names', () => {
expect(validateStackName('john', reservedNames).valid).toBe(true);
expect(validateStackName('alice-123', reservedNames).valid).toBe(true);
expect(validateStackName('my-stack', reservedNames).valid).toBe(true);
expect(validateStackName('abc', reservedNames).valid).toBe(true);
expect(validateStackName('12345678901234567890', reservedNames).valid).toBe(true); // exactly 20 chars
});
test('rejects empty or null names', () => {
expect(validateStackName('', reservedNames).valid).toBe(false);
expect(validateStackName(' ', reservedNames).valid).toBe(false);
});
test('rejects names too short or too long', () => {
expect(validateStackName('ab', reservedNames).error).toContain('between 3 and 20');
expect(validateStackName('a'.repeat(21), reservedNames).error).toContain('between 3 and 20');
});
test('rejects invalid characters', () => {
const result1 = validateStackName('john_doe', reservedNames);
expect(result1.valid).toBe(false);
expect(result1.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
const result2 = validateStackName('john.doe', reservedNames);
expect(result2.valid).toBe(false);
expect(result2.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
const result3 = validateStackName('john doe', reservedNames);
expect(result3.valid).toBe(false);
expect(result3.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
const result4 = validateStackName('john@example', reservedNames);
expect(result4.valid).toBe(false);
expect(result4.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
});
test('rejects names with leading or trailing hyphens', () => {
expect(validateStackName('-john', reservedNames).error).toContain('cannot start or end');
expect(validateStackName('john-', reservedNames).error).toContain('cannot start or end');
expect(validateStackName('-john-', reservedNames).error).toContain('cannot start or end');
});
test('rejects reserved names', () => {
expect(validateStackName('admin', reservedNames).error).toContain('reserved');
expect(validateStackName('api', reservedNames).error).toContain('reserved');
expect(validateStackName('www', reservedNames).error).toContain('reserved');
expect(validateStackName('root', reservedNames).error).toContain('reserved');
});
test('normalizes names (lowercase, trim)', () => {
// Uppercase input gets normalized to lowercase
const result1 = validateStackName(' John ', reservedNames);
expect(result1.valid).toBe(true); // passes after trim and lowercase
// Already lowercase with spaces - should pass
const result2 = validateStackName(' john ', reservedNames);
expect(result2.valid).toBe(true);
// All uppercase - should pass after normalization
const result3 = validateStackName('MYSTACK', reservedNames);
expect(result3.valid).toBe(true);
});
});
console.log('\n✅ Running unit tests...\n');