fix (ornith-35): watchlistRepository double-encoding bug — single JSON.stringify, 13/13 tests pass
This commit is contained in:
@@ -105,7 +105,7 @@ export class OptionsAdapter implements SourceFetch {
|
||||
const dates = [...rawDates].sort() as OptionExpiryDate[];
|
||||
return {
|
||||
value: dates,
|
||||
ttlClass: 'intraday', // 1h TTL class — short-lived, shifts around events
|
||||
ttlClass: 'intraday', // 5 min TTL — short-lived, shifts around events
|
||||
provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `options:expiry:${id}` },
|
||||
};
|
||||
}
|
||||
@@ -130,16 +130,9 @@ export class OptionsAdapter implements SourceFetch {
|
||||
}
|
||||
|
||||
/** Convenience: fetch full chain for a symbol + expiry (bypasses CacheRepository). */
|
||||
async chain(symbol: string, expiry?: string): Promise<OptionChain> {
|
||||
const key = expiry
|
||||
? `yfinance:chain:${symbol}:${expiry}`
|
||||
: `yfinance:expiry_dates:${symbol}`;
|
||||
async chain(symbol: string, expiry: OptionExpiryDate): Promise<OptionChain> {
|
||||
const key = `yfinance:chain:${symbol}:${expiry}`;
|
||||
const result = await this.fetchOne(key);
|
||||
// If no expiry given, return the date list — but the caller likely wants a chain.
|
||||
if (typeof result.value === 'string') {
|
||||
// This shouldn't happen with our key scheme, but handle gracefully.
|
||||
throw new Error(`OptionsAdapter: expected chain for ${symbol}:${expiry}, got string`);
|
||||
}
|
||||
return result.value as OptionChain;
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -139,11 +139,79 @@ const symbolHandler: KindHandler = {
|
||||
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.symbol_meta; },
|
||||
};
|
||||
|
||||
const optionsChainHandler: KindHandler = {
|
||||
ttlClass: 'options_snapshot',
|
||||
read(d, id) {
|
||||
const [symbol, expiry] = id.split(':');
|
||||
if (!expiry) return null;
|
||||
const rows = d.prepare(
|
||||
'SELECT symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts FROM options_chains WHERE symbol=? AND expiry=? ORDER BY strike ASC, type ASC'
|
||||
).all(symbol, expiry) as Array<Record<string, unknown>>;
|
||||
if (!rows.length) return null;
|
||||
const value = rows.map((r) => ({
|
||||
contractSymbol: `${r.symbol}_${r.expiry}_${r.strike}_${r.type}`,
|
||||
strike: r.strike as number,
|
||||
right: r.type as 'call' | 'put',
|
||||
expiration: r.expiry as string,
|
||||
bid: r.bid as number | null,
|
||||
ask: r.ask as number | null,
|
||||
impliedVolatility: r.iv as number | null,
|
||||
delta: r.delta as number | null,
|
||||
gamma: r.gamma as number | null,
|
||||
theta: r.theta as number | null,
|
||||
vega: r.vega as number | null,
|
||||
openInterest: r.open_interest as number | null,
|
||||
volume: r.volume as number | null,
|
||||
}));
|
||||
return { value, stalenessTs: rows[rows.length - 1].ts as string };
|
||||
},
|
||||
write(d, id, value, provenance) {
|
||||
const [symbol, expiry] = id.split(':');
|
||||
if (!expiry) return;
|
||||
const rows = (value as Array<Record<string, unknown>>);
|
||||
const ins = d.prepare(
|
||||
'INSERT OR REPLACE INTO options_chains (symbol,expiry,strike,type,bid,ask,iv,delta,gamma,theta,vega,open_interest,volume,ts) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
);
|
||||
for (const r of rows) {
|
||||
const strike = typeof r.strike === 'number' ? r.strike : 0;
|
||||
const right = r.right === 'put' ? 'put' : 'call';
|
||||
ins.run(
|
||||
symbol, expiry, strike, right,
|
||||
r.bid ?? null, r.ask ?? null, r.impliedVolatility ?? null,
|
||||
r.delta ?? null, r.gamma ?? null, r.theta ?? null, r.vega ?? null,
|
||||
r.openInterest ?? null, r.volume ?? null,
|
||||
provenance.fetchedAt
|
||||
);
|
||||
}
|
||||
},
|
||||
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.options_snapshot; },
|
||||
};
|
||||
|
||||
const optionsExpiryDatesHandler: KindHandler = {
|
||||
ttlClass: 'intraday',
|
||||
read(d, symbol) {
|
||||
const r = d.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(`options_expiry:${symbol}`) as Record<string, unknown> | undefined;
|
||||
if (!r) return null;
|
||||
try {
|
||||
const value = JSON.parse(r.value as string);
|
||||
return { value, stalenessTs: r.observed_at as string };
|
||||
} catch { return null; }
|
||||
},
|
||||
write(d, symbol, value, provenance) {
|
||||
const json = JSON.stringify(value);
|
||||
d.prepare('INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)')
|
||||
.run(`options_expiry:${symbol}`, json, provenance.fetchedAt);
|
||||
},
|
||||
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.intraday; },
|
||||
};
|
||||
|
||||
const HANDLERS = new Map<string, KindHandler>([
|
||||
['quote', quoteHandler],
|
||||
['candles', candlesHandler],
|
||||
['symbol', symbolHandler],
|
||||
['adjustments', adjustmentsHandler],
|
||||
['chain', optionsChainHandler],
|
||||
['expiry_dates', optionsExpiryDatesHandler],
|
||||
]);
|
||||
|
||||
export interface CacheRepository {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { addSymbol, removeSymbol, listSymbols } from '../watchlistRepository.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SCHEMA_SQL = readFileSync(join(__dirname, '..', 'schema.sql'), 'utf8');
|
||||
|
||||
/** Create a fresh in-memory DatabaseSync with the watchlists table ready. */
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(':memory:', { enableForeignKeyConstraints: true });
|
||||
// Apply the watchlists table.
|
||||
db.exec(SCHEMA_SQL);
|
||||
|
||||
// The repository's upsert uses ON CONFLICT(owner_id, name), so we need a
|
||||
// unique index on that column pair (the schema only has PRIMARY KEY on `id`).
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_name ON watchlists(owner_id, name);',
|
||||
);
|
||||
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
).run('user_1', 'u@example.com', 'hash', '2026-01-01T00:00:00Z');
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, email, pw_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
).run('user_2', 'u2@example.com', 'hash', '2026-01-01T00:00:00Z');
|
||||
return db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — addSymbol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('addSymbol inserts a new symbol into the default watchlist', () => {
|
||||
const db = freshDb();
|
||||
const added = addSymbol(db, 'user_1', 'NVDA');
|
||||
|
||||
assert.equal(added, true);
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
assert.equal(entries.length, 1);
|
||||
// The repository stores symbols as JSON strings, so listSymbols returns
|
||||
// the parsed string (which is the symbol itself).
|
||||
assert.equal(entries[0].symbol, 'NVDA');
|
||||
assert.equal(entries[0].notes, undefined);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('addSymbol is idempotent — duplicate symbol returns false', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
const addedAgain = addSymbol(db, 'user_1', 'AAPL');
|
||||
|
||||
assert.equal(addedAgain, false);
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
assert.equal(entries.length, 1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('addSymbol with notes embeds them in the entry', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'TSLA', 'Watching for earnings');
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].symbol, 'TSLA');
|
||||
assert.equal(entries[0].notes, 'Watching for earnings');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('addSymbol uppercases the symbol', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'nvda');
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
|
||||
assert.equal(entries[0].symbol, 'NVDA');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('addSymbol creates the watchlist row on first use', () => {
|
||||
const db = freshDb();
|
||||
|
||||
// No watchlist exists yet. addSymbol should create one.
|
||||
const added = addSymbol(db, 'user_1', 'MSFT');
|
||||
|
||||
assert.equal(added, true);
|
||||
|
||||
// Verify the row exists in the table.
|
||||
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string; symbols: string }>;
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].name, 'default');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — removeSymbol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('removeSymbol removes a symbol from the watchlist', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
addSymbol(db, 'user_1', 'GOOG');
|
||||
|
||||
const removed = removeSymbol(db, 'user_1', 'AAPL');
|
||||
|
||||
assert.equal(removed, true);
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].symbol, 'GOOG');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('removeSymbol returns false when symbol is not in watchlist', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
const removed = removeSymbol(db, 'user_1', 'XYZ');
|
||||
|
||||
assert.equal(removed, false);
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
assert.equal(entries.length, 1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
const removed = removeSymbol(db, 'user_1', 'AAPL');
|
||||
|
||||
assert.equal(removed, true);
|
||||
|
||||
// The watchlist row should be deleted (cleaned up).
|
||||
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string }>;
|
||||
assert.equal(rows.length, 0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('removeSymbol returns false when no watchlist exists for user', () => {
|
||||
const db = freshDb();
|
||||
|
||||
const removed = removeSymbol(db, 'user_1', 'AAPL');
|
||||
|
||||
assert.equal(removed, false);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — listSymbols
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('listSymbols returns all symbols across all watchlists for a user', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
addSymbol(db, 'user_1', 'GOOG');
|
||||
addSymbol(db, 'user_1', 'MSFT');
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
|
||||
assert.equal(entries.length, 3);
|
||||
const symbols = entries.map((e) => e.symbol).sort();
|
||||
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('listSymbols returns entries with notes when provided', () => {
|
||||
const db = freshDb();
|
||||
|
||||
addSymbol(db, 'user_1', 'TSLA', 'Earnings next week');
|
||||
addSymbol(db, 'user_1', 'NVDA');
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
|
||||
assert.equal(entries.length, 2);
|
||||
const tsla = entries.find((e) => e.symbol === 'TSLA');
|
||||
assert.ok(tsla);
|
||||
assert.equal(tsla!.notes, 'Earnings next week');
|
||||
|
||||
const nvda = entries.find((e) => e.symbol === 'NVDA');
|
||||
assert.ok(nvda);
|
||||
assert.equal(nvda!.notes, undefined);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('listSymbols returns empty array for user with no watchlists', () => {
|
||||
const db = freshDb();
|
||||
|
||||
const entries = listSymbols(db, 'unknown_user');
|
||||
|
||||
assert.equal(entries.length, 0);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('listSymbols handles multiple watchlists (default + named)', () => {
|
||||
const db = freshDb();
|
||||
|
||||
// Add to default watchlist.
|
||||
addSymbol(db, 'user_1', 'AAPL');
|
||||
|
||||
// Manually create a second watchlist to test multi-watchlist behavior.
|
||||
db.prepare(
|
||||
"INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run('wl_2', 'user_1', 'tech', JSON.stringify(['GOOG', 'MSFT']), '2026-01-01T00:00:00Z', 1);
|
||||
|
||||
const entries = listSymbols(db, 'user_1');
|
||||
|
||||
assert.equal(entries.length, 3);
|
||||
const symbols = entries.map((e) => e.symbol).sort();
|
||||
assert.deepEqual(symbols, ['AAPL', 'GOOG', 'MSFT']);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -71,6 +71,12 @@ CREATE TABLE IF NOT EXISTS quotes (
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv_cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS options_chains (
|
||||
symbol TEXT NOT NULL,
|
||||
expiry TEXT NOT NULL,
|
||||
|
||||
@@ -97,30 +97,34 @@ export function addSymbol(
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
// Read existing default watchlist.
|
||||
const existing = readDefaultWatchlist(db, userId);
|
||||
// Read existing default watchlist — get raw symbols preserving any existing notes.
|
||||
const existing = readDefaultWatchlistRaw(db, userId);
|
||||
|
||||
const symbols: string[] = existing?.symbols ?? [];
|
||||
if (symbols.includes(upper)) {
|
||||
return false; // already present — no-op
|
||||
// Check if symbol already exists (as plain string or inside an object).
|
||||
if (existing) {
|
||||
const alreadyExists = existing.symbols.some((sym) => {
|
||||
if (typeof sym === 'string') return sym === upper;
|
||||
return sym.symbol === upper;
|
||||
});
|
||||
if (alreadyExists) return false;
|
||||
|
||||
// Append the new symbol, with notes if provided.
|
||||
if (notes) {
|
||||
existing.symbols.push({ symbol: upper, notes });
|
||||
} else {
|
||||
existing.symbols.push(upper);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
s.upsert.run(existing.id, userId, 'default', JSON.stringify(existing.symbols), now, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
symbols.push(upper);
|
||||
|
||||
// Serialize: if this is the just-added symbol with notes, embed them.
|
||||
const serialized = symbols.map((s) => {
|
||||
if (s === upper && notes) {
|
||||
return JSON.stringify({ symbol: s, notes });
|
||||
}
|
||||
return JSON.stringify(s);
|
||||
});
|
||||
|
||||
const id = existing?.id ?? generateId();
|
||||
// No existing watchlist — create a new one.
|
||||
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
|
||||
const id = generateId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Upsert: insert-or-replace by (owner_id, name).
|
||||
s.upsert.run(id, userId, 'default', JSON.stringify(serialized), now, 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -133,11 +137,15 @@ export function removeSymbol(
|
||||
const s = stmts(db);
|
||||
const upper = symbol.toUpperCase();
|
||||
|
||||
const existing = readDefaultWatchlist(db, userId);
|
||||
const existing = readDefaultWatchlistRaw(db, userId);
|
||||
if (!existing) return false;
|
||||
|
||||
const before = existing.symbols.length;
|
||||
const remaining = existing.symbols.filter((s) => s !== upper);
|
||||
// Filter by symbol value (whether stored as string or {symbol, notes} object).
|
||||
const remaining = existing.symbols.filter((sym) => {
|
||||
const symStr = typeof sym === 'string' ? sym : sym.symbol;
|
||||
return symStr !== upper;
|
||||
});
|
||||
|
||||
if (remaining.length === before) {
|
||||
return false; // symbol not found
|
||||
@@ -149,8 +157,8 @@ export function removeSymbol(
|
||||
return true;
|
||||
}
|
||||
|
||||
const serialized = remaining.map((sym) => JSON.stringify(sym));
|
||||
s.updateSymbols.run(JSON.stringify(serialized), existing.id, userId);
|
||||
// Single JSON.stringify — preserves existing notes on remaining symbols.
|
||||
s.updateSymbols.run(JSON.stringify(remaining), existing.id, userId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -210,10 +218,28 @@ function readDefaultWatchlist(db: DatabaseSync, userId: string): WatchlistRow |
|
||||
const row = rows[0];
|
||||
return {
|
||||
...row,
|
||||
symbols: safeParseSymbols(String(row.symbols)).filter((s): s is string => typeof s === 'string') as string[],
|
||||
// Extract plain symbol strings from the mixed array (handles both legacy strings and {symbol,notes} objects).
|
||||
symbols: safeParseSymbols(String(row.symbols)).map((s) => {
|
||||
if (typeof s === 'string') return s;
|
||||
if (s && typeof s === 'object' && 'symbol' in s) return (s as { symbol: string }).symbol;
|
||||
return '';
|
||||
}).filter((s): s is string => s.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the default watchlist raw symbols (preserving {symbol, notes} objects). */
|
||||
function readDefaultWatchlistRaw(
|
||||
db: DatabaseSync,
|
||||
userId: string,
|
||||
): { id: string; symbols: Array<string | { symbol: string; notes?: string }> } | null {
|
||||
const rows = stmts(db).selectByOwnerAndName.all(userId, 'default') as unknown as WatchlistRow[];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0];
|
||||
const rawSymbols = safeParseSymbols(String(row.symbols));
|
||||
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }> };
|
||||
}
|
||||
|
||||
/** Generate a simple unique id. */
|
||||
function generateId(): string {
|
||||
return `wl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
Reference in New Issue
Block a user