feat: persist settings and enrich conversation context
This commit is contained in:
1 parent
a36723a325
commit
2c100d72b6
84 files changed
+2248
-644
No files matched your search
@@ -1,128 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { persistSubreddits } from '../src/config/persistence.js';
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ConfigStore } from '../src/config/persistence.js';
|
||||
import { loadConfig } from '../src/config/index.js';
|
||||
import { runMigrations } from '../src/database/client.js';
|
||||
import { getTestConfig } from './testUtils.js';
|
||||
|
||||
const SAMPLE = `# top-level note that must survive
|
||||
reddit:
|
||||
# weights bias picking
|
||||
subreddits:
|
||||
- { name: AverageNudes, weight: 4, tone: 'direct' } # validation seekers
|
||||
- { name: gonewild40plus, weight: 4, tone: 'gentle' } # mature
|
||||
- { name: normalnudes, weight: 3 } # real bodies
|
||||
- { name: tightandtoned, weight: 1 }
|
||||
|
||||
pollIntervalMs: 30000
|
||||
userAgent: "ua"
|
||||
`;
|
||||
|
||||
describe('persistSubreddits', () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
describe('ConfigStore', () => {
|
||||
let db: Database.Database;
|
||||
let store: ConfigStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-config-'));
|
||||
configPath = path.join(tmpDir, 'config.yaml');
|
||||
fs.writeFileSync(configPath, SAMPLE, 'utf-8');
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
store = new ConfigStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('mutates an existing row in place and preserves its inline comment', () => {
|
||||
persistSubreddits(
|
||||
[
|
||||
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'playful' },
|
||||
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||
],
|
||||
configPath,
|
||||
);
|
||||
const out = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(out).toMatch(/tone:\s*['"]?playful['"]?/);
|
||||
expect(out).toContain('# validation seekers');
|
||||
expect(out).toContain('# top-level note that must survive');
|
||||
expect(out).toContain('# weights bias picking');
|
||||
expect(out).toContain('# mature');
|
||||
expect(out).toContain('pollIntervalMs: 30000');
|
||||
});
|
||||
|
||||
it('drops removed rows (and their attached comments)', () => {
|
||||
persistSubreddits(
|
||||
[
|
||||
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||
],
|
||||
configPath,
|
||||
);
|
||||
const out = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(out).not.toContain('gonewild40plus');
|
||||
expect(out).not.toContain('normalnudes');
|
||||
expect(out).not.toContain('# mature');
|
||||
expect(out).toContain('AverageNudes');
|
||||
expect(out).toContain('tightandtoned');
|
||||
expect(out).toContain('# validation seekers');
|
||||
});
|
||||
|
||||
it('appends new rows in caller order', () => {
|
||||
persistSubreddits(
|
||||
[
|
||||
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||
{ name: 'NewSub', type: 'chat', weight: 2, tone: 'playful' },
|
||||
],
|
||||
configPath,
|
||||
);
|
||||
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const subs = parsed.reddit.subreddits;
|
||||
expect(subs[subs.length - 1]).toMatchObject({
|
||||
name: 'NewSub',
|
||||
type: 'chat',
|
||||
weight: 2,
|
||||
tone: 'playful',
|
||||
it('round-trips application configuration through SQLite', () => {
|
||||
const config = getTestConfig({
|
||||
rateLimit: { minDelayBetweenMessagesMs: 42, maxMessagesPerHour: 7, jitterMs: 3 },
|
||||
});
|
||||
store.save(config);
|
||||
|
||||
const loaded = store.load({
|
||||
OPENROUTER_API_KEY: 'environment-key',
|
||||
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
|
||||
DATABASE_PATH: './data/from-environment.db',
|
||||
});
|
||||
|
||||
expect(loaded?.rateLimit).toEqual(config.rateLimit);
|
||||
expect(loaded?.openrouter.apiKey).toBe('environment-key');
|
||||
expect(loaded?.web.authToken).toBe('environment-dashboard-token');
|
||||
expect(loaded?.database.path).toBe('./data/from-environment.db');
|
||||
});
|
||||
|
||||
it("omits 'default' tone and 'image' type on write to match on-disk style", () => {
|
||||
persistSubreddits(
|
||||
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'default' }],
|
||||
configPath,
|
||||
);
|
||||
const out = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(out).not.toMatch(/tone:\s*default/);
|
||||
expect(out).not.toMatch(/type:\s*image/);
|
||||
it('never stores secrets in the database record', () => {
|
||||
const config = getTestConfig();
|
||||
config.openrouter.apiKey = 'database-must-not-contain-this';
|
||||
config.web.authToken = 'also-secret';
|
||||
store.save(config);
|
||||
|
||||
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
|
||||
expect(row.config_json).not.toContain('database-must-not-contain-this');
|
||||
expect(row.config_json).not.toContain('also-secret');
|
||||
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
|
||||
});
|
||||
|
||||
it('atomic write — tmp file not left behind on success', () => {
|
||||
persistSubreddits(
|
||||
[{ name: 'OnlyOne', type: 'image', weight: 1, tone: 'direct' }],
|
||||
configPath,
|
||||
);
|
||||
expect(fs.existsSync(configPath + '.tmp')).toBe(false);
|
||||
it('updates the singleton record instead of appending versions', () => {
|
||||
store.save(getTestConfig());
|
||||
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
|
||||
|
||||
expect(db.prepare('SELECT COUNT(*) AS count FROM app_config').get()).toEqual({ count: 1 });
|
||||
expect(store.load({
|
||||
OPENROUTER_API_KEY: 'key',
|
||||
WEB_DASHBOARD_TOKEN: 'dashboard-token',
|
||||
})?.browser.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it('reorders rows according to input order', () => {
|
||||
persistSubreddits(
|
||||
[
|
||||
{ name: 'tightandtoned', type: 'image', weight: 1, tone: 'default' },
|
||||
{ name: 'AverageNudes', type: 'image', weight: 4, tone: 'direct' },
|
||||
{ name: 'gonewild40plus', type: 'image', weight: 4, tone: 'gentle' },
|
||||
{ name: 'normalnudes', type: 'image', weight: 3, tone: 'default' },
|
||||
],
|
||||
configPath,
|
||||
);
|
||||
const parsed = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
expect(parsed.reddit.subreddits.map((s: { name: string }) => s.name)).toEqual([
|
||||
'tightandtoned',
|
||||
'AverageNudes',
|
||||
'gonewild40plus',
|
||||
'normalnudes',
|
||||
]);
|
||||
it('imports legacy YAML once and then starts without the file', () => {
|
||||
const originalDirectory = process.cwd();
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'redditbot-config-import-'));
|
||||
const previousApiKey = process.env.OPENROUTER_API_KEY;
|
||||
const previousDashboardToken = process.env.WEB_DASHBOARD_TOKEN;
|
||||
try {
|
||||
process.chdir(tempDirectory);
|
||||
process.env.OPENROUTER_API_KEY = 'environment-key';
|
||||
process.env.WEB_DASHBOARD_TOKEN = 'environment-dashboard-token';
|
||||
fs.writeFileSync('config.yaml', `
|
||||
reddit:
|
||||
subreddits: [example]
|
||||
openrouter:
|
||||
imageAnalysisPrompt: analyze
|
||||
messageGenerationPrompt: compose
|
||||
`);
|
||||
|
||||
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
|
||||
fs.rmSync('config.yaml');
|
||||
expect(loadConfig(store).reddit.subreddits[0].name).toBe('example');
|
||||
} finally {
|
||||
process.chdir(originalDirectory);
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
if (previousApiKey === undefined) delete process.env.OPENROUTER_API_KEY;
|
||||
else process.env.OPENROUTER_API_KEY = previousApiKey;
|
||||
if (previousDashboardToken === undefined) delete process.env.WEB_DASHBOARD_TOKEN;
|
||||
else process.env.WEB_DASHBOARD_TOKEN = previousDashboardToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user