103 lines
5.6 KiB
TypeScript
103 lines
5.6 KiB
TypeScript
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);
|
||
|
|
});
|