Files
signal-platform/tests/unit/test_sec_client.py
T
dennisthiessenandClaude Opus 4.8 5939be7b7f fix(sec): A3 slice-1 review — read-only resolution, error propagation, fair-access
Addresses the slice-1 review:

1. Resolution is now read-only (A1 transaction contract). resolve_ciks /
   fetch_sic_updates compute proposals and mutate nothing; a new
   apply_ticker_updates issues the writes, called only in promote — so a failed
   validation can't leak ticker changes on the framework's failure commit.
2. Only 404 means "missing". Added SecNotFoundError; daily_index /
   latest_index_date catch only that. 403, exhausted 429, 5xx, timeouts, and
   transport/parse errors now propagate instead of looking like "no index".
3. Fair-access enforced when opening a REAL client (transport=None): reject
   blank/placeholder/non-email User-Agent and sub-0.11s spacing. Mock transports
   skip it (tests use 0 spacing).
4. submissions(include_history=False) by default — only the one-time full
   backfill fetches the history shards; SIC/incremental work makes no extra
   requests.

Plus: retry transient 5xx/network errors and honor Retry-After during the 1 GB
backfill; compose_revision rejects a missing index date (no "None:..." revision).

Re-verified live vs real SEC (fair-access validation passes, shard merge intact).
Tests: 18 (added error propagation, read-only resolution, fair-access, recent-only
submissions, reject-None revision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:49:33 +02:00

224 lines
8.2 KiB
Python

"""Tests for the SEC client's parsing and fair-access behavior, via a mocked
httpx transport (no network)."""
from __future__ import annotations
import json
from datetime import date
import httpx
import pytest
from app.services import sec_client as sc
from app.services.sec_client import SecClient, SecError, SecForbiddenError
COMPANY_TICKERS = {
"0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."},
"1": {"cik_str": 1652044, "ticker": "GOOGL", "title": "Alphabet"},
"2": {"cik_str": 1652044, "ticker": "GOOG", "title": "Alphabet"},
"3": {"cik_str": 1067983, "ticker": "BRK-B", "title": "Berkshire"},
}
SUBMISSIONS_BASE = {
"cik": 320193,
"name": "Apple Inc.",
"sic": "3571",
"sicDescription": "Electronic Computers",
"fiscalYearEnd": "0926",
"tickers": ["AAPL"],
"filings": {
"recent": {
"accessionNumber": ["0000320193-26-000013", "0000320193-26-000006", "0000320193-26-000099"],
"form": ["10-Q", "10-K", "8-K"],
"reportDate": ["2026-03-28", "2025-09-27", "2026-04-01"],
"acceptanceDateTime": ["2026-05-01T10:01:00.000Z", "2025-10-31T10:01:26.000Z", "2026-04-02T09:00:00.000Z"],
"isXBRL": [1, 1, 0],
},
"files": [{"name": "CIK0000320193-submissions-001.json", "filingFrom": "1994-01-26", "filingTo": "2015-05-27"}],
},
}
SUBMISSIONS_SHARD = {
"accessionNumber": ["0000320193-94-000002"],
"form": ["10-Q"],
"reportDate": ["1993-12-31"],
"acceptanceDateTime": ["1994-01-26T05:00:00.000Z"],
"isXBRL": [0],
}
FORM_IDX = """Description: Daily Index of EDGAR Dissemination Feed by Form Type
Form Type Company Name CIK Date Filed File Name
-------------------------------------------------------------------------------
10-K/A Starfighters Space, Inc. 1947016 20260721 edgar/data/1947016/0001062993-26-003746.txt
10-Q 3M CO 66740 20260721 edgar/data/66740/0000066740-26-000246.txt
8-K Some Corp 12345 20260721 edgar/data/12345/0000012345-26-000001.txt
10-Q CALIX, INC 1406666 20260721 edgar/data/1406666/0001406666-26-000034.txt
"""
DIR_JSON = {"directory": {"item": [
{"name": "form.20260720.idx"}, {"name": "form.20260721.idx"}, {"name": "company.20260721.idx"},
]}}
def _handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
if url.endswith("company_tickers.json"):
return httpx.Response(200, json=COMPANY_TICKERS)
if url.endswith("submissions/CIK0000320193.json"):
return httpx.Response(200, json=SUBMISSIONS_BASE)
if url.endswith("CIK0000320193-submissions-001.json"):
return httpx.Response(200, json=SUBMISSIONS_SHARD)
if url.endswith("form.20260721.idx"):
return httpx.Response(200, text=FORM_IDX)
if url.endswith("QTR3/index.json"):
return httpx.Response(200, json=DIR_JSON)
return httpx.Response(404)
def _client(**kw):
return SecClient(transport=httpx.MockTransport(_handler), spacing_seconds=0, **kw)
async def test_company_tickers_normalised_and_multiclass():
async with _client() as c:
m = await c.company_tickers()
assert m["AAPL"] == 320193
assert m["GOOGL"] == m["GOOG"] == 1652044 # multi-class share one CIK
assert m["BRK-B"] == 1067983 # dash form
async def test_submissions_history_merges_shards_and_filters_forms():
async with _client() as c:
sub = await c.submissions(320193, include_history=True)
assert sub["sic"] == "3571" and sub["fiscal_year_end"] == "0926"
accns = {f["accession"] for f in sub["filings"]}
# 10-Q + 10-K from recent, 10-Q from the shard; the 8-K is filtered out
assert accns == {"0000320193-26-000013", "0000320193-26-000006", "0000320193-94-000002"}
older = next(f for f in sub["filings"] if f["accession"] == "0000320193-94-000002")
assert older["report_date"] == "1993-12-31" and older["is_xbrl"] is False
async def test_submissions_recent_only_skips_shard_requests():
seen = []
def handler(request):
seen.append(str(request.url))
return _handler(request)
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) as c:
sub = await c.submissions(320193) # include_history defaults False
assert not any("submissions-001" in u for u in seen) # no shard fetch
accns = {f["accession"] for f in sub["filings"]}
assert accns == {"0000320193-26-000013", "0000320193-26-000006"} # recent only
async def test_daily_index_parses_10kq_rows():
async with _client() as c:
rows = await c.daily_index(date(2026, 7, 21))
forms = {r["form"] for r in rows}
assert forms == {"10-K/A", "10-Q"} # 8-K excluded
mmm = next(r for r in rows if r["cik"] == 66740)
assert mmm["accession"] == "0000066740-26-000246"
async def test_latest_index_date():
async with _client() as c:
d = await c.latest_index_date(today=date(2026, 7, 22))
assert d == date(2026, 7, 21)
async def test_403_raises_forbidden_no_retry():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
return httpx.Response(403)
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) as c:
with pytest.raises(SecForbiddenError):
await c.get_json("https://data.sec.gov/x")
assert calls["n"] == 1 # alert and stop, never retry-loop
async def test_429_retries_then_succeeds(monkeypatch):
async def _instant(_):
return None
monkeypatch.setattr(sc.asyncio, "sleep", _instant) # no real backoff wait
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] < 3:
return httpx.Response(429)
return httpx.Response(200, json={"ok": True})
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=5) as c:
data = await c.get_json("https://data.sec.gov/y")
assert data == {"ok": True} and calls["n"] == 3
async def test_429_gives_up_after_max_retries(monkeypatch):
async def _instant(_):
return None
monkeypatch.setattr(sc.asyncio, "sleep", _instant)
def handler(request):
return httpx.Response(429)
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=2) as c:
with pytest.raises(SecError):
await c.get_json("https://data.sec.gov/z")
def _status_client(status):
def handler(request):
return httpx.Response(status)
# max_retries=0 so 5xx/429 raise immediately (no retry sleeps)
return SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=0)
async def test_index_methods_propagate_403():
# A 403 must NOT be mistaken for "no index".
async with _status_client(403) as c:
with pytest.raises(sc.SecForbiddenError):
await c.daily_index(date(2026, 7, 21))
async with _status_client(403) as c:
with pytest.raises(sc.SecForbiddenError):
await c.latest_index_date(today=date(2026, 7, 22))
async def test_index_methods_propagate_500():
async with _status_client(500) as c:
with pytest.raises(SecError):
await c.daily_index(date(2026, 7, 21))
async with _status_client(500) as c:
with pytest.raises(SecError):
await c.latest_index_date(today=date(2026, 7, 22))
async def test_only_404_is_treated_as_missing():
async with _status_client(404) as c:
assert await c.daily_index(date(2026, 7, 21)) == []
assert await c.latest_index_date(today=date(2026, 7, 22)) is None
async def test_fair_access_validation_on_real_client():
# Placeholder email rejected.
with pytest.raises(SecError):
async with SecClient(user_agent="signal-platform (contact: you@example.com)"):
pass
# Non-email UA rejected.
with pytest.raises(SecError):
async with SecClient(user_agent="signal-platform"):
pass
# Valid UA but unsafe production spacing rejected.
with pytest.raises(SecError):
async with SecClient(user_agent="signal-platform real@person.io", spacing_seconds=0.0):
pass
# Valid UA + safe spacing opens fine.
async with SecClient(user_agent="signal-platform real@person.io", spacing_seconds=0.2):
pass