Files
fredy/test/services/debug/debugLogStorage.test.js
2026-06-09 15:42:25 +02:00

279 lines
11 KiB
JavaScript

/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import Database from 'better-sqlite3';
/**
* Wire up an in-memory better-sqlite3 instance plus a stubbed settings module so the
* storage module under test can exercise real SQL while the rest of the dependency
* graph stays inert.
*/
async function bootstrap() {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE debug_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
byte_size INTEGER NOT NULL
);
`);
const settings = { debug_logging_enabled: false, debug_logging_ever_enabled: false };
const ROOT = path.resolve('.');
const sqlitePath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
const settingsPath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
const sqliteMock = {
default: {
getConnection: () => db,
execute: (sql, params = {}) => db.prepare(sql).run(params),
query: (sql, params = {}) => db.prepare(sql).all(params),
},
};
const settingsMock = {
getSettings: async () => ({ ...settings }),
upsertSettings: (entries) => {
const map = Array.isArray(entries) ? Object.fromEntries(entries) : entries;
for (const [k, v] of Object.entries(map)) {
settings[k] = v;
}
},
};
vi.resetModules();
vi.doMock(sqlitePath, () => sqliteMock);
vi.doMock(settingsPath, () => settingsMock);
const storage = await import(path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js'));
storage._resetForTests();
return { storage, db, settings };
}
describe('services/debug/debugLogStorage.js', () => {
let ctx;
beforeEach(async () => {
ctx = await bootstrap();
});
afterEach(() => {
try {
ctx.db.close();
} catch {
// ignore
}
});
it('isEnabled is false before enableDebugLogging is called', async () => {
expect(ctx.storage.isEnabled()).toBe(false);
});
it('enableDebugLogging flips the cached flag and persists ever-enabled', async () => {
await ctx.storage.enableDebugLogging();
expect(ctx.storage.isEnabled()).toBe(true);
expect(ctx.settings.debug_logging_enabled).toBe(true);
expect(ctx.settings.debug_logging_ever_enabled).toBe(true);
});
it('reloadEnabledFromSettings picks up persisted state after restart', async () => {
ctx.settings.debug_logging_enabled = true;
const enabled = await ctx.storage.reloadEnabledFromSettings();
expect(enabled).toBe(true);
expect(ctx.storage.isEnabled()).toBe(true);
});
it('appendLogEntry writes only while enabled', async () => {
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'before-enable' });
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'after-enable' });
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1);
const row = ctx.db.prepare('SELECT level, message, byte_size FROM debug_logs').get();
expect(row.level).toBe('warn');
expect(row.message).toBe('after-enable');
expect(row.byte_size).toBe(Buffer.byteLength('after-enable', 'utf-8'));
});
it('disableDebugLogging stops writes but keeps existing rows', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'keep-me' });
await ctx.storage.disableDebugLogging();
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'never-written' });
const rows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id').all();
expect(rows).toHaveLength(1);
expect(rows[0].message).toBe('keep-me');
});
it('enableDebugLogging clears previous logs only when asked', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'pre-existing' });
await ctx.storage.disableDebugLogging();
await ctx.storage.enableDebugLogging({ clearPrevious: false });
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1);
await ctx.storage.disableDebugLogging();
await ctx.storage.enableDebugLogging({ clearPrevious: true });
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
});
it('hasAnyLogs / wasEverEnabled report correctly', async () => {
expect(ctx.storage.hasAnyLogs()).toBe(false);
expect(await ctx.storage.wasEverEnabled()).toBe(false);
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hi' });
expect(ctx.storage.hasAnyLogs()).toBe(true);
expect(await ctx.storage.wasEverEnabled()).toBe(true);
});
it('getCurrentSize reflects the on-disk byte total', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hello' }); // 5 bytes
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'world!' }); // 6 bytes
expect(await ctx.storage.getCurrentSize()).toBe(11);
});
it('rolling buffer drops oldest rows once the cap is exceeded', async () => {
await ctx.storage.enableDebugLogging();
const cap = ctx.storage.getMaxSize();
// Insert one row whose payload exceeds the entire cap. trimToFit must drop the
// oldest row(s) until the live size falls back under the cap. With a single
// oversized row, the only outcome is "table empty".
const giantText = 'X'.repeat(cap + 1024);
ctx.storage.appendLogEntry({ ts: 10, level: 'info', message: giantText });
const remaining = await ctx.storage.getCurrentSize();
expect(remaining).toBeLessThanOrEqual(cap);
expect(remaining).toBe(0);
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
});
it('rolling buffer keeps newer rows when only the oldest need to go', async () => {
await ctx.storage.enableDebugLogging();
const cap = ctx.storage.getMaxSize();
// Push the size just over the cap with one big row, then a smaller "newer" row
// that should survive the trim because it is not at the head of the queue.
const bigText = 'A'.repeat(cap - 10); // ~5 MiB - 10 bytes
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: bigText });
// At this point we are just under the cap. Pushing one more row will tip us over.
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'tip-over message which keeps us above cap' });
const remainingRows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id ASC').all();
// The oldest (big) row must be gone; the newer one survives.
expect(remainingRows).toHaveLength(1);
expect(remainingRows[0].message).toContain('tip-over');
const remainingSize = await ctx.storage.getCurrentSize();
expect(remainingSize).toBeLessThanOrEqual(cap);
// And the cache must match what SQLite reports, verifies no drift after trim.
const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s;
expect(remainingSize).toBe(dbSize);
});
it('cachedSize stays consistent across enable → append → disable → re-enable(clear) cycles', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'one' });
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'two' });
const sizeAfterFirst = await ctx.storage.getCurrentSize();
await ctx.storage.disableDebugLogging();
expect(await ctx.storage.getCurrentSize()).toBe(sizeAfterFirst);
await ctx.storage.enableDebugLogging({ clearPrevious: true });
expect(await ctx.storage.getCurrentSize()).toBe(0);
ctx.storage.appendLogEntry({ ts: 3, level: 'info', message: 'fresh' });
const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s;
expect(await ctx.storage.getCurrentSize()).toBe(dbSize);
});
it('clearAllDebugLogs empties the table and resets cached size', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'foo' });
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'bar' });
expect(await ctx.storage.getCurrentSize()).toBeGreaterThan(0);
ctx.storage.clearAllDebugLogs();
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
expect(await ctx.storage.getCurrentSize()).toBe(0);
});
it('getAllDebugLogs returns rows ordered chronologically', async () => {
await ctx.storage.enableDebugLogging();
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'first' });
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'second' });
ctx.storage.appendLogEntry({ ts: 3, level: 'error', message: 'third' });
const rows = ctx.storage.getAllDebugLogs();
expect(rows.map((r) => r.message)).toEqual(['first', 'second', 'third']);
expect(rows.map((r) => r.level)).toEqual(['info', 'warn', 'error']);
});
describe('logger sink wiring', () => {
let logger;
let consoleSpies;
beforeEach(async () => {
// Storage imports the same logger module; vi.resetModules() ensured both share
// the same fresh instance for this test. Spies silence console output so the
// vitest report stays clean while we exercise real logger.info() calls.
logger = (await import(path.resolve('lib/services/logger.js'))).default;
consoleSpies = {
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
};
});
afterEach(() => {
// Detach sink between tests to prevent cross-test pollution from the shared
// logger module instance.
logger.setDebugLogSink(null);
for (const spy of Object.values(consoleSpies)) spy.mockRestore();
});
it('routes logger calls into debug_logs once enabled', async () => {
await ctx.storage.enableDebugLogging();
logger.info('captured-via-logger');
const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all();
expect(rows).toHaveLength(1);
expect(rows[0].level).toBe('info');
expect(rows[0].message).toContain('captured-via-logger');
});
it('detaches the sink on disable so logger calls no longer hit the DB', async () => {
await ctx.storage.enableDebugLogging();
await ctx.storage.disableDebugLogging();
logger.info('not-captured');
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
});
it('restores the sink on reloadEnabledFromSettings when persisted state is on', async () => {
ctx.settings.debug_logging_enabled = true;
await ctx.storage.reloadEnabledFromSettings();
logger.warn('captured-after-restart');
const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all();
expect(rows).toHaveLength(1);
expect(rows[0].level).toBe('warn');
expect(rows[0].message).toContain('captured-after-restart');
});
});
});