starter.ts by ornith (9 symbols w/ ticker-kind + ADR-0007 reasons, defaults, 12 tests). onboarding.complete + onboarding.starter + me().onboarded by orchestrator; schema +drawdown_tolerance. Wizard UI (complexity picker, starter list, disclaimer, finish). onboarding subscribes starter symbols to the demand set (adapter fetches them). 87/87 backend + 2/2 lint green. Slice 3 done; slices 1-3 complete.
193 lines
11 KiB
TypeScript
193 lines
11 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);
|
|
});
|
|
|
|
// --- Slice 2: 2FA flow ---
|
|
import { totp as computeTotp } from '../../auth/totp.ts';
|
|
|
|
test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => {
|
|
const { freshCtx } = setup();
|
|
// signup
|
|
const signupCtx = freshCtx();
|
|
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
|
|
// enable2fa (protected: set ctx.userId)
|
|
const eCtx = freshCtx(); eCtx.userId = userId;
|
|
const en = await appRouter.createCaller(eCtx).auth.enable2fa({});
|
|
assert.ok(en.totpSecret);
|
|
assert.ok(en.qrUrl.startsWith('otpauth://TOTP/'));
|
|
assert.equal(en.backupCodes.length, 10);
|
|
// confirm2fa with a valid code
|
|
const code = computeTotp(en.totpSecret);
|
|
const cCtx = freshCtx(); cCtx.userId = userId;
|
|
const c = await appRouter.createCaller(cCtx).auth.confirm2fa({ totp: code });
|
|
assert.equal(c.ok, true);
|
|
// login WITHOUT totp now fails
|
|
await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'UNAUTHORIZED');
|
|
// login WITH valid totp succeeds
|
|
const code2 = computeTotp(en.totpSecret);
|
|
const res = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'password123', totp: code2 });
|
|
assert.equal(res.userId, userId);
|
|
});
|
|
|
|
// --- Slice 2b: OAuth (github) ---
|
|
test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallback links/creates a user', async () => {
|
|
const { db, cache } = setup();
|
|
process.env.GITHUB_CLIENT_ID = 'gh_id'; process.env.GITHUB_CLIENT_SECRET = 'gh_secret';
|
|
type Ctx = { db: typeof db; cache: typeof cache; resHeaders: Headers; userId: string | null; cookies: Record<string, string> };
|
|
const startCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: {} };
|
|
const start = await appRouter.createCaller(startCtx).auth.oauthStart({ provider: 'github', redirectUri: 'http://localhost/cb' });
|
|
assert.ok(start.redirectUrl.includes('client_id=gh_id'));
|
|
assert.ok(start.state.length > 0);
|
|
const setCookie = startCtx.resHeaders.get('set-cookie') ?? '';
|
|
const stateVal = setCookie.split(';')[0].slice('iflow_oauth_state='.length);
|
|
assert.ok(stateVal, 'oauth state cookie set');
|
|
|
|
const origFetch = global.fetch;
|
|
let calls = 0;
|
|
global.fetch = (async (url: unknown) => {
|
|
calls++;
|
|
const u = String(url);
|
|
if (u.includes('/access_token')) return new Response(JSON.stringify({ access_token: 'tok' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
return new Response(JSON.stringify({ id: 42, email: 'ghuser@x.co', name: 'GH User' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}) as typeof fetch;
|
|
try {
|
|
const cbCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: { iflow_oauth_state: stateVal } };
|
|
// wrong state -> rejected (CSRF)
|
|
await assert.rejects(() => appRouter.createCaller({ ...cbCtx, cookies: { iflow_oauth_state: 'bogus' } }).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' }), (e: { code: string }) => e.code === 'BAD_REQUEST');
|
|
// correct state -> creates user + session
|
|
const res = await appRouter.createCaller(cbCtx).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' });
|
|
assert.ok(res.userId);
|
|
const u = db.prepare('SELECT email,oauth_subject,oauth_provider,pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; oauth_subject: string; oauth_provider: string; pw_hash: string };
|
|
assert.equal(u.email, 'ghuser@x.co');
|
|
assert.equal(u.oauth_subject, '42');
|
|
assert.equal(u.oauth_provider, 'github');
|
|
assert.equal(u.pw_hash, 'oauth', 'OAuth-only account has a sentinel pw_hash');
|
|
assert.ok(cbCtx.resHeaders.get('set-cookie'), 'session cookie set');
|
|
assert.equal(calls, 2, 'token + userinfo fetches');
|
|
// second callback with same identity -> reuses the existing linked user (no duplicate)
|
|
const res2 = await appRouter.createCaller({ ...cbCtx, resHeaders: new Headers() }).auth.oauthCallback({ provider: 'github', code: 'abc2', state: start.state, redirectUri: 'http://localhost/cb' });
|
|
assert.equal(res2.userId, res.userId, 'reuses existing linked user');
|
|
assert.equal((db.prepare('SELECT COUNT(*) AS c FROM users').get() as { c: number }).c, 1, 'no duplicate users');
|
|
} finally {
|
|
global.fetch = origFetch;
|
|
delete process.env.GITHUB_CLIENT_ID; delete process.env.GITHUB_CLIENT_SECRET;
|
|
}
|
|
});
|
|
|
|
// --- Slice 3: onboarding ---
|
|
test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + subscribes demand', async () => {
|
|
const { db, cache, freshCtx } = setup();
|
|
const signupCtx = freshCtx();
|
|
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
|
|
const oCtx = { db, cache, resHeaders: new Headers(), userId, cookies: {} as Record<string, string> };
|
|
const res = await appRouter.createCaller(oCtx).onboarding.complete({ complexity: 'beginner' });
|
|
assert.ok(res.watchlistId);
|
|
const u = db.prepare('SELECT complexity, risk_tolerance, drawdown_tolerance FROM users WHERE id=?').get(userId) as { complexity: string; risk_tolerance: string; drawdown_tolerance: number };
|
|
assert.equal(u.complexity, 'beginner');
|
|
assert.equal(u.risk_tolerance, 'moderate');
|
|
assert.equal(u.drawdown_tolerance, -20);
|
|
const wl = db.prepare('SELECT symbols FROM watchlists WHERE owner_id=?').get(userId) as { symbols: string };
|
|
assert.equal(JSON.parse(wl.symbols).length, 9);
|
|
const demand = await cache.demandSet();
|
|
assert.ok(demand.includes('NVDA') && demand.includes('BTC'), 'starter symbols subscribed to demand set');
|
|
});
|