mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
279 lines
11 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|