f6a79c815e
- Add S3/MinIO storage adapter with multipart upload support - Implement database backup service for SQLite and PostgreSQL - Create backup manifest generator for tracking backup contents - Enhance backup service with S3 integration and incremental backups - Add restore service with safety measures and rollback capability - Create comprehensive test suite for all backup functionality - Add admin API endpoints for backup/restore management - Implement frontend UI with dashboard, configuration, and restore wizard - Add roadmap section to README with implemented backup feature This implementation provides: - Multiple backup destinations (local, rsync, S3/MinIO) - Intelligent change detection to minimize backup frequency - Full database backups with compression - Manifest-based restore with integrity validation - Pre-restore safety backups with rollback - Comprehensive error handling and monitoring - User-friendly admin interface 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
751 lines
23 KiB
JavaScript
751 lines
23 KiB
JavaScript
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
|
const mockFs = require('mock-fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { EventEmitter } = require('events');
|
|
|
|
// Mock dependencies before requiring the module
|
|
jest.mock('../../src/database/db');
|
|
jest.mock('../../src/utils/logger');
|
|
jest.mock('../../src/services/emailProcessor');
|
|
jest.mock('node-cron');
|
|
jest.mock('../../src/services/backupManifest');
|
|
jest.mock('../../src/services/storage/s3Storage');
|
|
|
|
const backupService = require('../../src/services/backupService');
|
|
const { db } = require('../../src/database/db');
|
|
const logger = require('../../src/utils/logger');
|
|
const { queueEmail } = require('../../src/services/emailProcessor');
|
|
const cron = require('node-cron');
|
|
const backupManifest = require('../../src/services/backupManifest');
|
|
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
|
|
|
|
describe('Enhanced Backup Service Tests', () => {
|
|
let mockDb;
|
|
let mockS3Client;
|
|
let mockCronJob;
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Mock database
|
|
mockDb = {
|
|
select: jest.fn().mockReturnThis(),
|
|
where: jest.fn().mockReturnThis(),
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
limit: jest.fn().mockReturnThis(),
|
|
first: jest.fn(),
|
|
insert: jest.fn(),
|
|
update: jest.fn(),
|
|
delete: jest.fn()
|
|
};
|
|
db.mockReturnValue(mockDb);
|
|
|
|
// Mock cron job
|
|
mockCronJob = {
|
|
stop: jest.fn()
|
|
};
|
|
cron.schedule.mockReturnValue(mockCronJob);
|
|
|
|
// Mock S3 client
|
|
mockS3Client = {
|
|
testConnection: jest.fn().mockResolvedValue(true),
|
|
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
|
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
|
|
download: jest.fn().mockResolvedValue(),
|
|
exists: jest.fn().mockResolvedValue(false),
|
|
delete: jest.fn().mockResolvedValue(),
|
|
list: jest.fn().mockResolvedValue({ Contents: [] })
|
|
};
|
|
S3StorageAdapter.mockImplementation(() => mockS3Client);
|
|
|
|
// Mock backup manifest
|
|
backupManifest.generateManifest = jest.fn().mockResolvedValue({
|
|
backup: { id: 'test-backup-123' },
|
|
version: '2.0'
|
|
});
|
|
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
|
|
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
|
|
backupManifest.validateManifest = jest.fn();
|
|
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
|
|
|
|
// Mock logger
|
|
logger.info = jest.fn();
|
|
logger.error = jest.fn();
|
|
logger.warn = jest.fn();
|
|
logger.debug = jest.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockFs.restore();
|
|
});
|
|
|
|
describe('getBackupConfig', () => {
|
|
it('should retrieve and parse backup configuration from database', async () => {
|
|
const mockSettings = [
|
|
{ setting_key: 'backup_enabled', setting_value: 'true' },
|
|
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
|
|
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
|
|
{ setting_key: 'backup_retention_days', setting_value: '30' }
|
|
];
|
|
|
|
mockDb.select.mockResolvedValue(mockSettings);
|
|
|
|
const config = await backupService.getBackupConfig();
|
|
|
|
expect(config).toEqual({
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_retention_days: 30
|
|
});
|
|
|
|
expect(db).toHaveBeenCalledWith('app_settings');
|
|
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
|
|
});
|
|
|
|
it('should handle JSON parse errors gracefully', async () => {
|
|
const mockSettings = [
|
|
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
|
|
];
|
|
|
|
mockDb.select.mockResolvedValue(mockSettings);
|
|
|
|
const config = await backupService.getBackupConfig();
|
|
|
|
expect(config).toEqual({
|
|
backup_enabled: 'invalid-json'
|
|
});
|
|
});
|
|
|
|
it('should return null on database error', async () => {
|
|
mockDb.select.mockRejectedValue(new Error('Database error'));
|
|
|
|
const config = await backupService.getBackupConfig();
|
|
|
|
expect(config).toBeNull();
|
|
expect(logger.error).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('S3 Backup Functionality', () => {
|
|
beforeEach(() => {
|
|
// Mock file system
|
|
mockFs({
|
|
'/storage/events/active/event1': {
|
|
'photo1.jpg': Buffer.from('photo1 content'),
|
|
'photo2.jpg': Buffer.from('photo2 content')
|
|
},
|
|
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
|
|
'/storage/thumbnails': {
|
|
'thumb1.jpg': Buffer.from('thumb1 content')
|
|
},
|
|
'/storage/uploads': {
|
|
'logo.png': Buffer.from('logo content')
|
|
}
|
|
});
|
|
|
|
process.env.STORAGE_PATH = '/storage';
|
|
});
|
|
|
|
it('should perform S3 backup with correct configuration', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_region: 'us-east-1',
|
|
backup_s3_endpoint: 'https://s3.amazonaws.com',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret',
|
|
backup_include_archived: true,
|
|
backup_max_file_size_mb: 100
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.where.mockReturnThis();
|
|
mockDb.first.mockResolvedValue(null);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
|
type: 'sqlite',
|
|
backupFile: null,
|
|
hasChanged: true
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(S3StorageAdapter).toHaveBeenCalledWith({
|
|
bucket: 'test-bucket',
|
|
region: 'us-east-1',
|
|
endpoint: 'https://s3.amazonaws.com',
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret',
|
|
forcePathStyle: false,
|
|
sslEnabled: true,
|
|
maxRetries: 3,
|
|
retryDelay: 1000
|
|
});
|
|
|
|
expect(mockS3Client.testConnection).toHaveBeenCalled();
|
|
expect(mockS3Client.upload).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle S3 upload failures gracefully', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
|
|
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
|
|
status: 'failed',
|
|
error_message: expect.stringContaining('Connection failed')
|
|
}));
|
|
});
|
|
|
|
it('should skip unchanged files in incremental backup', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret',
|
|
backup_incremental: true
|
|
};
|
|
|
|
// Mock existing file state
|
|
mockDb.first.mockImplementation((query) => {
|
|
if (query === undefined) {
|
|
return Promise.resolve({
|
|
file_path: 'events/active/event1/photo1.jpg',
|
|
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
|
|
});
|
|
}
|
|
return Promise.resolve(null);
|
|
});
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
await backupService.runBackup();
|
|
|
|
// Should skip unchanged file
|
|
const uploadCalls = mockS3Client.upload.mock.calls;
|
|
const photo1Uploaded = uploadCalls.some(call =>
|
|
call[1].includes('photo1.jpg')
|
|
);
|
|
expect(photo1Uploaded).toBe(false);
|
|
});
|
|
|
|
it('should include database backup when configured', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret',
|
|
backup_include_database: true
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
|
|
type: 'sqlite',
|
|
backupFile: '/backup/db-backup.sql',
|
|
size: 1024000,
|
|
checksum: 'abc123',
|
|
hasChanged: false
|
|
});
|
|
|
|
// Mock database backup file
|
|
mockFs({
|
|
'/storage/events/active': {},
|
|
'/backup/db-backup.sql': Buffer.from('database backup content')
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
// Verify database backup was uploaded
|
|
const uploadCalls = mockS3Client.upload.mock.calls;
|
|
const dbBackupUploaded = uploadCalls.some(call =>
|
|
call[1].includes('database/db-backup.sql')
|
|
);
|
|
expect(dbBackupUploaded).toBe(true);
|
|
});
|
|
|
|
it('should validate required S3 configuration', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket'
|
|
// Missing access key and secret key
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'S3 backup failed:',
|
|
expect.objectContaining({
|
|
message: expect.stringContaining('S3 backup configuration incomplete')
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Manifest Generation', () => {
|
|
it('should generate and save manifest for successful backup', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 'local',
|
|
backup_destination_path: '/backup',
|
|
backup_manifest_format: 'json'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
mockFs({
|
|
'/storage/events/active/event1': {
|
|
'photo1.jpg': Buffer.from('photo1 content')
|
|
},
|
|
'/backup': {}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
backupType: 'full',
|
|
backupPath: '/backup',
|
|
format: 'json'
|
|
})
|
|
);
|
|
|
|
expect(backupManifest.saveManifest).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should generate incremental manifest when parent exists', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 'local',
|
|
backup_destination_path: '/backup'
|
|
};
|
|
|
|
const lastBackup = {
|
|
id: 1,
|
|
manifest_path: '/backup/manifests/previous.json',
|
|
manifest_id: 'previous-backup-123'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([2]);
|
|
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
|
|
mockDb.orderBy.mockReturnThis();
|
|
mockDb.where.mockReturnThis();
|
|
mockDb.whereNot = jest.fn().mockReturnThis();
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
mockFs({
|
|
'/storage/events/active': {},
|
|
'/backup': {}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
|
|
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should upload manifest to S3 for S3 backups', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret',
|
|
backup_manifest_format: 'yaml'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
const manifest = {
|
|
backup: { id: 'backup-123' },
|
|
version: '2.0'
|
|
};
|
|
backupManifest.generateManifest.mockResolvedValue(manifest);
|
|
|
|
mockFs({
|
|
'/storage/events/active': {},
|
|
'/storage/temp': {}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
// Verify manifest was uploaded to S3
|
|
const uploadCalls = mockS3Client.upload.mock.calls;
|
|
const manifestUploaded = uploadCalls.some(call =>
|
|
call[1].includes('manifests/backup-manifest-backup-123.yaml')
|
|
);
|
|
expect(manifestUploaded).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Backward Compatibility', () => {
|
|
it('should support local backup destination', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 'local',
|
|
backup_destination_path: '/backup/local'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
mockFs({
|
|
'/storage/events/active/event1': {
|
|
'photo1.jpg': Buffer.from('photo1 content')
|
|
},
|
|
'/backup/local': {}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
// Verify files were copied to local destination
|
|
const fs = require('fs');
|
|
const destPath = '/backup/local/events/active/event1/photo1.jpg';
|
|
expect(fs.existsSync(destPath)).toBe(true);
|
|
});
|
|
|
|
it('should support rsync backup destination', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 'rsync',
|
|
backup_rsync_host: 'backup.example.com',
|
|
backup_rsync_user: 'backup',
|
|
backup_rsync_path: '/remote/backup'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
// Mock exec for rsync
|
|
const { exec } = require('child_process');
|
|
const mockExec = jest.fn((cmd, callback) => {
|
|
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
|
|
});
|
|
exec.mockImplementation(mockExec);
|
|
|
|
mockFs({
|
|
'/storage/events/active': {}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(mockExec).toHaveBeenCalledWith(
|
|
expect.stringContaining('rsync'),
|
|
expect.any(Function)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling and Recovery', () => {
|
|
it('should handle file read errors gracefully', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_s3_access_key: 'test-key',
|
|
backup_s3_secret_key: 'test-secret'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.first.mockResolvedValue(null);
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
// Mock file that throws error on read
|
|
const fs = require('fs');
|
|
const originalCreateReadStream = fs.createReadStream;
|
|
fs.createReadStream = jest.fn((path) => {
|
|
if (path.includes('error.jpg')) {
|
|
const stream = new EventEmitter();
|
|
process.nextTick(() => stream.emit('error', new Error('File read error')));
|
|
return stream;
|
|
}
|
|
return originalCreateReadStream(path);
|
|
});
|
|
|
|
mockFs({
|
|
'/storage/events/active': {
|
|
'error.jpg': Buffer.from('content'),
|
|
'good.jpg': Buffer.from('content')
|
|
}
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
// Should continue with other files despite error
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to backup file'),
|
|
expect.any(Error)
|
|
);
|
|
|
|
fs.createReadStream = originalCreateReadStream;
|
|
});
|
|
|
|
it('should send failure email on backup error', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 's3',
|
|
backup_s3_bucket: 'test-bucket',
|
|
backup_email_on_failure: true
|
|
};
|
|
|
|
const admins = [
|
|
{ email: 'admin1@example.com', is_active: true },
|
|
{ email: 'admin2@example.com', is_active: true }
|
|
];
|
|
|
|
mockDb.select.mockResolvedValue([]);
|
|
mockDb.insert.mockResolvedValue([1]);
|
|
mockDb.where.mockReturnThis();
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig')
|
|
.mockResolvedValueOnce(config)
|
|
.mockResolvedValueOnce(config);
|
|
|
|
// Force an error
|
|
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
|
|
|
|
// Mock admin users query
|
|
db.mockImplementation((table) => {
|
|
if (table === 'admin_users') {
|
|
return {
|
|
where: jest.fn().mockResolvedValue(admins)
|
|
};
|
|
}
|
|
return mockDb;
|
|
});
|
|
|
|
await backupService.runBackup();
|
|
|
|
expect(queueEmail).toHaveBeenCalledTimes(2);
|
|
expect(queueEmail).toHaveBeenCalledWith(
|
|
null,
|
|
'admin1@example.com',
|
|
'backup_failed',
|
|
expect.objectContaining({
|
|
error_message: 'Storage error'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle concurrent backup attempts', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_destination_type: 'local',
|
|
backup_destination_path: '/backup'
|
|
};
|
|
|
|
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
|
|
|
|
mockFs({
|
|
'/storage/events/active': {},
|
|
'/backup': {}
|
|
});
|
|
|
|
// Start two backups concurrently
|
|
const backup1 = backupService.runBackup();
|
|
const backup2 = backupService.runBackup();
|
|
|
|
await Promise.all([backup1, backup2]);
|
|
|
|
// Second backup should be skipped
|
|
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
|
|
});
|
|
});
|
|
|
|
describe('Service Lifecycle', () => {
|
|
it('should start backup service with cron schedule', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_schedule: '0 3 * * *' // 3 AM daily
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue(
|
|
Object.entries(config).map(([key, value]) => ({
|
|
setting_key: key,
|
|
setting_value: value.toString()
|
|
}))
|
|
);
|
|
|
|
await backupService.startBackupService();
|
|
|
|
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
|
|
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
|
|
});
|
|
|
|
it('should stop existing job when restarting service', async () => {
|
|
const config = {
|
|
backup_enabled: true,
|
|
backup_schedule: '0 2 * * *'
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue(
|
|
Object.entries(config).map(([key, value]) => ({
|
|
setting_key: key,
|
|
setting_value: value.toString()
|
|
}))
|
|
);
|
|
|
|
// Start service twice
|
|
await backupService.startBackupService();
|
|
await backupService.startBackupService();
|
|
|
|
expect(mockCronJob.stop).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not start service when backup is disabled', async () => {
|
|
const config = {
|
|
backup_enabled: false
|
|
};
|
|
|
|
mockDb.select.mockResolvedValue([
|
|
{ setting_key: 'backup_enabled', setting_value: 'false' }
|
|
]);
|
|
|
|
await backupService.startBackupService();
|
|
|
|
expect(cron.schedule).not.toHaveBeenCalled();
|
|
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
|
|
});
|
|
});
|
|
|
|
describe('Backup Status and History', () => {
|
|
it('should return backup status with recent runs', async () => {
|
|
const recentRuns = [
|
|
{
|
|
id: 1,
|
|
started_at: new Date(),
|
|
completed_at: new Date(),
|
|
status: 'completed',
|
|
files_backed_up: 100,
|
|
total_size_bytes: 1024000,
|
|
manifest_path: '/backup/manifest.json'
|
|
}
|
|
];
|
|
|
|
mockDb.limit.mockResolvedValue(recentRuns);
|
|
|
|
backupManifest.validateManifest.mockImplementation(() => true);
|
|
|
|
const status = await backupService.getBackupStatus();
|
|
|
|
expect(status).toEqual({
|
|
isRunning: false,
|
|
isHealthy: true,
|
|
lastRun: expect.objectContaining({
|
|
...recentRuns[0],
|
|
manifestValid: true
|
|
}),
|
|
recentRuns: recentRuns,
|
|
nextScheduledRun: expect.any(String)
|
|
});
|
|
});
|
|
|
|
it('should clean up old backup runs', async () => {
|
|
mockDb.delete.mockResolvedValue(5);
|
|
|
|
await backupService.cleanupOldBackupRuns(30);
|
|
|
|
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
|
|
expect(mockDb.delete).toHaveBeenCalled();
|
|
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
|
|
});
|
|
});
|
|
|
|
describe('getBackupManifest', () => {
|
|
it('should retrieve manifest from local filesystem', async () => {
|
|
const backupRun = {
|
|
id: 1,
|
|
manifest_path: '/backup/manifests/backup-123.json'
|
|
};
|
|
|
|
mockDb.first.mockResolvedValue(backupRun);
|
|
|
|
const manifest = { backup: { id: 'backup-123' } };
|
|
backupManifest.loadManifest.mockResolvedValue(manifest);
|
|
backupManifest.generateSummaryReport.mockReturnValue('Summary');
|
|
|
|
const result = await backupService.getBackupManifest(1);
|
|
|
|
expect(result).toEqual({
|
|
manifest: manifest,
|
|
summary: 'Summary'
|
|
});
|
|
});
|
|
|
|
it('should retrieve manifest from S3', async () => {
|
|
const backupRun = {
|
|
id: 1,
|
|
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
|
|
};
|
|
|
|
mockDb.first.mockResolvedValue(backupRun);
|
|
mockDb.select.mockResolvedValue([
|
|
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
|
|
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
|
|
]);
|
|
|
|
const manifest = { backup: { id: 'backup-123' } };
|
|
backupManifest.loadManifest.mockResolvedValue(manifest);
|
|
|
|
await backupService.getBackupManifest(1);
|
|
|
|
expect(S3StorageAdapter).toHaveBeenCalled();
|
|
expect(mockS3Client.download).toHaveBeenCalledWith(
|
|
'backups/manifests/backup-123.json',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
});
|
|
}); |