Files
ccs/tests/unit/services/logging/log-storage.test.ts
T

119 lines
4.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as zlib from 'zlib';
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader';
import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer';
import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config';
import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths';
import {
appendStructuredLogEntry,
pruneExpiredLogArchives,
} from '../../../../src/services/logging/log-storage';
import type { LogEntry } from '../../../../src/services/logging/log-types';
function createEntry(overrides: Partial<LogEntry>): LogEntry {
return {
id: overrides.id ?? 'entry-1',
timestamp: overrides.timestamp ?? new Date().toISOString(),
level: overrides.level ?? 'info',
source: overrides.source ?? 'unit:test',
event: overrides.event ?? 'test.event',
message: overrides.message ?? 'message',
processId: overrides.processId ?? 1234,
runId: overrides.runId ?? 'run-1',
context: overrides.context ?? {},
};
}
describe('log storage', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-'));
process.env.CCS_HOME = tempHome;
clearRecentLogEntries();
invalidateLoggingConfigCache();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
clearRecentLogEntries();
invalidateLoggingConfigCache();
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('rotates the current log into the archive when the file exceeds the age threshold', () => {
const config = createEmptyUnifiedConfig();
config.logging.retain_days = 7;
config.logging.rotate_mb = 10;
saveUnifiedConfig(config);
invalidateLoggingConfigCache();
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`,
'utf8'
);
const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp);
appendStructuredLogEntry(
createEntry({
id: 'new-entry',
message: 'new log entry after rotation',
})
);
const archiveDir = getLogArchiveDir();
const archives = fs.readdirSync(archiveDir);
expect(archives).toHaveLength(1);
const archivedContent = zlib
.gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0])))
.toString('utf8');
expect(archivedContent).toContain('"id":"old-entry"');
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
expect(currentContent).toContain('"id":"new-entry"');
expect(currentContent).not.toContain('"id":"old-entry"');
});
it('prunes expired archives according to retention settings', () => {
const config = createEmptyUnifiedConfig();
config.logging.retain_days = 1;
saveUnifiedConfig(config);
invalidateLoggingConfigCache();
const archiveDir = getLogArchiveDir();
fs.mkdirSync(archiveDir, { recursive: true });
const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz');
const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz');
fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 });
fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 });
const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp);
fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp);
pruneExpiredLogArchives();
expect(fs.existsSync(oldArchive)).toBe(false);
expect(fs.existsSync(freshArchive)).toBe(true);
});
});