Add unit tests for data file rotation utility functions
- Implement tests for writing data files with rotation, ensuring backups are created only on changes. - Verify that old backups are rotated correctly and the maximum number of backups is maintained. - Test restoration of backups while preserving the current state as a backup. - Utilize Vitest for testing framework and manage temporary file storage during tests.
This commit is contained in:
125
server/utils/data-file-rotation.js
Normal file
125
server/utils/data-file-rotation.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||
|
||||
function getProjectRoot() {
|
||||
const cwd = process.cwd()
|
||||
if (cwd.endsWith('.output')) {
|
||||
return path.resolve(cwd, '..')
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
function getBackupRoot() {
|
||||
const configured = process.env.DATA_FILE_BACKUP_DIR
|
||||
if (!configured) {
|
||||
return path.join(getProjectRoot(), 'backups', 'data-rotation')
|
||||
}
|
||||
if (path.isAbsolute(configured)) {
|
||||
return configured
|
||||
}
|
||||
return path.join(getProjectRoot(), configured)
|
||||
}
|
||||
|
||||
function sanitizeFileKey(filePath) {
|
||||
const projectRoot = getProjectRoot()
|
||||
const relative = path.relative(projectRoot, filePath)
|
||||
const normalized = relative.split(path.sep).join('__')
|
||||
return normalized.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
|
||||
function buildBackupName(date = new Date()) {
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
||||
}
|
||||
|
||||
async function ensureDirectory(dirPath) {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
async function rotateOldBackups(backupDir, maxBackups) {
|
||||
if (!Number.isFinite(maxBackups) || maxBackups < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||
const backups = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const overflowCount = Math.max(0, backups.length - maxBackups)
|
||||
if (overflowCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const toDelete = backups.slice(0, overflowCount)
|
||||
await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
export function getBackupDirectoryForDataFile(filePath) {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
|
||||
}
|
||||
|
||||
export async function listDataFileBackups(filePath) {
|
||||
const backupDir = getBackupDirectoryForDataFile(filePath)
|
||||
const entries = await fs.readdir(backupDir, { withFileTypes: true }).catch(() => [])
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.bak'))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse()
|
||||
}
|
||||
|
||||
export async function writeDataFileWithRotation(filePath, content, {
|
||||
encoding = 'utf-8',
|
||||
maxBackups = DEFAULT_MAX_BACKUPS
|
||||
} = {}) {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
await ensureDirectory(path.dirname(resolvedPath))
|
||||
|
||||
let existingContent = null
|
||||
try {
|
||||
existingContent = await fs.readFile(resolvedPath, encoding)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (existingContent !== null && existingContent === content) {
|
||||
return {
|
||||
changed: false,
|
||||
backupPath: null
|
||||
}
|
||||
}
|
||||
|
||||
let backupPath = null
|
||||
if (existingContent !== null) {
|
||||
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
await ensureDirectory(backupDir)
|
||||
backupPath = path.join(backupDir, buildBackupName())
|
||||
await fs.copyFile(resolvedPath, backupPath)
|
||||
await rotateOldBackups(backupDir, maxBackups)
|
||||
}
|
||||
|
||||
const tmpPath = `${resolvedPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
await fs.writeFile(tmpPath, content, encoding)
|
||||
await fs.rename(tmpPath, resolvedPath)
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
backupPath
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreDataFileBackup(filePath, backupName, options = {}) {
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
const sourceBackupPath = path.join(backupDir, backupName)
|
||||
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
|
||||
|
||||
return writeDataFileWithRotation(resolvedPath, backupContent, options)
|
||||
}
|
||||
Reference in New Issue
Block a user