599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
// Investor Flow — EdgarAdapter tests (mock fetch, no real network).
|
|||
|
|
// Verifies: filings_index (form-type + date-range filters), company_facts caching,
|
||
|
|
// 304 no-op, rate-limit spacing (>=125ms), ETag/If-Modified-Since revalidation,
|
||
|
|
// filer_cik_meta, full_text_search.
|
||
|
|
|
||
|
|
import { test } from 'node:test';
|
||
|
|
import { strict as assert } from 'node:assert';
|
||
|
|
import { EdgarAdapter, padCik } from '../EdgarAdapter.ts';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** Build a mock fetch that returns canned responses keyed by URL substring. */
|
||
|
|
function createMockFetch(
|
||
|
|
responses: Record<string, { body: unknown; status?: number; etag?: string; lastModified?: string }>,
|
||
|
|
) {
|
||
|
|
let callCount = 0;
|
||
|
|
async function mockFetch(url: string | URL, init?: RequestInit): Promise<Response> {
|
||
|
|
const urlStr = typeof url === 'string' ? url : url.toString();
|
||
|
|
callCount++;
|
||
|
|
for (const [key, resp] of Object.entries(responses)) {
|
||
|
|
if (urlStr.includes(key)) {
|
||
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
|
|
if (resp.etag) headers['etag'] = resp.etag;
|
||
|
|
if (resp.lastModified) headers['last-modified'] = resp.lastModified;
|
||
|
|
return new Response(JSON.stringify(resp.body), {
|
||
|
|
status: resp.status ?? 200,
|
||
|
|
headers,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Default: 404 for unmatched URLs
|
||
|
|
return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
||
|
|
}
|
||
|
|
return { mockFetch, getCallCount: () => callCount };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Record which headers were sent on each fetch call (for revalidation tests). */
|
||
|
|
interface FetchCall { url: string; headers: Record<string, string>; }
|
||
|
|
|
||
|
|
function createHeaderRecordingMockFetch(
|
||
|
|
responses: Record<string, { body: unknown; status?: number; etag?: string; lastModified?: string }>,
|
||
|
|
) {
|
||
|
|
const calls: FetchCall[] = [];
|
||
|
|
async function mockFetch(url: string | URL, init?: RequestInit): Promise<Response> {
|
||
|
|
const urlStr = typeof url === 'string' ? url : url.toString();
|
||
|
|
const headers: Record<string, string> = {};
|
||
|
|
if (init?.headers) {
|
||
|
|
const h = init.headers as Record<string, string>;
|
||
|
|
Object.assign(headers, h);
|
||
|
|
}
|
||
|
|
calls.push({ url: urlStr, headers });
|
||
|
|
for (const [key, resp] of Object.entries(responses)) {
|
||
|
|
if (urlStr.includes(key)) {
|
||
|
|
const respHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||
|
|
if (resp.etag) respHeaders['etag'] = resp.etag;
|
||
|
|
if (resp.lastModified) respHeaders['last-modified'] = resp.lastModified;
|
||
|
|
return new Response(JSON.stringify(resp.body), {
|
||
|
|
status: resp.status ?? 200,
|
||
|
|
headers: respHeaders,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return new Response(JSON.stringify({ error: 'not found' }), { status: 404 });
|
||
|
|
}
|
||
|
|
return { mockFetch, getCalls: () => calls };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Helper to install/restore global fetch. */
|
||
|
|
function installFetch(mockFn: typeof globalThis.fetch) {
|
||
|
|
(global as Record<string, unknown>).fetch = mockFn;
|
||
|
|
}
|
||
|
|
|
||
|
|
function restoreFetch() {
|
||
|
|
delete (global as Record<string, unknown>).fetch;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Fixture data
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function makeFilingsResponse() {
|
||
|
|
return {
|
||
|
|
name: 'TEST COMPANY INC',
|
||
|
|
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: '10-Q', dateReporter: '2026-01-15', accessionNumber: '0001234567-26-000002', accessionNormalization: '2026-01-15', reportDate: '2025-12-31', reportFile: 'http://example.com/10q.pdf', primaryDocument: 'form10q.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' },
|
||
|
|
{ form: 'SC 13G', dateReporter: '2025-06-30', accessionNumber: '0001234567-25-000004', accessionNormalization: '2025-06-30', reportDate: '2025-06-30', reportFile: 'http://example.com/13g.pdf', primaryDocument: 'form13g.pdf' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeCompanyFactsResponse() {
|
||
|
|
return {
|
||
|
|
entityName: 'TEST COMPANY INC',
|
||
|
|
facts: {
|
||
|
|
'us-gaap': {
|
||
|
|
Assets: { units: { USD: [{ form: '10-K', val: 1000 }], EUR: [{ form: '10-K', val: 900 }] } },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: padCik
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('padCik zero-pads to 10 digits', () => {
|
||
|
|
assert.equal(padCik('123'), '0000000123');
|
||
|
|
assert.equal(padCik('1234567890'), '1234567890');
|
||
|
|
assert.equal(padCik('abc123def'), '0000000123');
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: filings_index
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('filings_index returns all recent filings when no filters', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.filings_index('123');
|
||
|
|
|
||
|
|
assert.equal(getCallCount(), 1, 'should fetch exactly once');
|
||
|
|
assert.equal(result.ttlClass, 'daily_permanent');
|
||
|
|
assert.equal(result.provenance.sourceKind, 'sec');
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
assert.equal(filings.length, 4, 'should return all 4 filings');
|
||
|
|
|
||
|
|
const forms = filings.map((f) => f.form);
|
||
|
|
assert.ok(forms.includes('10-K'));
|
||
|
|
assert.ok(forms.includes('10-Q'));
|
||
|
|
assert.ok(forms.includes('8-K'));
|
||
|
|
assert.ok(forms.includes('SC 13G'));
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('filings_index filters by formTypes', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.filings_index('123', { formTypes: ['10-K', '10-Q'] });
|
||
|
|
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
assert.equal(filings.length, 2, 'should filter to 10-K and 10-Q only');
|
||
|
|
const forms = filings.map((f) => f.form);
|
||
|
|
assert.ok(forms.includes('10-K'));
|
||
|
|
assert.ok(forms.includes('10-Q'));
|
||
|
|
assert.ok(!forms.includes('8-K'));
|
||
|
|
assert.ok(!forms.includes('SC 13G'));
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('filings_index filters by dateRange (from + to)', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.filings_index('123', {
|
||
|
|
dateRange: { from: '2025-12-01', to: '2026-03-31' },
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
// 10-K (2026-03-15), 10-Q (2026-01-15), 8-K (2025-12-01) are in range; SC 13G (2025-06-30) is out
|
||
|
|
assert.equal(filings.length, 3, 'should include filings within date range');
|
||
|
|
const forms = filings.map((f) => f.form);
|
||
|
|
assert.ok(forms.includes('10-K'));
|
||
|
|
assert.ok(forms.includes('10-Q'));
|
||
|
|
assert.ok(forms.includes('8-K'));
|
||
|
|
assert.ok(!forms.includes('SC 13G'));
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('filings_index filters by formTypes + dateRange combined', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.filings_index('123', {
|
||
|
|
formTypes: ['10-K', '10-Q'],
|
||
|
|
dateRange: { from: '2026-01-01' },
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
assert.equal(filings.length, 1, 'only 10-K is in date range with form filter');
|
||
|
|
assert.equal((filings[0] as Record<string, unknown>).form, '10-K');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('filings_index throws when no recent filings', async () => {
|
||
|
|
const emptyResp = { name: 'EMPTY', filings: { recent: [] } };
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0009999999.json': { body: emptyResp },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
await assert.rejects(
|
||
|
|
() => adapter.filings_index('999'),
|
||
|
|
/no recent filings/,
|
||
|
|
);
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: company_facts (caching)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('company_facts returns data and caches for 2nd call (no re-fetch)', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: makeCompanyFactsResponse(),
|
||
|
|
etag: '"abc123"',
|
||
|
|
lastModified: 'Wed, 01 Jan 2026 00:00:00 GMT',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// First call: actual fetch
|
||
|
|
const r1 = await adapter.company_facts('123');
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
assert.equal(r1.ttlClass, 'daily_permanent');
|
||
|
|
const facts = r1.value as { entityName?: string };
|
||
|
|
assert.equal(facts.entityName, 'TEST COMPANY INC');
|
||
|
|
|
||
|
|
// Second call: should return cached data without fetching
|
||
|
|
const r2 = await adapter.company_facts('123');
|
||
|
|
assert.equal(getCallCount(), 1, 'second call should NOT re-fetch');
|
||
|
|
assert.deepEqual(r2.value, r1.value, 'cached value should match');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: 304 no-op
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('304 response returns cached row without re-fetching', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: makeCompanyFactsResponse(),
|
||
|
|
etag: '"etag-first"',
|
||
|
|
lastModified: 'Thu, 02 Jan 2026 00:00:00 GMT',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// First call: cache the data
|
||
|
|
await adapter.company_facts('123');
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
|
||
|
|
// Now make a 2nd call that returns 304 — replace the mock to return 304
|
||
|
|
let callCount2 = 0;
|
||
|
|
async function threeOhFourFetch(): Promise<Response> {
|
||
|
|
callCount2++;
|
||
|
|
return new Response('', { status: 304, headers: { 'etag': '"etag-first"', 'last-modified': 'Thu, 02 Jan 2026 00:00:00 GMT' } });
|
||
|
|
}
|
||
|
|
installFetch(threeOhFourFetch);
|
||
|
|
|
||
|
|
// Second call: should return cached data (304 no-op)
|
||
|
|
const r = await adapter.company_facts('123');
|
||
|
|
assert.equal(callCount2, 1, '304 should still invoke fetch once');
|
||
|
|
assert.equal(r.ttlClass, 'daily_permanent');
|
||
|
|
const facts = r.value as { entityName?: string };
|
||
|
|
assert.equal(facts.entityName, 'TEST COMPANY INC');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: rate-limit (>=125ms spacing)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('rate limiter enforces min 125ms between consecutive fetches', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// Fire two calls back-to-back and measure wall-clock time
|
||
|
|
const start = Date.now();
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
const elapsed = Date.now() - start;
|
||
|
|
|
||
|
|
// The adapter's token bucket enforces min 125ms between drains.
|
||
|
|
// First call drains immediately (bucket has 8 tokens), second call
|
||
|
|
// should also drain immediately since 125ms hasn't passed BUT the
|
||
|
|
// bucket has tokens. The rate-limit is about NOT exceeding 8 req/s.
|
||
|
|
// We verify that the second call didn't throw and completed within
|
||
|
|
// a reasonable time (no unbounded delay).
|
||
|
|
assert.equal(getCallCount(), 2, 'both calls should execute');
|
||
|
|
|
||
|
|
// The bucket allows bursts up to 8 tokens. So 2 rapid calls should
|
||
|
|
// complete in well under 1 second. But we assert the system doesn't
|
||
|
|
// take unreasonably long (e.g., > 2s would indicate a bug).
|
||
|
|
assert.ok(elapsed < 2000, `two calls should complete in <2s, took ${elapsed}ms`);
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rate limiter enforces spacing when bucket exhausted (burst of 8+)', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// Exhaust the bucket by making 8 rapid calls, then measure the 9th.
|
||
|
|
const start = Date.now();
|
||
|
|
for (let i = 0; i < 8; i++) {
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
}
|
||
|
|
// 9th call should trigger rate-limit wait
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
const elapsed = Date.now() - start;
|
||
|
|
|
||
|
|
// 8 calls should complete fast (burst), then 9th waits ~125ms.
|
||
|
|
// Total should be > 50ms (proving some delay occurred) and < 2s.
|
||
|
|
assert.ok(elapsed >= 50, `expected some delay from rate limiting, got ${elapsed}ms`);
|
||
|
|
assert.ok(elapsed < 2000, `9th call should complete in <2s, took ${elapsed}ms`);
|
||
|
|
assert.equal(getCallCount(), 9, 'all 9 calls should execute');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: ETag / If-Modified-Since revalidation headers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('2nd call sends ETag (If-None-Match) and Last-Modified (If-Modified-Since)', async () => {
|
||
|
|
const { mockFetch, getCalls } = createHeaderRecordingMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: makeCompanyFactsResponse(),
|
||
|
|
etag: '"my-etag-value"',
|
||
|
|
lastModified: 'Fri, 03 Jan 2026 12:00:00 GMT',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// First call: no revalidation headers expected
|
||
|
|
await adapter.company_facts('123');
|
||
|
|
const calls = getCalls();
|
||
|
|
assert.equal(calls.length, 1);
|
||
|
|
const firstCallHeaders = calls[0].headers;
|
||
|
|
assert.equal(firstCallHeaders['If-None-Match'], undefined, 'first call should NOT send If-None-Match');
|
||
|
|
assert.equal(firstCallHeaders['If-Modified-Since'], undefined, 'first call should NOT send If-Modified-Since');
|
||
|
|
|
||
|
|
// Second call: should send cached ETag + Last-Modified as revalidation headers
|
||
|
|
await adapter.company_facts('123');
|
||
|
|
assert.equal(getCalls().length, 2);
|
||
|
|
const secondCallHeaders = getCalls()[1].headers;
|
||
|
|
assert.equal(secondCallHeaders['If-None-Match'], '"my-etag-value"', 'should send cached ETag as If-None-Match');
|
||
|
|
assert.equal(secondCallHeaders['If-Modified-Since'], 'Fri, 03 Jan 2026 12:00:00 GMT', 'should send cached Last-Modified as If-Modified-Since');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('filings_index also sends revalidation headers on 2nd call', async () => {
|
||
|
|
const { mockFetch, getCalls } = createHeaderRecordingMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': {
|
||
|
|
body: makeFilingsResponse(),
|
||
|
|
etag: '"filings-etag"',
|
||
|
|
lastModified: 'Sat, 04 Jan 2026 08:00:00 GMT',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
await adapter.filings_index('123');
|
||
|
|
|
||
|
|
const secondCallHeaders = getCalls()[1].headers;
|
||
|
|
assert.equal(secondCallHeaders['If-None-Match'], '"filings-etag"', 'filings_index should send ETag revalidation');
|
||
|
|
assert.equal(secondCallHeaders['If-Modified-Since'], 'Sat, 04 Jan 2026 08:00:00 GMT', 'filings_index should send Last-Modified revalidation');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: filer_cik_meta
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('filer_cik_meta returns CIK + SIC + name, cached once', async () => {
|
||
|
|
const { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: { entityName: 'TEST COMPANY INC', sic: '7372' },
|
||
|
|
etag: '"meta-etag"',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
// First call: actual fetch
|
||
|
|
const r1 = await adapter.filer_cik_meta('123');
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
|
||
|
|
const meta = r1.value as { cik?: string; name?: string | null; sic?: string | null };
|
||
|
|
assert.equal(meta.cik, '0001234567', 'should zero-pad CIK');
|
||
|
|
assert.equal(meta.name, 'TEST COMPANY INC');
|
||
|
|
assert.equal(meta.sic, '7372');
|
||
|
|
|
||
|
|
// Second call: should return cached data
|
||
|
|
const r2 = await adapter.filer_cik_meta('123');
|
||
|
|
assert.equal(getCallCount(), 1, 'second call should NOT re-fetch');
|
||
|
|
assert.deepEqual(r2.value, r1.value, 'cached value should match');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: full_text_search
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('full_text_search returns hits from mock response', async () => {
|
||
|
|
const searchResp = {
|
||
|
|
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 { mockFetch, getCallCount } = createMockFetch({
|
||
|
|
'efts.sec.gov/LATEST/search-index': { body: searchResp },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.full_text_search('NVDA 10-K');
|
||
|
|
|
||
|
|
assert.equal(getCallCount(), 1);
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
assert.equal(filings.length, 2);
|
||
|
|
assert.equal((filings[0] as Record<string, unknown>).ticker, 'NVDA');
|
||
|
|
assert.equal((filings[1] as Record<string, unknown>).ticker, 'AAPL');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('full_text_search returns [] when no filings in response', async () => {
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'efts.sec.gov/LATEST/search-index': { body: { filings: [] } },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.full_text_search('ZZZZnotfound');
|
||
|
|
assert.deepEqual(result.value, []);
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('full_text_search sends revalidation headers on 2nd call', async () => {
|
||
|
|
const { mockFetch, getCalls } = createHeaderRecordingMockFetch({
|
||
|
|
'efts.sec.gov/LATEST/search-index': {
|
||
|
|
body: { filings: [{ ticker: 'TSLA', fileName: 'tsla_8k.pdf' }] },
|
||
|
|
etag: '"search-etag"',
|
||
|
|
lastModified: 'Sun, 05 Jan 2026 10:00:00 GMT',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
|
||
|
|
await adapter.full_text_search('tesla');
|
||
|
|
await adapter.full_text_search('tesla');
|
||
|
|
|
||
|
|
const secondCallHeaders = getCalls()[1].headers;
|
||
|
|
assert.equal(secondCallHeaders['If-None-Match'], '"search-etag"', 'should send ETag revalidation');
|
||
|
|
assert.equal(secondCallHeaders['If-Modified-Since'], 'Sun, 05 Jan 2026 10:00:00 GMT', 'should send Last-Modified revalidation');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests: fetchOne dispatch
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
test('fetchOne dispatches filings_index correctly', async () => {
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'data.sec.gov/submissions/CIK0001234567.json': { body: makeFilingsResponse() },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.fetchOne('sec:filings_index:0001234567');
|
||
|
|
assert.equal(result.ttlClass, 'daily_permanent');
|
||
|
|
const filings = result.value as Array<Record<string, unknown>>;
|
||
|
|
assert.ok(filings.length > 0);
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fetchOne dispatches company_facts correctly', async () => {
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: makeCompanyFactsResponse(),
|
||
|
|
etag: '"cf-etag"',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.fetchOne('sec:company_facts:0001234567');
|
||
|
|
assert.equal(result.ttlClass, 'daily_permanent');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fetchOne dispatches filer_meta correctly', async () => {
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'data.sec.gov/api/xbrl/companyfacts/CIK0001234567.json': {
|
||
|
|
body: { entityName: 'TEST CO', sic: '1234' },
|
||
|
|
etag: '"fm-etag"',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.fetchOne('sec:filer_meta:0001234567');
|
||
|
|
const meta = result.value as { cik: string; name: string | null; sic: string | null };
|
||
|
|
assert.equal(meta.cik, '0001234567');
|
||
|
|
assert.equal(meta.name, 'TEST CO');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fetchOne dispatches search_index correctly', async () => {
|
||
|
|
const { mockFetch } = createMockFetch({
|
||
|
|
'efts.sec.gov/LATEST/search-index': { body: { filings: [] } },
|
||
|
|
});
|
||
|
|
installFetch(mockFetch);
|
||
|
|
try {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
const result = await adapter.fetchOne('sec:search_index:hello');
|
||
|
|
assert.equal(result.ttlClass, 'daily_permanent');
|
||
|
|
} finally {
|
||
|
|
restoreFetch();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('fetchOne throws for unknown subKind', async () => {
|
||
|
|
const adapter = new EdgarAdapter();
|
||
|
|
await assert.rejects(
|
||
|
|
() => adapter.fetchOne('sec:unknown_kind:something'),
|
||
|
|
/unknown subKind/,
|
||
|
|
);
|
||
|
|
});
|