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:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user