fix (ornith-35): watchlistRepository double-encoding bug — single JSON.stringify, 13/13 tests pass

This commit is contained in:
Investor Flow Build
2026-06-30 17:54:01 -04:00
parent 97607e0bd4
commit 1007ab4ed5
62 changed files with 11617 additions and 34 deletions
@@ -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();
});
+6
View File
@@ -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,
+50 -24
View File
@@ -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)}`;