20dd43c093
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Failing after 2m18s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- 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>
251 lines
7.6 KiB
JavaScript
251 lines
7.6 KiB
JavaScript
const { DatabaseBackupService } = require('../databaseBackup');
|
|
const { db } = require('../../database/db');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
// Mock dependencies
|
|
jest.mock('../../database/db');
|
|
jest.mock('../../utils/logger');
|
|
jest.mock('../emailProcessor');
|
|
jest.mock('child_process');
|
|
|
|
describe('DatabaseBackupService', () => {
|
|
let service;
|
|
let mockExecAsync;
|
|
|
|
beforeEach(() => {
|
|
service = new DatabaseBackupService();
|
|
mockExecAsync = jest.fn();
|
|
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Mock execAsync
|
|
const childProcess = require('child_process');
|
|
childProcess.exec = jest.fn((cmd, opts, callback) => {
|
|
if (callback) {
|
|
callback(null, { stdout: 'ok', stderr: '' });
|
|
}
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Cleanup test files
|
|
try {
|
|
await fs.rmdir('/tmp/test-backup', { recursive: true });
|
|
} catch (e) {
|
|
// Ignore
|
|
}
|
|
});
|
|
|
|
describe('calculateChecksum', () => {
|
|
it('should calculate SHA256 checksum of a file', async () => {
|
|
const testFile = '/tmp/test-checksum.txt';
|
|
const testContent = 'Hello, World!';
|
|
await fs.writeFile(testFile, testContent);
|
|
|
|
const checksum = await service.calculateChecksum(testFile);
|
|
|
|
// Expected checksum for "Hello, World!"
|
|
const expectedChecksum = crypto
|
|
.createHash('sha256')
|
|
.update(testContent)
|
|
.digest('hex');
|
|
|
|
expect(checksum).toBe(expectedChecksum);
|
|
|
|
await fs.unlink(testFile);
|
|
});
|
|
});
|
|
|
|
describe('getTableChecksums', () => {
|
|
it('should get checksums for all tables', async () => {
|
|
// Mock getTables
|
|
service.getTables = jest.fn().mockResolvedValue(['events', 'photos']);
|
|
|
|
// Mock SQLite response
|
|
db.raw = jest.fn()
|
|
.mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }])
|
|
.mockResolvedValueOnce([{ row_count: 20, data_sum: 2000 }]);
|
|
|
|
const checksums = await service.getTableChecksums();
|
|
|
|
expect(checksums).toHaveProperty('events');
|
|
expect(checksums).toHaveProperty('photos');
|
|
expect(checksums.events.rowCount).toBe(10);
|
|
expect(checksums.photos.rowCount).toBe(20);
|
|
expect(checksums.events.checksum).toBeDefined();
|
|
expect(checksums.photos.checksum).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getTables', () => {
|
|
it('should get list of tables for SQLite', async () => {
|
|
service.dbType = 'sqlite';
|
|
|
|
db.raw = jest.fn().mockResolvedValue([
|
|
{ name: 'events' },
|
|
{ name: 'photos' },
|
|
{ name: 'admin_users' }
|
|
]);
|
|
|
|
const tables = await service.getTables();
|
|
|
|
expect(tables).toEqual(['events', 'photos', 'admin_users']);
|
|
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('sqlite_master'));
|
|
});
|
|
|
|
it('should get list of tables for PostgreSQL', async () => {
|
|
service.dbType = 'postgresql';
|
|
|
|
db.raw = jest.fn().mockResolvedValue({
|
|
rows: [
|
|
{ table_name: 'events' },
|
|
{ table_name: 'photos' },
|
|
{ table_name: 'admin_users' }
|
|
]
|
|
});
|
|
|
|
const tables = await service.getTables();
|
|
|
|
expect(tables).toEqual(['events', 'photos', 'admin_users']);
|
|
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('information_schema.tables'));
|
|
});
|
|
});
|
|
|
|
describe('getDatabaseSize', () => {
|
|
it('should get database size for SQLite', async () => {
|
|
service.dbType = 'sqlite';
|
|
const mockSize = 1024 * 1024 * 10; // 10MB
|
|
|
|
// Mock fs.stat
|
|
const originalStat = fs.stat;
|
|
fs.stat = jest.fn().mockResolvedValue({ size: mockSize });
|
|
|
|
const size = await service.getDatabaseSize();
|
|
|
|
expect(size).toBe(mockSize);
|
|
|
|
fs.stat = originalStat;
|
|
});
|
|
|
|
it('should get database size for PostgreSQL', async () => {
|
|
service.dbType = 'postgresql';
|
|
const mockSize = 1024 * 1024 * 100; // 100MB
|
|
|
|
db.raw = jest.fn().mockResolvedValue({
|
|
rows: [{ size: mockSize.toString() }]
|
|
});
|
|
|
|
const size = await service.getDatabaseSize();
|
|
|
|
expect(size).toBe(mockSize);
|
|
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('pg_database_size'));
|
|
});
|
|
});
|
|
|
|
describe('compressFile', () => {
|
|
it('should compress file and return stats', async () => {
|
|
const testFile = '/tmp/test-compress.txt';
|
|
const compressedFile = '/tmp/test-compress.txt.gz';
|
|
|
|
// Create test file with repetitive content (compresses well)
|
|
const testContent = 'Hello, World! '.repeat(1000);
|
|
await fs.writeFile(testFile, testContent);
|
|
|
|
const stats = await service.compressFile(testFile, compressedFile);
|
|
|
|
expect(stats.originalSize).toBeGreaterThan(0);
|
|
expect(stats.compressedSize).toBeGreaterThan(0);
|
|
expect(stats.compressedSize).toBeLessThan(stats.originalSize);
|
|
expect(parseFloat(stats.compressionRatio)).toBeGreaterThan(0);
|
|
|
|
// Cleanup
|
|
await fs.unlink(testFile);
|
|
await fs.unlink(compressedFile);
|
|
});
|
|
});
|
|
|
|
describe('backup configuration', () => {
|
|
it('should get backup configuration from database', async () => {
|
|
const mockConfig = [
|
|
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
|
|
{ setting_key: 'database_backup_compress', setting_value: 'true' },
|
|
{ setting_key: 'database_backup_retention_days', setting_value: '30' }
|
|
];
|
|
|
|
db.mockReturnValue({
|
|
where: jest.fn().mockReturnThis(),
|
|
select: jest.fn().mockResolvedValue(mockConfig)
|
|
});
|
|
|
|
const config = await service.getBackupConfig();
|
|
|
|
expect(config.database_backup_enabled).toBe(true);
|
|
expect(config.database_backup_compress).toBe(true);
|
|
expect(config.database_backup_retention_days).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('cleanupOldBackups', () => {
|
|
it('should delete old backup files and records', async () => {
|
|
const oldBackups = [
|
|
{ id: 1, file_path: '/backup/old1.sql.gz' },
|
|
{ id: 2, file_path: '/backup/old2.sql.gz' }
|
|
];
|
|
|
|
db.mockReturnValue({
|
|
where: jest.fn().mockReturnThis(),
|
|
select: jest.fn().mockResolvedValue(oldBackups),
|
|
delete: jest.fn().mockResolvedValue(1)
|
|
});
|
|
|
|
// Mock fs.unlink
|
|
fs.unlink = jest.fn().mockResolvedValue(undefined);
|
|
|
|
await service.cleanupOldBackups(30);
|
|
|
|
expect(fs.unlink).toHaveBeenCalledTimes(2);
|
|
expect(fs.unlink).toHaveBeenCalledWith('/backup/old1.sql.gz');
|
|
expect(fs.unlink).toHaveBeenCalledWith('/backup/old2.sql.gz');
|
|
});
|
|
});
|
|
|
|
describe('progress tracking', () => {
|
|
it('should update and retrieve progress', () => {
|
|
expect(service.getProgress()).toBeNull();
|
|
|
|
service.updateProgress('Testing...', { step: 1 });
|
|
|
|
const progress = service.getProgress();
|
|
expect(progress.message).toBe('Testing...');
|
|
expect(progress.details.step).toBe(1);
|
|
expect(progress.timestamp).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('backup history', () => {
|
|
it('should retrieve backup history', async () => {
|
|
const mockHistory = [
|
|
{
|
|
id: 1,
|
|
started_at: new Date(),
|
|
completed_at: new Date(),
|
|
status: 'completed',
|
|
file_size_bytes: 1024000
|
|
}
|
|
];
|
|
|
|
db.mockReturnValue({
|
|
orderBy: jest.fn().mockReturnThis(),
|
|
limit: jest.fn().mockResolvedValue(mockHistory)
|
|
});
|
|
|
|
const history = await service.getBackupHistory(10);
|
|
|
|
expect(history).toEqual(mockHistory);
|
|
expect(history.length).toBe(1);
|
|
});
|
|
});
|
|
}); |