"""Chantier api — strict v1 validation (400 instead of 500), partial multi-ticker results, tier-bounded limits, HTTP hygiene (gzip, X-Request-ID, security headers, caching), /health, Swagger moved to /swagger.""" from __future__ import annotations import json import logging import pytest # ----------------------------------------------------------------------------------------------------- validation @pytest.mark.parametrize("url", [ "/v1/bars/stock/AAPL?timeframe=1day&start=bad", "/v1/bars/stock/AAPL?timeframe=1day&end=2024-13-45", "/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01T25:00:00", "/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&start=nope", "/v1/snapshot/stock?tickers=AAPL&at=yesterday", "/v1/options/chain/AAPL?trade_date=2025-04-0x", "/v1/options/chain/AAPL?expiry=soon", "/v1/options/expirations/AAPL?trade_date=x", "/v1/options/history/AAPL?strike=200&expiry=x&call_put=c", ]) def test_invalid_dates_are_400(client, url): r = client.get(url) assert r.status_code == 400, r.text body = r.json() assert body["error"]["code"] == "INVALID_PARAMETER" assert body["detail"] # legacy field assert "Traceback" not in r.text def test_end_before_start_is_400(client): r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-06-10&end=2024-06-01") assert r.status_code == 400 assert r.json()["error"]["code"] == "INVALID_PARAMETER" assert "start" in r.json()["error"]["details"] @pytest.mark.parametrize("start,end", [ ("2024-06-03", "2024-06-04"), ("2024-06-03 09:30:00", "2024-06-03 09:34:59"), ("2024-06-03T09:30:00", "2024-06-03T09:34:59Z"), ("2024-06-03T09:30:00+00:00", None), ("2024-06-03T05:30:00-04:00", "2024-06-04"), ]) def test_accepted_date_forms(client, start, end): from urllib.parse import quote q = f"start={quote(start)}" + (f"&end={quote(end)}" if end else "") r = client.get(f"/v1/bars/stock/AAPL?timeframe=1day&{q}") assert r.status_code == 200, r.text def test_window_is_honoured_and_typed(client): r = client.get("/v1/bars/stock/AAPL?timeframe=1min&start=2025-06-30 09:30:00&end=2025-06-30 09:34:59&adjustment=UNADJUSTED") assert r.status_code == 200 rows = r.json()["data"] assert len(rows) == 5 assert rows[0]["datetime"].startswith("2025-06-30 09:30") and rows[-1]["datetime"].startswith("2025-06-30 09:34") def test_too_many_tickers_is_400(client): names = ",".join(f"T{i}" for i in range(51)) r = client.get(f"/v1/bars/stock?tickers={names}&timeframe=1day") assert r.status_code == 400 assert r.json()["error"]["code"] == "TOO_MANY_TICKERS" r = client.get(f"/v1/snapshot/stock?tickers={names}&at=2025-06-30 10:00:00") assert r.status_code == 400 and r.json()["error"]["code"] == "TOO_MANY_TICKERS" def test_fifty_tickers_ok_dedup(client): names = ",".join(["AAPL", "aapl", "MSFT"] + [f"T{i}" for i in range(47)]) # 49 distinct r = client.get(f"/v1/bars/stock?tickers={names}&timeframe=1day&limit=1") assert r.status_code == 200 assert sorted({row["ticker"] for row in r.json()["data"]}) == ["AAPL", "MSFT"] def test_empty_tickers_is_400(client): r = client.get("/v1/bars/stock?tickers=,,&timeframe=1day") assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" # -------------------------------------------------------------------------------------------- partial results def test_partially_missing_tickers_200_with_header(client): r = client.get("/v1/bars/stock?tickers=AAPL,NOPE,MSFT,ZZZ&timeframe=1day&limit=2") assert r.status_code == 200, r.text assert r.headers["X-Missing-Tickers"] == "NOPE,ZZZ" body = r.json() assert body["count"] == 4 and {row["ticker"] for row in body["data"]} == {"AAPL", "MSFT"} assert r.headers["X-Row-Count"] == "4" def test_all_missing_tickers_404(client): r = client.get("/v1/bars/stock?tickers=NOPE,ZZZ&timeframe=1day") assert r.status_code == 404 assert r.json()["error"]["code"] == "TICKER_NOT_FOUND" assert r.json()["error"]["details"]["missing"] == ["NOPE", "ZZZ"] def test_no_missing_header_when_all_found(client): r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&limit=1") assert r.status_code == 200 and "X-Missing-Tickers" not in r.headers def test_snapshot_partial(client): r = client.get("/v1/snapshot/stock?tickers=AAPL,NOPE&at=2025-06-30 10:00:00&adjustment=UNADJUSTED") assert r.status_code == 200 assert r.headers["X-Missing-Tickers"] == "NOPE" body = r.json() assert body["count"] == 1 and body["data"][0]["ticker"] == "AAPL" assert body["data"][0]["datetime"].startswith("2025-06-30 10:00") def test_multi_per_ticker_limit_and_order(client): r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&limit=3&order=desc") assert r.status_code == 200 rows = r.json()["data"] assert len(rows) == 6 aapl = [x["datetime"] for x in rows if x["ticker"] == "AAPL"] assert aapl == sorted(aapl, reverse=True) and aapl[0].startswith("2025-06-30") # ------------------------------------------------------------------------------------------------- limits def test_limit_bounded_by_tier_max_rows(client): """Keyless tier: 5 000 rows per request. 6 tickers × 1 950 one-minute bars would be 11 700 rows.""" r = client.get("/v1/bars/stock?tickers=AAPL,MSFT,SMCP,SHAK,GOOG,GOOGL&timeframe=1min&limit=5000&adjustment=UNADJUSTED") assert r.status_code == 200 assert r.headers["X-Row-Count"] == "5000" and r.json()["count"] == 5000 def test_single_limit_bounded_by_tier_max_rows(client_hu, client): from core.params import bound_limit class _Req: class state: max_rows = 100 assert bound_limit(5000, 50_000, _Req()) == 100 assert bound_limit(50, 50_000, _Req()) == 50 class _NoTier: class state: pass assert bound_limit(5_000_000, 50_000, _NoTier()) == 50_000 assert bound_limit(10, 50_000, None) == 10 # --------------------------------------------------------------------------------------------- HTTP hygiene def test_gzip_when_accepted(client): r = client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=500", headers={"Accept-Encoding": "gzip"}) assert r.status_code == 200 assert r.headers.get("Content-Encoding") == "gzip" assert r.json()["count"] == 500 # httpx decodes transparently def test_no_gzip_for_small_bodies(client): r = client.get("/health", headers={"Accept-Encoding": "gzip"}) assert r.status_code in (200, 503) # /health is above 1 KiB only with many checks; the point is that gzip is negotiated, not forced r = client.get("/v1/options/quarters", headers={"Accept-Encoding": "gzip"}) assert "Content-Encoding" not in r.headers def test_request_id_generated_and_echoed(client): r = client.get("/v1/options/quarters") rid = r.headers["X-Request-ID"] assert 8 <= len(rid) <= 64 r2 = client.get("/v1/options/quarters", headers={"X-Request-ID": "trace-abc.123"}) assert r2.headers["X-Request-ID"] == "trace-abc.123" r3 = client.get("/v1/options/quarters", headers={"X-Request-ID": "bad id with spaces"}) assert r3.headers["X-Request-ID"] != "bad id with spaces" def test_request_id_on_errors_too(client): r = client.get("/v1/bars/stock/NOPE?timeframe=1day") assert r.status_code == 404 and r.headers.get("X-Request-ID") def test_access_log_line_is_json(client, caplog): with caplog.at_level(logging.INFO, logger="hfmarketdata.access"): client.get("/v1/bars/stock/AAPL?timeframe=1day&limit=7", headers={"X-Request-ID": "log-test-1"}) lines = [rec.getMessage() for rec in caplog.records if rec.name == "hfmarketdata.access"] assert lines, "no access log line" rec = json.loads(lines[-1]) assert rec["request_id"] == "log-test-1" and rec["method"] == "GET" and rec["path"] == "/v1/bars/stock/AAPL" assert rec["status"] == 200 and rec["rows"] == 7 and rec["bytes"] > 0 and rec["duration_ms"] >= 0 assert "principal" in rec and rec["query"].startswith("timeframe=1day") def test_security_headers_on_json(client): r = client.get("/v1/options/quarters") assert r.headers["Strict-Transport-Security"] == "max-age=31536000; includeSubDomains" assert r.headers["X-Content-Type-Options"] == "nosniff" assert r.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" assert r.headers["X-Frame-Options"] == "DENY" assert "Content-Security-Policy" not in r.headers def test_swagger_and_redoc_moved(client): r = client.get("/swagger") assert r.status_code == 200 and "swagger-ui" in r.text assert "Content-Security-Policy" in r.headers and "cdn.jsdelivr.net" in r.headers["Content-Security-Policy"] assert "X-Frame-Options" not in r.headers r = client.get("/redoc") assert r.status_code == 200 and "redoc" in r.text.lower() def test_docs_is_not_swagger(client): """Without a built front-end the shell is absent (404 JSON) — but never Swagger UI.""" r = client.get("/docs") assert "swagger-ui" not in r.text assert r.status_code == 404 def test_openapi_etag_and_cache(client): r = client.get("/openapi.json") assert r.status_code == 200 etag = r.headers["ETag"] assert etag.startswith('"') and r.headers["Cache-Control"].startswith("public") spec = r.json() assert spec["openapi"] == "3.1.0" and "/v1/bars/{asset}" in spec["paths"] r2 = client.get("/openapi.json", headers={"If-None-Match": etag}) assert r2.status_code == 304 and r2.headers["ETag"] == etag def test_openapi_documents_new_errors_and_headers(client): spec = client.get("/openapi.json").json() op = spec["paths"]["/v1/bars/{asset}"]["get"] assert "X-Missing-Tickers" in op["responses"]["200"]["headers"] assert "X-Request-ID" in op["responses"]["200"]["headers"] assert "TOO_MANY_TICKERS" in op["responses"]["400"]["description"] assert "TOO_MANY_TICKERS" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"] def test_history_cache_control(client): r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01&end=2024-01-31") assert r.status_code == 200 and r.headers["Cache-Control"] == "public, max-age=86400" r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01") # open-ended → not cacheable assert "Cache-Control" not in r.headers r = client.get("/v1/bars/stock/AAPL?timeframe=1day&start=2024-01-01&end=2999-01-01") assert "Cache-Control" not in r.headers r = client.get("/v1/bars/stock?tickers=AAPL,MSFT&timeframe=1day&start=2024-01-01&end=2024-01-31") assert r.headers["Cache-Control"] == "public, max-age=86400" def test_login_redirects_to_signin(client): r = client.get("/login", follow_redirects=False) assert r.status_code == 301 and r.headers["Location"] == "/signin" # ------------------------------------------------------------------------------------------------- /health def test_health_extended(client): r = client.get("/health") assert r.status_code == 200, r.text body = r.json() assert body["status"] == "ok" and body["data_root_present"] is True checks = body["checks"] assert checks["parquet"]["ok"] and checks["sqlite"]["ok"] and checks["redis"]["ok"] and checks["duckdb"]["ok"] assert checks["redis"].get("backend") == "fakeredis" assert checks["duckdb"]["rows"] > 0 and checks["duckdb"]["file"].endswith(".parquet") assert "X-RateLimit-Limit-Requests" not in r.headers # exempt from quotas assert r.headers["Cache-Control"] == "no-store" def test_health_degraded_is_503(client, monkeypatch): from core import health monkeypatch.setattr(health, "_check_sqlite", lambda: {"ok": False, "error": "boom"}) r = client.get("/health") assert r.status_code == 503 and r.json()["status"] == "degraded" assert r.json()["checks"]["sqlite"]["error"] == "boom" def test_status_is_quota_exempt(client): r = client.get("/v1/status") assert r.status_code == 200 # the request is counted, the rows are not assert r.headers.get("X-RateLimit-Remaining-Rows") == r.headers.get("X-RateLimit-Limit-Rows") # --------------------------------------------------------------------------------------- v1 errors unchanged def test_unknown_asset_404_asset_code(client): r = client.get("/v1/bars/bond/XYZ") assert r.status_code == 404 and r.json()["error"]["code"] == "ASSET_NOT_FOUND" def test_unknown_timeframe_400(client): r = client.get("/v1/bars/stock/AAPL?timeframe=2min") assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" def test_duckdb_error_never_leaks_traceback(client, monkeypatch): import duckdb import main class _Boom: def execute(self, *a, **k): raise duckdb.ConversionException("Conversion Error: invalid timestamp field format") monkeypatch.setattr(main, "con", lambda: _Boom()) r = client.get("/v1/bars/stock/AAPL?timeframe=1day") assert r.status_code == 400 assert r.json()["error"]["code"] == "INVALID_PARAMETER" and "Traceback" not in r.text class _IO: def execute(self, *a, **k): raise duckdb.IOException("IO Error: No such file") monkeypatch.setattr(main, "con", lambda: _IO()) r = client.get("/v1/bars/stock/AAPL?timeframe=1day") assert r.status_code == 503 and r.json()["error"]["code"] == "SERVICE_UNAVAILABLE"