fix: Implement tiered backup retention classification (refs #6)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m15s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 6m15s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Replace per-schedule count-based retention with unified tiered classification. Backups are now classified by timestamp into categories (hourly/daily/weekly/monthly) and are only deleted when they exceed ALL applicable category quotas. Changes: - Add backup-classification.service.ts for timestamp-based classification - Rewrite backup-retention.service.ts with tiered logic - Add categories and expires_at columns to backup_history - Add Expires column to desktop and mobile backup UI - Add unit tests for classification logic (22 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for BackupClassificationService
|
||||
* @ai-context Tests pure timestamp-based classification functions
|
||||
*/
|
||||
|
||||
import {
|
||||
classifyBackup,
|
||||
calculateExpiration,
|
||||
isFirstBackupOfDay,
|
||||
isSunday,
|
||||
isFirstDayOfMonth,
|
||||
classifyAndCalculateExpiration,
|
||||
} from '../../domain/backup-classification.service';
|
||||
import { TIERED_RETENTION } from '../../domain/backup.types';
|
||||
|
||||
describe('BackupClassificationService', () => {
|
||||
describe('classifyBackup', () => {
|
||||
it('should classify regular hourly backup (non-midnight)', () => {
|
||||
// Tuesday, January 7, 2026 at 14:30 UTC
|
||||
const timestamp = new Date('2026-01-07T14:30:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly']);
|
||||
});
|
||||
|
||||
it('should classify midnight backup as hourly + daily', () => {
|
||||
// Wednesday, January 8, 2026 at 00:00 UTC
|
||||
const timestamp = new Date('2026-01-08T00:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly', 'daily']);
|
||||
});
|
||||
|
||||
it('should classify Sunday midnight backup as hourly + daily + weekly', () => {
|
||||
// Sunday, January 4, 2026 at 00:00 UTC
|
||||
const timestamp = new Date('2026-01-04T00:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly', 'daily', 'weekly']);
|
||||
});
|
||||
|
||||
it('should classify 1st of month midnight backup as hourly + daily + monthly', () => {
|
||||
// Thursday, January 1, 2026 at 00:00 UTC (not Sunday)
|
||||
const timestamp = new Date('2026-01-01T00:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly', 'daily', 'monthly']);
|
||||
});
|
||||
|
||||
it('should classify Sunday 1st of month midnight as all categories', () => {
|
||||
// Sunday, February 1, 2026 at 00:00 UTC
|
||||
const timestamp = new Date('2026-02-01T00:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly', 'daily', 'weekly', 'monthly']);
|
||||
});
|
||||
|
||||
it('should not classify non-midnight on 1st as monthly', () => {
|
||||
// Thursday, January 1, 2026 at 10:00 UTC
|
||||
const timestamp = new Date('2026-01-01T10:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly']);
|
||||
});
|
||||
|
||||
it('should not classify non-midnight on Sunday as weekly', () => {
|
||||
// Sunday, January 4, 2026 at 15:00 UTC
|
||||
const timestamp = new Date('2026-01-04T15:00:00.000Z');
|
||||
const categories = classifyBackup(timestamp);
|
||||
|
||||
expect(categories).toEqual(['hourly']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateExpiration', () => {
|
||||
const baseTimestamp = new Date('2026-01-05T00:00:00.000Z');
|
||||
|
||||
it('should calculate 8 hours for hourly-only backup', () => {
|
||||
const expiresAt = calculateExpiration(['hourly'], baseTimestamp);
|
||||
const expectedDate = new Date('2026-01-05T08:00:00.000Z');
|
||||
|
||||
expect(expiresAt).toEqual(expectedDate);
|
||||
});
|
||||
|
||||
it('should calculate 7 days for daily backup', () => {
|
||||
const expiresAt = calculateExpiration(['hourly', 'daily'], baseTimestamp);
|
||||
const expectedDate = new Date('2026-01-12T00:00:00.000Z');
|
||||
|
||||
expect(expiresAt).toEqual(expectedDate);
|
||||
});
|
||||
|
||||
it('should calculate 4 weeks for weekly backup', () => {
|
||||
const expiresAt = calculateExpiration(['hourly', 'daily', 'weekly'], baseTimestamp);
|
||||
const expectedDate = new Date('2026-02-02T00:00:00.000Z');
|
||||
|
||||
expect(expiresAt).toEqual(expectedDate);
|
||||
});
|
||||
|
||||
it('should calculate 12 months for monthly backup', () => {
|
||||
const expiresAt = calculateExpiration(
|
||||
['hourly', 'daily', 'weekly', 'monthly'],
|
||||
baseTimestamp
|
||||
);
|
||||
const expectedDate = new Date('2027-01-05T00:00:00.000Z');
|
||||
|
||||
expect(expiresAt).toEqual(expectedDate);
|
||||
});
|
||||
|
||||
it('should use longest retention when monthly is present (even without weekly)', () => {
|
||||
const expiresAt = calculateExpiration(['hourly', 'daily', 'monthly'], baseTimestamp);
|
||||
const expectedDate = new Date('2027-01-05T00:00:00.000Z');
|
||||
|
||||
expect(expiresAt).toEqual(expectedDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFirstBackupOfDay', () => {
|
||||
it('should return true for midnight UTC', () => {
|
||||
const timestamp = new Date('2026-01-05T00:00:00.000Z');
|
||||
expect(isFirstBackupOfDay(timestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-midnight', () => {
|
||||
const timestamp = new Date('2026-01-05T01:00:00.000Z');
|
||||
expect(isFirstBackupOfDay(timestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for midnight with minutes/seconds', () => {
|
||||
// 00:30:45 is still hour 0
|
||||
const timestamp = new Date('2026-01-05T00:30:45.000Z');
|
||||
expect(isFirstBackupOfDay(timestamp)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSunday', () => {
|
||||
it('should return true for Sunday', () => {
|
||||
// January 4, 2026 is a Sunday
|
||||
const timestamp = new Date('2026-01-04T12:00:00.000Z');
|
||||
expect(isSunday(timestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-Sunday', () => {
|
||||
// January 5, 2026 is a Monday
|
||||
const timestamp = new Date('2026-01-05T12:00:00.000Z');
|
||||
expect(isSunday(timestamp)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFirstDayOfMonth', () => {
|
||||
it('should return true for 1st of month', () => {
|
||||
const timestamp = new Date('2026-01-01T12:00:00.000Z');
|
||||
expect(isFirstDayOfMonth(timestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-1st', () => {
|
||||
const timestamp = new Date('2026-01-15T12:00:00.000Z');
|
||||
expect(isFirstDayOfMonth(timestamp)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyAndCalculateExpiration', () => {
|
||||
it('should return both categories and expiresAt', () => {
|
||||
// Sunday, February 1, 2026 at 00:00 UTC - all categories
|
||||
const timestamp = new Date('2026-02-01T00:00:00.000Z');
|
||||
const result = classifyAndCalculateExpiration(timestamp);
|
||||
|
||||
expect(result.categories).toEqual(['hourly', 'daily', 'weekly', 'monthly']);
|
||||
expect(result.expiresAt).toEqual(new Date('2027-02-01T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('should work for hourly-only backup', () => {
|
||||
const timestamp = new Date('2026-01-07T14:30:00.000Z');
|
||||
const result = classifyAndCalculateExpiration(timestamp);
|
||||
|
||||
expect(result.categories).toEqual(['hourly']);
|
||||
expect(result.expiresAt).toEqual(new Date('2026-01-07T22:30:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('TIERED_RETENTION constants', () => {
|
||||
it('should have correct retention values', () => {
|
||||
expect(TIERED_RETENTION.hourly).toBe(8);
|
||||
expect(TIERED_RETENTION.daily).toBe(7);
|
||||
expect(TIERED_RETENTION.weekly).toBe(4);
|
||||
expect(TIERED_RETENTION.monthly).toBe(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user