fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
@@ -0,0 +1,346 @@
// Investor Flow — tRPC Edgar Router tests (Slice 6 / M6).
// Verifies: filings_index, company_facts, filer_cik_meta, full_text_search,
// form13f_holdings, form4_tx via the tRPC router with FakeSourceAdapter.
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, queue, xAdapter: null, resHeaders: new Headers(), userId: req ? resolveSessionUserId(db, req) : null, cookies: {} });
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 } });
// Helper to create an authenticated context.
async function createAuthCtx() {
const { db, freshCtx } = setup();
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const { userId } = await caller.auth.signup({ email: 'test@example.com', password: 'password123' });
// Approve the user directly for tests
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
const { createSession } = await import('../../trpc/context.ts');
const { cookie } = createSession(db, userId);
const authCtx = freshCtx(reqWithCookie(cookie.split(';')[0]));
return { db, cache: ctx.cache, ctx: authCtx };
}
test('edgar.filings_index returns filings for a CIK', async () => {
const { db, cache, ctx } = await createAuthCtx();
// We need to install a mock fetch for the EdgarAdapter.
const mockKey = `data.sec.gov/submissions/CIK0000000123.json`;
const mockBody = {
name: 'TEST COMPANY',
filings: {
recent: [
{ form: '10-K', dateReporter: '2026-03-15', accessionNumber: '0001234567-26-000001', accessionNormalization: '2026-03-15', reportDate: '2026-02-28', reportFile: 'http://example.com/10k.pdf', primaryDocument: 'form10k.pdf' },
{ form: '8-K', dateReporter: '2025-12-01', accessionNumber: '0001234567-25-000003', accessionNormalization: '2025-12-01', reportDate: '2025-12-01', reportFile: 'http://example.com/8k.pdf', primaryDocument: 'form8k.pdf' },
],
},
};
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes(mockKey)) {
return new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return origFetch(url, init);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.filings_index({ cik: '123' });
assert.ok(result.filings);
assert.equal(result.filings.length, 2);
assert.equal(result.filings[0].form, '10-K');
assert.equal(result.filings[1].form, '8-K');
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.filings_index filters by formTypes', async () => {
const { db, cache, ctx } = await createAuthCtx();
const mockKey = `data.sec.gov/submissions/CIK0000000456.json`;
const mockBody = {
name: 'FILTER TEST',
filings: {
recent: [
{ form: '10-K', dateReporter: '2026-03-15', accessionNumber: '0004567890-26-000001', accessionNormalization: '2026-03-15', reportDate: '2026-02-28', reportFile: '', primaryDocument: '' },
{ form: '10-Q', dateReporter: '2026-01-15', accessionNumber: '0004567890-26-000002', accessionNormalization: '2026-01-15', reportDate: '2025-12-31', reportFile: '', primaryDocument: '' },
{ form: '8-K', dateReporter: '2025-12-01', accessionNumber: '0004567890-25-000003', accessionNormalization: '2025-12-01', reportDate: '2025-12-01', reportFile: '', primaryDocument: '' },
],
},
};
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes(mockKey)) {
return new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.filings_index({ cik: '456', formTypes: ['10-K'] });
assert.equal(result.filings.length, 1);
assert.equal(result.filings[0].form, '10-K');
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.company_facts returns XBRL financials', async () => {
const { db, cache, ctx } = await createAuthCtx();
const mockKey = `data.sec.gov/api/xbrl/companyfacts/CIK0000000789.json`;
const mockBody = {
entityName: 'XBRL TEST CORP',
facts: { 'us-gaap': { Assets: { units: { USD: [{ form: '10-K', val: 5000 }] } } } },
};
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes(mockKey)) {
return new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.company_facts({ cik: '789' });
assert.ok(result.facts);
assert.equal(result.cik, '789');
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.filer_cik_meta returns CIK + SIC + name', async () => {
const { db, cache, ctx } = await createAuthCtx();
const mockKey = `data.sec.gov/api/xbrl/companyfacts/CIK0000000321.json`;
const mockBody = { entityName: 'META PLATFORMS INC', sic: '7372' };
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes(mockKey)) {
return new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.filer_cik_meta({ cik: '321' });
assert.ok(result.meta);
assert.equal(result.meta.name, 'META PLATFORMS INC');
assert.equal(result.meta.sic, '7372');
assert.equal(result.meta.cik, '0000000321');
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.full_text_search returns search results', async () => {
const { db, cache, ctx } = await createAuthCtx();
const mockBody = {
filings: [
{ ticker: 'NVDA', fileNumber: '001-0', fileName: 'nvda_10k.pdf', reportDate: '2026-02-28' },
{ ticker: 'AAPL', fileNumber: '001-0', fileName: 'aapl_10q.pdf', reportDate: '2026-01-15' },
],
};
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes('efts.sec.gov/LATEST/search-index')) {
return new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.full_text_search({ q: 'NVDA 10-K' });
assert.ok(result.filings);
assert.equal(result.filings.length, 2);
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.form13f_holdings returns parsed holdings', async () => {
const { db, cache, ctx } = await createAuthCtx();
// Mock the index.json response
const indexMock = {
fileDate: '2026-03-15',
documents: [
{ name: 'form13f.txt', type: '13F' },
],
};
// Mock the primary document (XML)
const xmlMock = `<table><tr><td>123456789</td><td>APPLE INC</td><td>1000</td><td>50000</td></tr></table>`;
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes('index.json')) {
return new Response(JSON.stringify(indexMock), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (urlStr.endsWith('.txt') || urlStr.endsWith('.xml')) {
return new Response(xmlMock, { status: 200, headers: { 'Content-Type': 'text/plain' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.form13f_holdings({ cik: '123', accession: '0001234567-26-000001' });
assert.ok(Array.isArray(result.holdings));
assert.equal(result.accession, '0001234567/000123456726000001');
assert.ok(result.holdings.length > 0);
assert.equal(result.total, result.holdings.length);
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.form13f_holdings honors limit/offset (server-side pagination)', async () => {
const { db, cache, ctx } = await createAuthCtx();
const indexMock = {
fileDate: '2026-03-15',
documents: [{ name: 'form13f.txt', type: '13F' }],
};
// Three holdings rows so we can page through them.
const xmlMock = [
'<table>',
'<tr><td>111111111</td><td>AAA INC</td><td>1000</td><td>50000</td></tr>',
'<tr><td>222222222</td><td>BBB INC</td><td>2000</td><td>60000</td></tr>',
'<tr><td>333333333</td><td>CCC INC</td><td>3000</td><td>70000</td></tr>',
'</table>',
].join('');
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes('index.json')) {
return new Response(JSON.stringify(indexMock), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (urlStr.endsWith('.txt') || urlStr.endsWith('.xml')) {
return new Response(xmlMock, { status: 200, headers: { 'Content-Type': 'text/plain' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const page = await caller.edgar.form13f_holdings({ cik: '123', accession: '0001234567-26-000001', limit: 2, offset: 1 });
assert.equal(page.total, 3);
assert.equal(page.holdings.length, 2);
assert.equal(page.holdings[0].cusip, '222222222');
assert.equal(page.holdings[1].cusip, '333333333');
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar.form4_tx returns parsed transactions', async () => {
const { db, cache, ctx } = await createAuthCtx();
// Mock the index.json response
const indexMock = {
fileDate: '2026-03-15',
documents: [
{ name: 'form4.xml', type: 'Form 4' },
],
};
// Mock the XML document with transaction data (using valid Form 4 code 'B' for sale)
const xmlMock = `<form4><infotable><rptOwner><rptOwnerCik>123</rptOwnerCik><rptOwnerName>John Doe</rptOwnerName></rptOwner><securityTitle>Common Stock</securityTitle><transactionDate>2026-03-10</transactionDate><transactionCode>B</transactionCode><nonDerivativeShares>1000</nonDerivativeShares><priceOrStrikePrice>150.50</priceOrStrikePrice></infotable></form4>`;
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
const urlStr = typeof url === 'string' ? url : url.toString();
if (urlStr.includes('index.json')) {
return new Response(JSON.stringify(indexMock), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (urlStr.endsWith('.xml')) {
return new Response(xmlMock, { status: 200, headers: { 'Content-Type': 'application/xml' } });
}
return origFetch(url);
};
try {
const caller = appRouter.createCaller(ctx);
const result = await caller.edgar.form4_tx({ cik: '123', accession: '0001234567-26-000001' });
assert.ok(result.transactions);
assert.equal(result.transactions.accession, '0001234567/000123456726000001');
assert.ok(result.transactions.transactions.length > 0);
const tx = result.transactions.transactions[0];
assert.equal(tx.reporter, 'John Doe');
assert.equal(tx.transactionCode, 'B');
assert.equal(tx.shares, 1000);
} finally {
globalThis.fetch = origFetch;
}
});
test('edgar procedures throw NOT_FOUND on adapter errors', async () => {
const { db, cache, ctx } = await createAuthCtx();
// Mock a 404 response
const origFetch = globalThis.fetch;
(globalThis as any).fetch = async (url: string | URL) => {
return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
};
try {
const caller = appRouter.createCaller(ctx);
await assert.rejects(
() => caller.edgar.filings_index({ cik: '9999999999' }),
(e: { code: string }) => e.code === 'NOT_FOUND'
);
} finally {
globalThis.fetch = origFetch;
}
});
+32 -28
View File
@@ -18,26 +18,24 @@ function setup() {
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 });
const freshCtx = (req?: Request): Context => ({ db, cache, queue, xAdapter: null, resHeaders: new Headers(), userId: req ? resolveSessionUserId(db, req) : null, cookies: {} });
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 () => {
test('signup creates a user with pending_approval status (no session)', 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(res.pending, true);
const u = db.prepare('SELECT email, pw_hash, status FROM users WHERE id=?').get(res.userId) as { email: string; pw_hash: string; status: 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);
assert.equal(u.status, 'pending_approval');
assert.equal((db.prepare('SELECT COUNT(*) AS c FROM sessions').get() as { c: number }).c, 0); // no session created
});
test('duplicate signup is CONFLICT', async () => {
@@ -48,8 +46,9 @@ test('duplicate signup is CONFLICT', async () => {
});
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 { db, freshCtx } = setup();
const { userId } = await appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' });
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const res = await caller.auth.login({ email: 'A@B.CO', password: 'password123' });
@@ -59,11 +58,14 @@ test('login succeeds with correct password; fails UNAUTHORIZED with wrong passwo
});
test('me returns null unauthenticated; prefs when session resolves', async () => {
const { freshCtx } = setup();
const { db, freshCtx } = setup();
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
const { createSession } = await import('../../trpc/context.ts');
const { cookie } = createSession(db, userId);
assert.equal(await appRouter.createCaller(freshCtx()).auth.me(), null);
const req = reqWithCookie(cookieHeader(signupCtx.resHeaders));
const req = reqWithCookie(cookie.split(';')[0]);
const me = await appRouter.createCaller(freshCtx(req)).auth.me();
assert.equal(me?.userId, userId);
assert.equal(me?.complexity, 'beginner');
@@ -105,10 +107,11 @@ test('market.snapshot serves cached values (not stale) after drain populates cac
import { totp as computeTotp } from '../../auth/totp.ts';
test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => {
const { freshCtx } = setup();
const { db, freshCtx } = setup();
// signup
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
// enable2fa (protected: set ctx.userId)
const eCtx = freshCtx(); eCtx.userId = userId;
const en = await appRouter.createCaller(eCtx).auth.enable2fa({});
@@ -130,10 +133,10 @@ test('2FA: enable2fa -> confirm2fa -> login requires totp', async () => {
// --- Slice 2b: OAuth (github) ---
test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallback links/creates a user', async () => {
const { db, cache } = setup();
const { db, cache, queue } = 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: {} };
type Ctx = { db: typeof db; cache: typeof cache; queue: AdapterQueue; xAdapter: null; resHeaders: Headers; userId: string | null; cookies: Record<string, string> };
const startCtx: Ctx = { db, cache, queue, xAdapter: null, 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);
@@ -150,7 +153,7 @@ test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallbac
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 } };
const cbCtx: Ctx = { db, cache, queue, xAdapter: null, 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
@@ -175,12 +178,12 @@ test('oauth: oauthStart returns a redirect URL + CSRF state cookie; oauthCallbac
// --- Slice 2c: OAuth — link existing account by email ---
test('oauthCallback links an OAuth identity to an existing user when emails match', async () => {
const { db, cache } = setup();
const { db, cache, queue } = 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> };
type Ctx = { db: typeof db; cache: typeof cache; queue: AdapterQueue; xAdapter: null; resHeaders: Headers; userId: string | null; cookies: Record<string, string> };
// Step 1: sign up with email "link-me@example.com" (creates a password-based account).
const signupCtx: Ctx = { db, cache, resHeaders: new Headers(), userId: null, cookies: {} };
const signupCtx: Ctx = { db, cache, queue, xAdapter: null, resHeaders: new Headers(), userId: null, cookies: {} };
const signup = await appRouter.createCaller(signupCtx).auth.signup({ email: 'link-me@example.com', password: 'password123' });
assert.ok(signup.userId);
@@ -202,7 +205,7 @@ test('oauthCallback links an OAuth identity to an existing user when emails matc
return new Response(JSON.stringify({ id: 999, email: 'link-me@example.com', name: 'Link 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 } };
const cbCtx: Ctx = { db, cache, queue, xAdapter: null, resHeaders: new Headers(), userId: null, cookies: { iflow_oauth_state: stateVal } };
const res = await appRouter.createCaller(cbCtx).auth.oauthCallback({ provider: 'github', code: 'abc', state: start.state, redirectUri: 'http://localhost/cb' });
assert.equal(res.userId, signup.userId, 'should return the EXISTING user id');
const u = db.prepare('SELECT oauth_subject,oauth_provider,pw_hash FROM users WHERE id=?').get(signup.userId) as { oauth_subject: string; oauth_provider: string; pw_hash: string };
@@ -220,10 +223,11 @@ test('oauthCallback links an OAuth identity to an existing user when emails matc
// --- Slice 3: onboarding ---
test('onboarding.complete writes complexity/risk/drawdown + starter watchlist + subscribes demand', async () => {
const { db, cache, freshCtx } = setup();
const { db, cache, freshCtx, queue } = 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> };
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
const oCtx = { db, cache, queue, xAdapter: null, 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 };
@@ -267,11 +271,11 @@ test('market.indicators returns arrays aligned to candle length', async () => {
assert.equal(res.ema['50']!.length, 1);
assert.equal(res.ema['200']!.length, 1);
assert.equal(res.rsi.length, 1);
assert.equal(res.relativeVolume.length, 1);
assert.equal(res.relvol.length, 1);
// With only 1 candle, nothing can be computed yet
assert.equal(res.ema['9']![0], undefined);
assert.equal(res.rsi[0], undefined);
assert.equal(res.relativeVolume[0], undefined);
assert.equal(res.relvol[0], undefined);
});
test('market.indicators computes real values when enough candles exist', async () => {
@@ -295,7 +299,7 @@ test('market.indicators computes real values when enough candles exist', async (
assert.equal(res.rsi[13], undefined, 'RSI undefined before period');
assert.ok(typeof res.rsi[14] === 'number', 'RSI computed at index 14');
// RelativeVolume-20: first computed value at index 20
assert.equal(res.relativeVolume.length, 50);
assert.equal(res.relativeVolume[19], undefined, 'relvol undefined before period');
assert.ok(typeof res.relativeVolume[20] === 'number', 'relativeVolume computed at index 20');
assert.equal(res.relvol.length, 50);
assert.equal(res.relvol[19], undefined, 'relvol undefined before period');
assert.ok(typeof res.relvol[20] === 'number', 'relativeVolume computed at index 20');
});
+4 -1
View File
@@ -5,6 +5,7 @@ import { randomUUID, randomBytes, scryptSync, timingSafeEqual, createHmac } from
import type { DatabaseSync } from 'node:sqlite';
import type { CacheRepository } from '../cache/CacheRepository.ts';
import type { AdapterQueue } from '../queue/AdapterQueue.ts';
import type { XCookieAdapter } from '../adapters/XCookieAdapter.ts';
export const SESSION_COOKIE = 'iflow_session';
export const OAUTH_STATE_COOKIE = 'iflow_oauth_state';
@@ -21,6 +22,7 @@ export interface Context {
db: DatabaseSync;
cache: CacheRepository;
queue: AdapterQueue;
xAdapter: XCookieAdapter | null;
resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter)
userId: string | null; // resolved from the session cookie; null = unauthenticated
cookies: Record<string, string>; // parsed request cookies (session + oauth state)
@@ -102,13 +104,14 @@ export function verifyPassword(pw: string, stored: string): boolean {
}
// ----- context factory: 1f wires real db+cache; tests inject in-memory -----
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository; queue: AdapterQueue }) {
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository; queue: AdapterQueue; xAdapter?: XCookieAdapter | null }) {
return ({ req, resHeaders }: CreateContextOpts): Context => {
const cookies = parseCookies(req);
return {
db: opts.db,
cache: opts.cache,
queue: opts.queue,
xAdapter: opts.xAdapter ?? null,
resHeaders,
cookies,
userId: resolveSessionUserId(opts.db, req),
File diff suppressed because it is too large Load Diff