fix (ornith-35): watchlistRepository double-encoding bug — single JSON.stringify, 13/13 tests pass
This commit is contained in:
@@ -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