phase-8g: add sqllite.

This commit is contained in:
Kazuma
2026-06-05 23:34:25 -04:00
committed by Kazuma
parent ca449b4300
commit 09f2444157
20 changed files with 2514 additions and 239 deletions
+51 -77
View File
@@ -1,32 +1,31 @@
/**
* Unit tests for MarketCallRepository
* Each test gets its own temp file so tests are fully isolated.
* Unit tests for MarketCallRepository (SQLite-backed).
* Each test gets its own in-memory database so tests are fully isolated.
*/
import { test, after } from 'node:test';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import BetterSqlite3 from 'better-sqlite3';
import { MarketCallRepository } from '../server/repositories/MarketCallRepository';
// ── Temp-file helpers ─────────────────────────────────────────────────────────
// ── Helpers ───────────────────────────────────────────────────────────────────
const tmpDirs: string[] = [];
const DDL = `
CREATE TABLE IF NOT EXISTS market_calls (
id TEXT PRIMARY KEY,
title TEXT NOT NULL, quarter TEXT NOT NULL, date TEXT NOT NULL,
thesis TEXT NOT NULL, tickers TEXT NOT NULL, snapshot TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
function tempRepo(): MarketCallRepository {
const dir = mkdtempSync(join(tmpdir(), 'mkt-calls-test-'));
const path = join(dir, 'calls.json');
tmpDirs.push(dir);
return new MarketCallRepository(path);
function makeRepo(): MarketCallRepository {
const db = new BetterSqlite3(':memory:');
db.pragma('journal_mode = WAL');
db.exec(DDL);
return new MarketCallRepository(db);
}
after(() => {
for (const dir of tmpDirs) {
rmSync(dir, { recursive: true, force: true });
}
});
// ── Fixtures ──────────────────────────────────────────────────────────────────
const CALL_INPUT = {
@@ -38,14 +37,12 @@ const CALL_INPUT = {
// ── Tests ─────────────────────────────────────────────────────────────────────
test('list() returns empty array when file does not exist', () => {
const repo = tempRepo();
assert.deepEqual(repo.list(), []);
test('list() returns empty array on fresh db', () => {
assert.deepEqual(makeRepo().list(), []);
});
test('create() returns call with id, createdAt, and correct fields', () => {
const repo = tempRepo();
const call = repo.create(CALL_INPUT);
const call = makeRepo().create(CALL_INPUT);
assert.ok(call.id, 'id should be set');
assert.ok(call.createdAt, 'createdAt should be set');
assert.equal(call.title, CALL_INPUT.title);
@@ -54,76 +51,58 @@ test('create() returns call with id, createdAt, and correct fields', () => {
assert.deepEqual(call.tickers, CALL_INPUT.tickers);
});
test('create() persists to disk — list() returns the created call', () => {
const repo = tempRepo();
test('create() persists — list() returns the created call', () => {
const repo = makeRepo();
repo.create(CALL_INPUT);
assert.equal(repo.list().length, 1);
assert.equal(repo.list()[0].title, CALL_INPUT.title);
});
test('list() returns calls newest-first', () => {
// Write two calls directly with distinct timestamps to guarantee stable ordering.
const dir = mkdtempSync(join(tmpdir(), 'mkt-order-'));
tmpDirs.push(dir);
const path = join(dir, 'calls.json');
const repo = makeRepo();
const db = (repo as any).db as BetterSqlite3.Database;
const older = {
id: 'old-id',
title: 'First',
quarter: 'Q1',
date: '2025-01-01',
thesis: 'A',
tickers: [],
snapshot: {},
createdAt: '2025-01-01T00:00:00.000Z',
};
const newer = {
id: 'new-id',
title: 'Second',
quarter: 'Q1',
date: '2025-01-02',
thesis: 'B',
tickers: [],
snapshot: {},
createdAt: '2025-01-02T00:00:00.000Z',
};
writeFileSync(path, JSON.stringify({ calls: [older, newer] }), 'utf8');
// Insert two rows with distinct created_at values directly
db.prepare(
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
).run('old-id', 'First', 'Q1', '2025-01-01', 'A', '[]', '{}', '2025-01-01T00:00:00.000Z');
db.prepare(
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?,?,?,?,?,?,?,?)`,
).run('new-id', 'Second', 'Q1', '2025-01-02', 'B', '[]', '{}', '2025-01-02T00:00:00.000Z');
const repo = new MarketCallRepository(path);
const list = repo.list();
assert.equal(list[0].id, 'new-id', 'newer call should be first');
assert.equal(list[1].id, 'old-id', 'older call should be second');
});
test('get() returns the call by id', () => {
const repo = tempRepo();
const repo = makeRepo();
const call = repo.create(CALL_INPUT);
const found = repo.get(call.id);
assert.ok(found, 'should find by id');
assert.ok(found);
assert.equal(found!.id, call.id);
});
test('get() returns null for unknown id', () => {
const repo = tempRepo();
assert.equal(repo.get('nonexistent-id'), null);
assert.equal(makeRepo().get('no-such-id'), null);
});
test('delete() removes the call and returns true', () => {
const repo = tempRepo();
const repo = makeRepo();
const call = repo.create(CALL_INPUT);
const ok = repo.delete(call.id);
assert.equal(ok, true);
assert.equal(repo.delete(call.id), true);
assert.equal(repo.list().length, 0);
assert.equal(repo.get(call.id), null);
});
test('delete() returns false for unknown id', () => {
const repo = tempRepo();
assert.equal(repo.delete('no-such-id'), false);
assert.equal(makeRepo().delete('no-such-id'), false);
});
test('delete() only removes the targeted call, leaves others intact', () => {
const repo = tempRepo();
test('delete() only removes the targeted call', () => {
const repo = makeRepo();
const a = repo.create({ ...CALL_INPUT, title: 'Keep me' });
const b = repo.create({ ...CALL_INPUT, title: 'Delete me' });
repo.delete(b.id);
@@ -133,34 +112,29 @@ test('delete() only removes the targeted call, leaves others intact', () => {
});
test('create() stores snapshot when provided', () => {
const repo = tempRepo();
const repo = makeRepo();
const snapshot = { TLT: { price: 95.5, signal: '✅ Strong Buy' } };
const call = repo.create({ ...CALL_INPUT, snapshot } as any);
const found = repo.get(call.id)!;
assert.deepEqual(found.snapshot, snapshot);
assert.deepEqual(repo.get(call.id)!.snapshot, snapshot);
});
test('create() sets default date when not provided', () => {
const repo = tempRepo();
const call = repo.create(CALL_INPUT);
const call = makeRepo().create(CALL_INPUT);
assert.match(call.date, /^\d{4}-\d{2}-\d{2}$/);
});
test('create() uses provided date', () => {
const repo = tempRepo();
const call = repo.create({ ...CALL_INPUT, date: '2025-03-15' });
const call = makeRepo().create({ ...CALL_INPUT, date: '2025-03-15' });
assert.equal(call.date, '2025-03-15');
});
test('concurrent writes: two rapid creates do not lose data', async () => {
const repo = tempRepo();
// Both writes happen synchronously (writeFileSync), so the second
// always sees the first. This test documents the behaviour.
test('concurrent writes: two rapid creates both persist (SQLite WAL is concurrency-safe)', () => {
const repo = makeRepo();
const a = repo.create({ ...CALL_INPUT, title: 'A' });
const b = repo.create({ ...CALL_INPUT, title: 'B' });
const list = repo.list();
assert.equal(list.length, 2, 'both calls should be persisted');
assert.equal(list.length, 2);
const ids = new Set(list.map((c) => c.id));
assert.ok(ids.has(a.id), 'call A should be present');
assert.ok(ids.has(b.id), 'call B should be present');
assert.ok(ids.has(a.id));
assert.ok(ids.has(b.id));
});
+6 -6
View File
@@ -9,7 +9,7 @@ import Fastify from 'fastify';
import cors from '@fastify/cors';
import { CallsController } from '../server/controllers/calls.controller';
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient';
import type { CalendarService } from '../server/services/CalendarService';
import type { MarketCall, ScreenerResult, MarketContext, CreateCallInput } from '../server/types';
// ── Stubs ────────────────────────────────────────────────────────────────────
@@ -35,9 +35,9 @@ const stubEngine = {
screenTickers: async () => EMPTY_RESULT,
} as unknown as ScreenerEngine;
const stubYahoo = {
fetchCalendarEvents: async () => null,
} as unknown as YahooFinanceClient;
const stubCalendar = {
getEvents: async () => ({ events: [], tickers: [] }),
} as unknown as CalendarService;
// In-memory MarketCallRepository stub
function makeRepoStub() {
@@ -81,7 +81,7 @@ function makeRepoStub() {
async function buildTestApp() {
const app = Fastify({ logger: false });
await app.register(cors, { origin: '*' });
new CallsController(makeRepoStub() as any, stubEngine, stubYahoo).register(app);
new CallsController(makeRepoStub() as any, stubEngine, stubCalendar).register(app);
await app.ready();
return app;
}
@@ -210,5 +210,5 @@ test('GET /api/calls/calendar with no calls → 200 empty events', async () => {
const app = await buildTestApp();
const res = await app.inject({ method: 'GET', url: '/api/calls/calendar' });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json(), { events: [] });
assert.deepEqual(res.json(), { events: [], tickers: [] });
});