slice 1e-1f: tRPC router (auth + market.snapshot) + node:http server

auth.signup/login/logout/me with signed HMAC session cookies + scrypt hashing;
market.snapshot mega-endpoint (quote+candles+sector). node:http server mounts
tRPC at /api/trpc + /health + background drain loop. Live-verified: real NVDA
194.97/65 candles/Technology served after stale-while-revalidate drain. 36 tests green.
This commit is contained in:
Investor Flow Build
2026-06-29 17:40:42 -04:00
parent 1962ecc740
commit 82079b3e66
7 changed files with 349 additions and 5 deletions
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { appRouter } from '../router.ts';
import { resolveSessionUserId, type Context } from '../context.ts';
import { createDb, initSchema } from '../../db/client.ts';
import { createCacheRepository, type Quote, type PriceCandle, type SymbolMeta, type SourceKind } from '../../cache/CacheRepository.ts';
import { FakeSourceAdapter, type SourceFetch } from '../../adapters/SourceAdapter.ts';
import { AdapterQueue } from '../../queue/AdapterQueue.ts';
function setup() {
const db = createDb({ path: ':memory:' });
initSchema(db);
const fake = new FakeSourceAdapter('yfinance')
.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194.97, change: 2.44, changePercent: 1.27 } as Quote, 'live_quote')
.set('yfinance:candles:NVDA:1d', [{ ts: '2026-06-27', o: 192, h: 196, l: 191, c: 194.97, v: 1.2e8, adjClose: 194.9 }] as PriceCandle[], 'daily_permanent')
.set('yfinance:symbol:NVDA', { symbol: 'NVDA', name: 'NVIDIA Corporation', sector: 'Technology', industry: 'Semiconductors', tickerKind: 'equity' } as SymbolMeta, 'symbol_meta');
const adapters = new Map<SourceKind, SourceFetch>([['yfinance', fake]]);
const queue = new AdapterQueue({ db, adapters, rateLimitMs: { yfinance: 0 } });
const cache = createCacheRepository({ db, scheduler: queue });
queue.cache = cache;
const freshCtx = (req?: Request): Context => ({ db, cache, resHeaders: new Headers(), userId: req ? resolveSessionUserId(db, req) : null });
return { db, fake, queue, cache, freshCtx };
}
const cookieHeader = (res: Headers) => res.get('set-cookie')?.split(';')[0] ?? '';
const reqWithCookie = (cookie: string) => new Request('http://localhost/api/trpc', { headers: { cookie } });
test('signup creates a user + session row and sets a signed cookie', async () => {
const { db, freshCtx } = setup();
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const res = await caller.auth.signup({ email: 'A@B.CO', password: 'password123' });
assert.ok(res.userId);
const u = db.prepare('SELECT email, pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; pw_hash: string };
assert.equal(u.email, 'a@b.co'); // normalized lowercase
assert.ok(u.pw_hash.startsWith('scrypt$'));
assert.equal((db.prepare('SELECT COUNT(*) AS c FROM sessions').get() as { c: number }).c, 1);
assert.ok(ctx.resHeaders.get('set-cookie'), 'cookie must be set');
// session cookie round-trip: resolve userId from the cookie
const req = reqWithCookie(cookieHeader(ctx.resHeaders));
assert.equal(resolveSessionUserId(db, req), res.userId);
});
test('duplicate signup is CONFLICT', async () => {
const { freshCtx } = setup();
const caller = appRouter.createCaller(freshCtx());
await caller.auth.signup({ email: 'a@b.co', password: 'password123' });
await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'CONFLICT');
});
test('login succeeds with correct password; fails UNAUTHORIZED with wrong password', async () => {
const { freshCtx } = setup();
await appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' });
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const res = await caller.auth.login({ email: 'A@B.CO', password: 'password123' });
assert.ok(res.userId);
assert.ok(ctx.resHeaders.get('set-cookie'));
await assert.rejects(() => caller.auth.login({ email: 'a@b.co', password: 'wrong' }), (e: { code: string }) => e.code === 'UNAUTHORIZED');
});
test('me returns null unauthenticated; prefs when session resolves', async () => {
const { freshCtx } = setup();
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
assert.equal(await appRouter.createCaller(freshCtx()).auth.me(), null);
const req = reqWithCookie(cookieHeader(signupCtx.resHeaders));
const me = await appRouter.createCaller(freshCtx(req)).auth.me();
assert.equal(me?.userId, userId);
assert.equal(me?.complexity, 'beginner');
});
test('logout clears the session cookie', async () => {
const { freshCtx } = setup();
const ctx = freshCtx();
await appRouter.createCaller(ctx).auth.logout();
const c = ctx.resHeaders.get('set-cookie') ?? '';
assert.match(c, /Max-Age=0/);
});
test('market.snapshot returns nulls + stale when cache is empty (and queues refreshes)', async () => {
const { db, freshCtx } = setup();
const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'nVdA' }); // case-normalized
assert.equal(snap.symbol, 'NVDA');
assert.equal(snap.quote, null);
assert.equal(snap.candles, null);
assert.equal(snap.sector, null);
assert.equal(snap.stale.quote && snap.stale.candles && snap.stale.sector, true);
assert.equal((db.prepare("SELECT COUNT(*) AS c FROM adapter_queue WHERE status='pending'").get() as { c: number }).c, 3);
});
test('market.snapshot serves cached values (not stale) after drain populates cache', async () => {
const { queue, freshCtx } = setup();
await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' }); // queues
await queue.drain(); // populates cache from FakeSourceAdapter
const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' });
assert.equal(snap.quote?.price, 194.97);
assert.equal(snap.sector?.sector, 'Technology');
assert.equal(snap.candles?.length, 1);
assert.equal(snap.stale.quote, false);
assert.equal(snap.stale.candles, false);
assert.equal(snap.stale.sector, false);
});