feat: add initial frontend setup with styles, types, and testing framework
- Created a global CSS file for styling the frontend with responsive design. - Introduced TypeScript types for various entities including Research, Alpha, and Account. - Implemented Playwright tests for account management and data synchronization workflows. - Configured TypeScript with strict settings and included necessary libraries. - Set up Vite as the build tool with React plugin and API proxy configuration. - Added a Python script to initialize environment variables securely.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""Isolated browser acceptance server: temporary database, synthetic upstream, no real network."""
|
||||
|
||||
import tempfile
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.models import Base
|
||||
from app.worldquant import WqClient
|
||||
|
||||
TEST_PASSWORD = "browser-test-password"
|
||||
|
||||
|
||||
def sample(index):
|
||||
super_alpha = index % 7 == 0
|
||||
language = "PYTHON" if index % 11 == 0 and not super_alpha else "FASTEXPR"
|
||||
names = [
|
||||
"短期价格反转",
|
||||
"成交量与价格背离",
|
||||
"分析师预期修正",
|
||||
"行业中性动量",
|
||||
"低波动质量信号",
|
||||
"资金流向偏离",
|
||||
"组合选择实验",
|
||||
]
|
||||
return {
|
||||
"id": f"TEST{index:04}",
|
||||
"name": names[index % len(names)] + f" · {index:03}",
|
||||
"type": "SUPER" if super_alpha else "REGULAR",
|
||||
"stage": "OS" if index % 3 == 0 else "IS",
|
||||
"status": "ACTIVE" if index % 3 == 0 else "UNSUBMITTED",
|
||||
"hidden": index % 5 == 0,
|
||||
"regular": None
|
||||
if super_alpha
|
||||
else {
|
||||
"code": "def alpha(data):\n return -data.close.rank()"
|
||||
if language == "PYTHON"
|
||||
else "group_neutralize(-rank(ts_delta(close, 5)), industry)"
|
||||
},
|
||||
"selection": {"code": "self_correlation < 0.5"} if super_alpha else None,
|
||||
"combo": {"code": "alpha"} if super_alpha else None,
|
||||
"settings": {
|
||||
"region": ["USA", "CHN", "EUR"][index % 3],
|
||||
"universe": "TOP3000",
|
||||
"language": language,
|
||||
"delay": 1,
|
||||
"decay": 4,
|
||||
"neutralization": "INDUSTRY",
|
||||
"truncation": 0.08,
|
||||
"pasteurization": "ON",
|
||||
},
|
||||
"is": {
|
||||
"sharpe": None if index % 13 == 0 else round(0.8 + (index % 25) * 0.1, 2),
|
||||
"fitness": round(0.3 + (index % 20) * 0.06, 2),
|
||||
"turnover": round(0.08 + (index % 12) * 0.015, 3),
|
||||
"returns": 0.09,
|
||||
"margin": 0.0002,
|
||||
"drawdown": 0.11,
|
||||
"checks": [{"name": "LOW_SHARPE", "result": "PASS", "value": 2.1, "limit": 1.58}],
|
||||
},
|
||||
"os": {"sharpe": 1.1} if index % 3 == 0 else None,
|
||||
"dateCreated": (datetime(2025, 1, 1, tzinfo=timezone.utc) + timedelta(days=index)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def create_test_app():
|
||||
temporary = tempfile.TemporaryDirectory(prefix="wq-browser-test-")
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
database_url=f"sqlite+aiosqlite:///{temporary.name}/test.db",
|
||||
admin_password=TEST_PASSWORD,
|
||||
encryption_key=Fernet.generate_key().decode(),
|
||||
public_origin="http://127.0.0.1:5179",
|
||||
)
|
||||
records = [sample(i) for i in range(620)]
|
||||
|
||||
def upstream(request):
|
||||
path = request.url.path
|
||||
if path == "/authentication" and request.method == "POST":
|
||||
return httpx.Response(
|
||||
201, json={"user": {"id": "TEST_USER"}}, headers={"Set-Cookie": "mock=only; Path=/"}
|
||||
)
|
||||
if request.method != "GET":
|
||||
raise AssertionError("Browser acceptance attempted an upstream mutation")
|
||||
if path == "/users/self":
|
||||
return httpx.Response(
|
||||
200, json={"id": "TEST_USER", "name": "模拟研究员", "email": "test@example.com"}
|
||||
)
|
||||
if path == "/users/self/alphas":
|
||||
unsubmitted = "status" in request.url.params
|
||||
hidden = request.url.params.get("hidden") == "true"
|
||||
matched = [
|
||||
r for r in records if (r["status"] == "UNSUBMITTED") == unsubmitted and r["hidden"] == hidden
|
||||
]
|
||||
offset, limit = (
|
||||
int(request.url.params.get("offset", 0)),
|
||||
int(request.url.params.get("limit", 100)),
|
||||
)
|
||||
return httpx.Response(
|
||||
200, json={"count": len(matched), "results": matched[offset : offset + limit]}
|
||||
)
|
||||
if path.endswith("/recordsets/pnl"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||
"records": [
|
||||
[
|
||||
(datetime(2025, 1, 1) + timedelta(days=i)).date().isoformat(),
|
||||
i * 250 + (i % 13) * 85,
|
||||
]
|
||||
for i in range(180)
|
||||
],
|
||||
},
|
||||
)
|
||||
record = next((r for r in records if path == f"/alphas/{r['id']}"), None)
|
||||
return httpx.Response(200, json=record) if record else httpx.Response(404)
|
||||
|
||||
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(upstream)))
|
||||
original_lifespan = application.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
async with app.state.engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
async with original_lifespan(app):
|
||||
yield
|
||||
temporary.cleanup()
|
||||
|
||||
application.router.lifespan_context = lifespan
|
||||
return application
|
||||
@@ -0,0 +1,71 @@
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
from app.models import Base
|
||||
from app.worldquant import WqClient
|
||||
|
||||
ADMIN_PASSWORD = "test-admin-password-only"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path):
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
database_url=f"sqlite+aiosqlite:///{tmp_path}/test.db",
|
||||
admin_password=ADMIN_PASSWORD,
|
||||
encryption_key=Fernet.generate_key().decode(),
|
||||
enable_runner=False,
|
||||
public_origin="http://testserver",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app(settings):
|
||||
def unexpected_request(request):
|
||||
raise AssertionError("Unit tests must never access the real platform")
|
||||
|
||||
application = create_app(settings, WqClient(settings, transport=httpx.MockTransport(unexpected_request)))
|
||||
async with application.state.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async with application.router.lifespan_context(application):
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers={"X-WQ-Request": "1"}
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def logged_in(client):
|
||||
response = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
assert response.status_code == 200
|
||||
return client
|
||||
|
||||
|
||||
def alpha(alpha_id="alpha1", **changes):
|
||||
raw = {
|
||||
"id": alpha_id,
|
||||
"name": "Reversal",
|
||||
"type": "REGULAR",
|
||||
"stage": "IS",
|
||||
"status": "UNSUBMITTED",
|
||||
"hidden": False,
|
||||
"regular": {"code": "-rank(ts_delta(close, 5))"},
|
||||
"settings": {"region": "USA", "universe": "TOP3000", "language": "FASTEXPR", "delay": 1},
|
||||
"is": {
|
||||
"sharpe": 1.5,
|
||||
"fitness": 0.8,
|
||||
"turnover": 0.2,
|
||||
"returns": 0.04,
|
||||
"checks": [{"name": "LOW_SHARPE", "result": "FAIL", "value": 1.5, "limit": 1.58}],
|
||||
},
|
||||
"dateCreated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
return {**raw, **changes}
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Run against an isolated acceptance Compose project; never use a personal data stack.
|
||||
|
||||
From the repository root:
|
||||
python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env
|
||||
Requires images already built and services started with -p wq-alpha-acceptance.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--env-file", required=True, type=Path)
|
||||
parser.add_argument("--project", default="wq-alpha-acceptance")
|
||||
args = parser.parse_args()
|
||||
if not args.project.startswith("wq-alpha-acceptance"):
|
||||
raise SystemExit("Only an isolated wq-alpha-acceptance* project is allowed")
|
||||
values = dict(
|
||||
line.split("=", 1)
|
||||
for line in args.env_file.read_text().splitlines()
|
||||
if "=" in line and not line.startswith("#")
|
||||
)
|
||||
compose = ["docker", "compose", "--env-file", str(args.env_file), "-p", args.project]
|
||||
|
||||
def run(arguments, data=None):
|
||||
result = subprocess.run(compose + arguments, input=data, capture_output=True, check=True)
|
||||
return result.stdout
|
||||
|
||||
# Assert that we are addressing the isolated project rather than an unrelated localhost app.
|
||||
published = run(["port", "web", "80"]).decode().strip()
|
||||
assert published == f"127.0.0.1:{values.get('LOCAL_PORT', '8080')}"
|
||||
base = f"http://localhost:{values.get('LOCAL_PORT', '8080')}"
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
||||
)
|
||||
|
||||
def request(path, method="GET", payload=None):
|
||||
body = json.dumps(payload).encode() if payload is not None else None
|
||||
return opener.open(
|
||||
urllib.request.Request(
|
||||
base + path,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json", "X-WQ-Request": "1", "Origin": base},
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert json.load(request("/api/v1/health")) == {"status": "ok"}
|
||||
html = request("/")
|
||||
assert "frame-ancestors 'none'" in html.headers["Content-Security-Policy"]
|
||||
assert b'<div id="root">' in html.read()
|
||||
try:
|
||||
request("/api/v1/alphas")
|
||||
raise AssertionError("Unauthenticated data was exposed")
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 401
|
||||
json.load(
|
||||
request(
|
||||
"/api/v1/auth/login",
|
||||
"POST",
|
||||
{"username": values.get("ADMIN_USERNAME", "admin"), "password": values["ADMIN_PASSWORD"]},
|
||||
)
|
||||
)
|
||||
code = b"""import asyncio
|
||||
from app.config import Settings
|
||||
from app.db import create_database
|
||||
from app.alphas import upsert_alpha
|
||||
async def seed():
|
||||
engine, sessions = create_database(Settings().database_url)
|
||||
async with sessions() as db:
|
||||
await upsert_alpha(db, {"id":"DOCKER_ACCEPTANCE", "name":"Synthetic acceptance record", "type":"REGULAR", "stage":"IS", "status":"UNSUBMITTED", "regular":{"code":"rank(close)"}, "settings":{"region":"USA", "language":"FASTEXPR"}, "is":{"sharpe":2.0}})
|
||||
await db.commit()
|
||||
await engine.dispose()
|
||||
asyncio.run(seed())
|
||||
"""
|
||||
run(["exec", "-T", "backend", "python", "-"], code)
|
||||
json.load(
|
||||
request(
|
||||
"/api/v1/alphas/DOCKER_ACCEPTANCE/research",
|
||||
"PATCH",
|
||||
{
|
||||
"note": "persistent local note",
|
||||
"tags": ["docker-verified"],
|
||||
"state": "candidate",
|
||||
"favorite": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
print("PASS: PostgreSQL migration, login, API authorization, local research write")
|
||||
run(["up", "-d", "--force-recreate", "--wait"])
|
||||
# A persisted server session and all database rows survive container replacement.
|
||||
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||||
assert detail["research"]["note"] == "persistent local note"
|
||||
run(["exec", "-T", "backend", "python", "-"], code.replace(b'"sharpe":2.0', b'"sharpe":3.0'))
|
||||
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||||
assert detail["sharpe"] == 3 and detail["research"]["state"] == "candidate"
|
||||
assert detail["research"]["tags"] == ["docker-verified"]
|
||||
print("PASS: container replacement preserves session and data; snapshot update preserves research")
|
||||
dump = run(["exec", "-T", "db", "pg_dump", "-U", "wq", "-d", "wq", "-Fc", "--no-owner"])
|
||||
backup = Path(".local/docker-acceptance.dump")
|
||||
fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
output.write(dump)
|
||||
# Only this script's scratch restore database is replaced on repeated acceptance runs.
|
||||
run(["exec", "-T", "db", "dropdb", "-U", "wq", "--if-exists", "wq_acceptance_restore"])
|
||||
run(["exec", "-T", "db", "createdb", "-U", "wq", "wq_acceptance_restore"])
|
||||
run(
|
||||
[
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"pg_restore",
|
||||
"-U",
|
||||
"wq",
|
||||
"-d",
|
||||
"wq_acceptance_restore",
|
||||
"--no-owner",
|
||||
"--exit-on-error",
|
||||
],
|
||||
dump,
|
||||
)
|
||||
rows = (
|
||||
run(
|
||||
[
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"psql",
|
||||
"-U",
|
||||
"wq",
|
||||
"-d",
|
||||
"wq_acceptance_restore",
|
||||
"-At",
|
||||
"-c",
|
||||
"SELECT note || '|' || state FROM research WHERE alpha_id = 'DOCKER_ACCEPTANCE'",
|
||||
]
|
||||
)
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
assert rows == "persistent local note|candidate"
|
||||
print("PASS: PostgreSQL custom-format backup restores records into independent database")
|
||||
config = json.loads(run(["-f", "compose.public.yaml", "config", "--format", "json"]))
|
||||
assert config["services"]["backend"]["environment"]["COOKIE_SECURE"] == "true"
|
||||
assert config["services"]["backend"]["environment"]["PUBLIC_ORIGIN"].startswith("https://")
|
||||
assert "ports" not in config["services"]["db"] and "ports" not in config["services"]["backend"]
|
||||
run(
|
||||
[
|
||||
"exec",
|
||||
"-T",
|
||||
"web",
|
||||
"caddy",
|
||||
"validate",
|
||||
"--config",
|
||||
"/etc/caddy/Caddyfile",
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
]
|
||||
)
|
||||
print("PASS: Caddy configuration and public HTTPS/Secure-cookie configuration checks")
|
||||
# Keep the backend process alive while its database disappears and returns.
|
||||
run(["stop", "db"])
|
||||
run(["up", "-d", "--wait", "db"])
|
||||
probe = b"""import asyncio
|
||||
from app.config import Settings
|
||||
from app.db import create_database
|
||||
from app.jobs import create_job
|
||||
async def enqueue():
|
||||
engine, sessions = create_database(Settings().database_url)
|
||||
async with sessions() as db:
|
||||
job = await create_job(db, "profile")
|
||||
print(job.id)
|
||||
await engine.dispose()
|
||||
asyncio.run(enqueue())
|
||||
"""
|
||||
job_id = run(["exec", "-T", "backend", "python", "-"], probe).decode().strip()
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
status = json.load(request(f"/api/v1/sync-jobs/{job_id}"))["status"]
|
||||
if status == "waiting_connection":
|
||||
break
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise AssertionError("Scheduler did not resume after database restart")
|
||||
print("PASS: live backend resumes task processing after database stop/start")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,232 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.alphas import upsert_alpha
|
||||
from app.models import Account, Admin, LoginSession, Research, now
|
||||
from app.security import cipher
|
||||
from tests.conftest import ADMIN_PASSWORD, alpha
|
||||
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
|
||||
async def seed(app, count=1):
|
||||
async with app.state.sessions() as db:
|
||||
for i in range(count):
|
||||
await upsert_alpha(db, alpha(f"a{i:04}", name=f"Signal {i:04}", **{"is": {"sharpe": i / 100}}))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_login_logout_expiry_and_password_hash(app, client):
|
||||
assert (await client.get(f"{PREFIX}/alphas")).status_code == 401
|
||||
wrong = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert wrong.status_code == 401
|
||||
login = await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
assert login.status_code == 200
|
||||
assert "HttpOnly" in login.headers["set-cookie"] and "SameSite=strict" in login.headers["set-cookie"]
|
||||
token = client.cookies.get("wq_session")
|
||||
async with app.state.sessions() as db:
|
||||
admin = await db.get(Admin, 1)
|
||||
session = (await db.scalars(select(LoginSession))).one()
|
||||
assert admin.password_hash.startswith("$argon2id$") and ADMIN_PASSWORD not in admin.password_hash
|
||||
assert session.token_hash != token
|
||||
assert (await client.post(f"{PREFIX}/auth/logout")).status_code == 200
|
||||
client.cookies.set("wq_session", token)
|
||||
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
|
||||
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
async with app.state.sessions() as db:
|
||||
session = (await db.scalars(select(LoginSession))).one()
|
||||
session.expires_at = now() - timedelta(seconds=1)
|
||||
await db.commit()
|
||||
assert (await client.get(f"{PREFIX}/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_login_rate_limit_and_csrf(client):
|
||||
for _ in range(5):
|
||||
assert (
|
||||
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
).status_code == 401
|
||||
assert (
|
||||
await client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD})
|
||||
).status_code == 429
|
||||
assert (
|
||||
await client.post(f"{PREFIX}/auth/login", headers={"X-WQ-Request": ""}, json={})
|
||||
).status_code == 403
|
||||
assert (
|
||||
await client.post(f"{PREFIX}/auth/login", headers={"Origin": "https://evil.example"}, json={})
|
||||
).status_code == 403
|
||||
|
||||
|
||||
async def test_public_session_cookie_is_secure(app, client):
|
||||
app.state.settings.cookie_secure = True
|
||||
response = await client.post(
|
||||
f"{PREFIX}/auth/login", json={"username": "admin", "password": ADMIN_PASSWORD}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "; Secure" in response.headers["set-cookie"] and "; HttpOnly" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
async def test_credentials_encrypted_and_never_returned(app, logged_in):
|
||||
secret = "private-worldquant-password"
|
||||
r = await logged_in.put(
|
||||
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": secret}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["configured"] is True
|
||||
assert "password" not in r.text and secret not in r.text
|
||||
async with app.state.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
assert secret not in account.password_encrypted
|
||||
assert cipher(app.state.settings).decrypt(account.password_encrypted.encode()).decode() == secret
|
||||
account.wq_user_id = "person"
|
||||
await db.commit()
|
||||
assert (
|
||||
await logged_in.put(
|
||||
f"{PREFIX}/account/credentials", json={"email": "other@example.com", "password": secret}
|
||||
)
|
||||
).status_code == 409
|
||||
invalid = await logged_in.put(
|
||||
f"{PREFIX}/account/credentials", json={"email": "invalid", "password": secret}
|
||||
)
|
||||
assert invalid.status_code == 422 and secret not in invalid.text
|
||||
response = await logged_in.get(f"{PREFIX}/account")
|
||||
assert response.json()["profile"] == {} and "password" not in response.text
|
||||
|
||||
|
||||
async def test_preferences_and_openapi(app, logged_in):
|
||||
values = {"display_name": "My Lab", "theme": "dark", "timezone": "UTC", "page_size": 100}
|
||||
r = await logged_in.patch(f"{PREFIX}/account/preferences", json=values)
|
||||
assert r.status_code == 200 and all(r.json()[k] == v for k, v in values.items())
|
||||
values["timezone"] = "invalid/timezone"
|
||||
assert (await logged_in.patch(f"{PREFIX}/account/preferences", json=values)).status_code == 422
|
||||
contract = app.openapi()
|
||||
assert contract["paths"]["/api/v1/alphas"]["get"]["security"] == [{"APIKeyCookie": []}]
|
||||
assert "text/csv" in contract["paths"]["/api/v1/alphas/export"]["get"]["responses"]["200"]["content"]
|
||||
assert contract["paths"]["/api/v1/alphas"]["get"]["responses"]["422"]["content"]["application/json"][
|
||||
"schema"
|
||||
]["$ref"].endswith("/ErrorOutput")
|
||||
|
||||
|
||||
async def test_local_research_survives_snapshot_and_bulk_is_atomic(app, logged_in):
|
||||
await seed(app, 3)
|
||||
r = await logged_in.patch(
|
||||
f"{PREFIX}/alphas/a0000/research",
|
||||
json={"note": "keep hypothesis", "tags": ["a", "a"], "state": "candidate", "favorite": True},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
async with app.state.sessions() as db:
|
||||
await upsert_alpha(db, alpha("a0000", **{"is": {"sharpe": 9.25}}))
|
||||
await db.commit()
|
||||
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
|
||||
assert detail["sharpe"] == 9.25 and detail["fitness"] is None
|
||||
assert detail["research"]["note"] == "keep hypothesis" and detail["research"]["favorite"]
|
||||
assert detail["research"]["tags"] == ["a"] and detail["research"]["state"] == "candidate"
|
||||
invalid = await logged_in.patch(
|
||||
f"{PREFIX}/alphas/research/bulk", json={"alpha_ids": ["a0000", "missing"], "add_tags": ["bad"]}
|
||||
)
|
||||
assert invalid.status_code == 404
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas?tag=bad")).json()["total"] == 0
|
||||
r = await logged_in.patch(
|
||||
f"{PREFIX}/alphas/research/bulk",
|
||||
json={
|
||||
"alpha_ids": ["a0000", "a0001"],
|
||||
"add_tags": ["new"],
|
||||
"remove_tags": ["a"],
|
||||
"state": "optimizing",
|
||||
},
|
||||
)
|
||||
assert r.json()["updated"] == 2
|
||||
result = (await logged_in.get(f"{PREFIX}/alphas?tag=new&research_state=optimizing")).json()
|
||||
assert result["total"] == 2
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas?tag=ne")).json()["total"] == 0
|
||||
await logged_in.patch(f"{PREFIX}/alphas/a0000/research", json={"note": "updated"})
|
||||
detail = (await logged_in.get(f"{PREFIX}/alphas/a0000")).json()
|
||||
assert detail["research"]["favorite"] and detail["research"]["tags"] == ["new"]
|
||||
|
||||
|
||||
async def test_filter_pagination_sort_and_export_over_500(app, logged_in):
|
||||
await seed(app, 603)
|
||||
query = "region=USA&sharpe_min=0.02&sharpe_max=6&sort=sharpe&direction=desc"
|
||||
page = (await logged_in.get(f"{PREFIX}/alphas?{query}&offset=10&limit=25")).json()
|
||||
assert page["total"] == 599 and len(page["items"]) == 25
|
||||
assert page["items"][0]["id"] == "a0590"
|
||||
exported = await logged_in.get(f"{PREFIX}/alphas/export?{query}&offset=10&limit=25")
|
||||
rows = list(csv.DictReader(io.StringIO(exported.text.lstrip("\ufeff"))))
|
||||
assert len(rows) == 599 and rows[0]["id"] == "a0600" and rows[-1]["id"] == "a0002"
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas?q=Signal%200001")).json()["total"] == 1
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas?q=%25")).json()["total"] == 0
|
||||
for query in (
|
||||
"sort=raw",
|
||||
"sharpe_min=nan",
|
||||
"sharpe_min=inf",
|
||||
"sharpe_min=5&sharpe_max=2",
|
||||
"limit=1000",
|
||||
"created_from=2026-01-01&created_to=2025-01-01T00:00:00Z",
|
||||
):
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas?{query}")).status_code == 422
|
||||
|
||||
|
||||
async def test_export_formula_injection_and_detail_variants(app, logged_in):
|
||||
async with app.state.sessions() as db:
|
||||
await upsert_alpha(
|
||||
db,
|
||||
alpha(
|
||||
"super1",
|
||||
name="=DANGEROUS()",
|
||||
type="SUPER",
|
||||
regular=None,
|
||||
selection={"code": "self_correlation < 0.5"},
|
||||
combo={"code": "alpha"},
|
||||
settings={"language": "FASTEXPR"},
|
||||
**{"is": {"sharpe": None, "fitness": float("nan")}},
|
||||
),
|
||||
)
|
||||
await upsert_alpha(
|
||||
db,
|
||||
alpha(
|
||||
"python1", regular={"code": "def alpha():\n return close"}, settings={"language": "PYTHON"}
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
row = await db.get(Research, "super1")
|
||||
row.note = " @formula()"
|
||||
await db.commit()
|
||||
detail = (await logged_in.get(f"{PREFIX}/alphas/super1")).json()
|
||||
assert detail["selection"] == "self_correlation < 0.5" and detail["combo"] == "alpha"
|
||||
assert detail["expression"] is None and detail["sharpe"] is None and detail["fitness"] is None
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas/python1")).json()["language"] == "PYTHON"
|
||||
csv_data = (await logged_in.get(f"{PREFIX}/alphas/export?q=super1")).text
|
||||
row = list(csv.DictReader(io.StringIO(csv_data.lstrip("\ufeff"))))[0]
|
||||
assert row["name"] == "'=DANGEROUS()" and row["note"] == "' @formula()"
|
||||
assert (await logged_in.get(f"{PREFIX}/alphas/super1/pnl")).json() == {
|
||||
"cached": False,
|
||||
"points": [],
|
||||
"fetched_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_job_api_deduplicates_and_retries(app, logged_in):
|
||||
assert (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).status_code == 409
|
||||
await logged_in.put(
|
||||
f"{PREFIX}/account/credentials", json={"email": "person@example.com", "password": "only-testing"}
|
||||
)
|
||||
connect = await logged_in.post(f"{PREFIX}/account/connect")
|
||||
assert connect.status_code == 202
|
||||
first = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
|
||||
again = (await logged_in.post(f"{PREFIX}/sync-jobs", json={"kind": "full_sync"})).json()
|
||||
assert first["id"] == again["id"]
|
||||
await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/cancel")
|
||||
assert (await logged_in.get(f"{PREFIX}/sync-jobs/{first['id']}")).json()["status"] == "cancelled"
|
||||
assert (await logged_in.post(f"{PREFIX}/sync-jobs/{first['id']}/retry")).json()["status"] == "queued"
|
||||
assert (
|
||||
await logged_in.post(
|
||||
f"{PREFIX}/sync-jobs", json={"kind": "alpha_refresh", "alpha_ids": ["../authentication"]}
|
||||
)
|
||||
).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["account", "alphas", "alphas/export", "alphas/a1/pnl", "sync-jobs"])
|
||||
async def test_every_data_endpoint_requires_session(client, endpoint):
|
||||
assert (await client.get(f"{PREFIX}/{endpoint}")).status_code == 401
|
||||
@@ -0,0 +1,251 @@
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.jobs import Runner, create_job
|
||||
from app.models import Account, Alpha, Job, JobItem, Pnl, Research
|
||||
from app.security import cipher
|
||||
from app.worldquant import VerificationRequired, WqError
|
||||
from tests.conftest import alpha
|
||||
|
||||
|
||||
class FakePlatform:
|
||||
def __init__(self):
|
||||
self.verification_url = None
|
||||
self.on_retry = None
|
||||
self.page_calls = []
|
||||
self.fail_page = True
|
||||
self.fail_id = True
|
||||
self.require_verification = False
|
||||
self.block = None
|
||||
|
||||
async def authenticate(self, *args, **kwargs):
|
||||
if self.require_verification:
|
||||
self.verification_url = "https://api.worldquantbrain.com/authentication/persona/test"
|
||||
raise VerificationRequired(self.verification_url)
|
||||
|
||||
async def verify(self):
|
||||
self.require_verification = False
|
||||
self.verification_url = None
|
||||
|
||||
async def profile(self):
|
||||
return {"id": "user1", "email": "test@example.com", "password": "never-save", "unknown": 1}
|
||||
|
||||
async def alphas(self, submission, hidden, offset, before):
|
||||
self.page_calls.append((submission, hidden, offset, before))
|
||||
if self.block:
|
||||
self.block.set()
|
||||
await asyncio.Future()
|
||||
if submission == "UNSUBMITTED" and not hidden:
|
||||
if offset == 0:
|
||||
return {"results": [alpha("a1"), alpha("a2")], "count": 4}
|
||||
if self.fail_page:
|
||||
raise WqError("模拟第二页网络故障", "network_error")
|
||||
return {"results": [alpha("a2", **{"is": {"sharpe": 7.0}}), alpha("a3")], "count": 4}
|
||||
if submission == "SUBMITTED" and hidden:
|
||||
return {"results": [alpha("hidden1", hidden=True, stage="OS", status="ACTIVE")], "next": None}
|
||||
return {"results": [], "next": None}
|
||||
|
||||
async def alpha(self, alpha_id):
|
||||
if alpha_id == "bad" and self.fail_id:
|
||||
raise WqError("模拟无权访问", "access_denied")
|
||||
return alpha(alpha_id, **{"is": {"sharpe": 8.0}})
|
||||
|
||||
async def pnl(self, alpha_id):
|
||||
return {
|
||||
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||
"records": [["2025-01-01", 100]],
|
||||
}
|
||||
|
||||
def disconnect(self):
|
||||
self.verification_url = None
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
async def ready_runner(app):
|
||||
async with app.state.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.email = "test@example.com"
|
||||
account.password_encrypted = cipher(app.state.settings).encrypt(b"test-password").decode()
|
||||
account.connection_status = "connected"
|
||||
await db.commit()
|
||||
return Runner(app.state.sessions, app.state.settings, FakePlatform())
|
||||
|
||||
|
||||
async def job_for(runner, kind="full_sync", ids=None):
|
||||
async with runner.sessions() as db:
|
||||
return (await create_job(db, kind, {"alpha_ids": ids or []})).id
|
||||
|
||||
|
||||
async def result(runner, job_id):
|
||||
async with runner.sessions() as db:
|
||||
return await db.get(Job, job_id)
|
||||
|
||||
|
||||
async def test_pages_commit_resume_dedupe_and_preserve_research(app):
|
||||
runner = await ready_runner(app)
|
||||
job_id = await job_for(runner)
|
||||
await runner.execute(job_id)
|
||||
failed = await result(runner, job_id)
|
||||
assert failed.status == "failed" and failed.processed == 2
|
||||
assert failed.checkpoint == {"partition": 0, "offset": 2}
|
||||
async with runner.sessions() as db:
|
||||
research = await db.get(Research, "a2")
|
||||
research.note, research.tags, research.state = "keep me", ["keep"], "candidate"
|
||||
await db.commit()
|
||||
runner.client.fail_page = False
|
||||
runner.client.page_calls.clear()
|
||||
await runner.execute(job_id)
|
||||
finished = await result(runner, job_id)
|
||||
assert finished.status == "completed" and finished.processed == 4 and finished.total == 4
|
||||
assert runner.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
|
||||
assert len({call[3] for call in runner.client.page_calls}) == 1
|
||||
async with runner.sessions() as db:
|
||||
assert await db.scalar(select(func.count()).select_from(Alpha)) == 4
|
||||
assert (await db.get(Alpha, "a2")).sharpe == 7.0
|
||||
assert (await db.get(Alpha, "hidden1")).hidden is True
|
||||
research = await db.get(Research, "a2")
|
||||
assert (research.note, research.tags, research.state) == ("keep me", ["keep"], "candidate")
|
||||
assert await db.scalar(select(func.count()).select_from(JobItem)) == 4
|
||||
# A later full scan updates existing IDs, without deleting unseen local records.
|
||||
second = await job_for(runner)
|
||||
await runner.execute(second)
|
||||
assert (await result(runner, second)).processed == 4
|
||||
|
||||
|
||||
async def test_partial_ids_retry_only_errors_and_pnl_cache(app):
|
||||
runner = await ready_runner(app)
|
||||
job_id = await job_for(runner, "alpha_refresh", ["good", "bad"])
|
||||
await runner.execute(job_id)
|
||||
partial = await result(runner, job_id)
|
||||
assert (partial.status, partial.processed, partial.failed, partial.total) == (
|
||||
"completed_with_errors",
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
)
|
||||
runner.client.fail_id = False
|
||||
await runner.execute(job_id)
|
||||
complete = await result(runner, job_id)
|
||||
assert (complete.status, complete.processed, complete.failed) == ("completed", 2, 0)
|
||||
pnl_job = await job_for(runner, "pnl_refresh", ["good", "missing"])
|
||||
await runner.execute(pnl_job)
|
||||
assert (await result(runner, pnl_job)).failed == 1
|
||||
async with runner.sessions() as db:
|
||||
pnl = await db.get(Pnl, "good")
|
||||
assert pnl.points == [{"date": "2025-01-01", "value": 100.0}]
|
||||
|
||||
|
||||
async def test_human_verification_waits_then_resumes_pending_jobs(app):
|
||||
runner = await ready_runner(app)
|
||||
runner.client.require_verification = True
|
||||
job_id = await job_for(runner, "alpha_refresh", ["good"])
|
||||
await runner.execute(job_id)
|
||||
assert (await result(runner, job_id)).status == "waiting_auth"
|
||||
async with runner.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
assert account.connection_status == "verification_required" and account.verification_url
|
||||
verification = await job_for(runner, "verify")
|
||||
await runner.execute(verification)
|
||||
assert (await result(runner, verification)).status == "completed"
|
||||
assert (await result(runner, job_id)).status == "queued"
|
||||
async with runner.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
assert (
|
||||
account.wq_user_id == "user1"
|
||||
and "password" not in account.profile
|
||||
and "unknown" not in account.profile
|
||||
)
|
||||
await runner.execute(job_id)
|
||||
assert (await result(runner, job_id)).status == "completed"
|
||||
|
||||
|
||||
async def wait_status(runner, job_id, expected):
|
||||
async with asyncio.timeout(5):
|
||||
while (await result(runner, job_id)).status not in expected:
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
|
||||
async def test_user_cancel_interrupts_request_and_disconnect_pauses(app):
|
||||
runner = await ready_runner(app)
|
||||
runner.client.block = asyncio.Event()
|
||||
job_id = await job_for(runner)
|
||||
await runner.start()
|
||||
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||
async with runner.sessions() as db:
|
||||
job = await db.get(Job, job_id)
|
||||
job.cancel_requested = True
|
||||
await db.commit()
|
||||
await runner.cancel(job_id)
|
||||
await wait_status(runner, job_id, {"cancelled"})
|
||||
runner.client.block.clear()
|
||||
pending = await job_for(runner)
|
||||
runner.wake.set()
|
||||
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||
await runner.disconnect()
|
||||
assert (await result(runner, pending)).status == "waiting_connection"
|
||||
async with runner.sessions() as db:
|
||||
assert (await db.get(Account, 1)).connection_status == "disconnected"
|
||||
await runner.stop()
|
||||
|
||||
|
||||
async def test_restart_recovers_running_job_and_keeps_checkpoint(app):
|
||||
runner = await ready_runner(app)
|
||||
runner.client.block = asyncio.Event()
|
||||
job_id = await job_for(runner)
|
||||
await runner.start()
|
||||
await asyncio.wait_for(runner.client.block.wait(), 3)
|
||||
await runner.stop()
|
||||
assert (await result(runner, job_id)).status == "queued"
|
||||
# Simulate abrupt process loss after a committed page, before the running flag was cleared.
|
||||
async with runner.sessions() as db:
|
||||
job = await db.get(Job, job_id)
|
||||
job.status, job.checkpoint = "running", {"partition": 0, "offset": 2}
|
||||
await db.commit()
|
||||
resumed = Runner(runner.sessions, runner.settings, FakePlatform())
|
||||
resumed.client.fail_page = False
|
||||
await resumed.start()
|
||||
await wait_status(resumed, job_id, {"completed", "failed"})
|
||||
await resumed.stop()
|
||||
assert (await result(resumed, job_id)).status == "completed"
|
||||
assert resumed.client.page_calls[0][:3] == ("UNSUBMITTED", False, 2)
|
||||
|
||||
|
||||
async def test_identity_change_fails_without_overwriting_profile(app):
|
||||
runner = await ready_runner(app)
|
||||
async with runner.sessions() as db:
|
||||
account = await db.get(Account, 1)
|
||||
account.wq_user_id = "original-user"
|
||||
await db.commit()
|
||||
job_id = await job_for(runner, "connect")
|
||||
await runner.execute(job_id)
|
||||
assert (await result(runner, job_id)).status == "waiting_connection"
|
||||
async with runner.sessions() as db:
|
||||
assert (await db.get(Account, 1)).wq_user_id == "original-user"
|
||||
|
||||
|
||||
async def test_scheduler_recovers_after_database_outage(app):
|
||||
runner = await ready_runner(app)
|
||||
job_id = await job_for(runner, "alpha_refresh", ["good"])
|
||||
original = runner.run_next
|
||||
attempts = 0
|
||||
|
||||
async def failing_once():
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
async with runner.sessions() as db:
|
||||
job = await db.get(Job, job_id)
|
||||
job.status = "running"
|
||||
await db.commit()
|
||||
raise OperationalError("", {}, Exception("simulated database restart"))
|
||||
await original()
|
||||
|
||||
runner.run_next = failing_once
|
||||
await runner.start()
|
||||
await wait_status(runner, job_id, {"completed", "failed"})
|
||||
await runner.stop()
|
||||
assert attempts >= 2 and (await result(runner, job_id)).status == "completed"
|
||||
@@ -0,0 +1,186 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import format_datetime
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.alphas import pnl_points, sanitize
|
||||
from app.worldquant import VerificationRequired, WqClient, WqError
|
||||
|
||||
|
||||
async def test_cookie_auth_expiry_and_read_only_boundary(settings):
|
||||
calls, auth_count = [], 0
|
||||
|
||||
def handler(request):
|
||||
nonlocal auth_count
|
||||
calls.append((request.method, request.url.path))
|
||||
if request.url.path == "/authentication":
|
||||
auth_count += 1
|
||||
return httpx.Response(201, headers={"Set-Cookie": f"t=session{auth_count}; Path=/"}, json={})
|
||||
assert "Authorization" not in request.headers
|
||||
if request.headers.get("Cookie") == "t=session1":
|
||||
return httpx.Response(401)
|
||||
return httpx.Response(200, json={"id": "user1"})
|
||||
|
||||
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||
await client.authenticate("person@example.com", "secret")
|
||||
assert await client.profile() == {"id": "user1"}
|
||||
assert auth_count == 2
|
||||
await asyncio.gather(*(client.authenticate("person@example.com", "secret") for _ in range(5)))
|
||||
assert auth_count == 2
|
||||
assert all(method == "GET" or path == "/authentication" for method, path in calls)
|
||||
client.disconnect()
|
||||
assert client.credentials is None and not list(client.client.cookies.items())
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_persona_verification_uses_same_cookie_and_safe_location(settings):
|
||||
complete = False
|
||||
|
||||
def handler(request):
|
||||
if request.url.path == "/authentication":
|
||||
return httpx.Response(
|
||||
401,
|
||||
headers={
|
||||
"WWW-Authenticate": "persona",
|
||||
"Location": "/authentication/persona/challenge",
|
||||
"Set-Cookie": "challenge=abc; Path=/",
|
||||
},
|
||||
)
|
||||
assert request.headers.get("Cookie") == "challenge=abc"
|
||||
return httpx.Response(201 if complete else 202, json={})
|
||||
|
||||
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||
with pytest.raises(VerificationRequired) as error:
|
||||
await client.authenticate("test@example.com", "secret")
|
||||
assert error.value.url.endswith("/authentication/persona/challenge")
|
||||
with pytest.raises(VerificationRequired):
|
||||
await client.verify()
|
||||
complete = True
|
||||
await client.verify()
|
||||
assert client.authenticated and client.verification_url is None
|
||||
await client.close()
|
||||
unsafe = WqClient(
|
||||
settings,
|
||||
transport=httpx.MockTransport(
|
||||
lambda r: httpx.Response(
|
||||
401, headers={"WWW-Authenticate": "persona", "Location": "https://evil.example/secret"}
|
||||
)
|
||||
),
|
||||
)
|
||||
with pytest.raises(WqError) as error:
|
||||
await unsafe.authenticate("test@example.com", "secret")
|
||||
assert error.value.code == "invalid_verification"
|
||||
await unsafe.close()
|
||||
|
||||
|
||||
async def test_retry_after_network_budget_pending_and_permissions(settings):
|
||||
delays, requests = [], 0
|
||||
|
||||
async def sleep(delay):
|
||||
delays.append(delay)
|
||||
|
||||
def handler(request):
|
||||
nonlocal requests
|
||||
requests += 1
|
||||
if requests == 1:
|
||||
return httpx.Response(429, headers={"Retry-After": "120"})
|
||||
if requests == 2:
|
||||
raise httpx.ConnectError("contains private body", request=request)
|
||||
return httpx.Response(200, json={"id": "ready"})
|
||||
|
||||
client = WqClient(settings, transport=httpx.MockTransport(handler), sleep=sleep)
|
||||
client.credentials, client.authenticated = ("test@example.com", "secret"), True
|
||||
assert await client.profile() == {"id": "ready"}
|
||||
assert delays[0] == 120 and len(delays) == 2
|
||||
date = format_datetime(datetime.now(timezone.utc) + timedelta(seconds=60), usegmt=True)
|
||||
assert 58 < client.retry_delay(date, 0) <= 60
|
||||
await client.close()
|
||||
for status, code in [(403, "access_denied"), (404, "not_found"), (503, "retry_exhausted")]:
|
||||
c = WqClient(
|
||||
settings,
|
||||
transport=httpx.MockTransport(lambda r: httpx.Response(status, text="private data")),
|
||||
sleep=sleep,
|
||||
)
|
||||
c.credentials, c.authenticated = ("test@example.com", "secret"), True
|
||||
with pytest.raises(WqError) as error:
|
||||
await c.profile()
|
||||
assert error.value.code == code and "private" not in str(error.value)
|
||||
await c.close()
|
||||
responses = iter(
|
||||
[httpx.Response(202, headers={"Retry-After": "1"}), httpx.Response(200, json={"records": []})]
|
||||
)
|
||||
c = WqClient(settings, transport=httpx.MockTransport(lambda r: next(responses)), sleep=sleep)
|
||||
c.credentials, c.authenticated = ("test@example.com", "secret"), True
|
||||
assert await c.pnl("abc") == {"records": []}
|
||||
await c.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
(
|
||||
{
|
||||
"schema": {"properties": [{"name": "date"}, {"name": "pnl"}]},
|
||||
"records": [["2025-01-01", 10], ["2025-01-02", None]],
|
||||
},
|
||||
[{"date": "2025-01-01", "value": 10.0}, {"date": "2025-01-02", "value": None}],
|
||||
),
|
||||
(
|
||||
{"schema": {"properties": [{"name": "pnl"}, {"name": "date"}]}, "records": [[20, "2025-01-01"]]},
|
||||
[{"date": "2025-01-01", "value": 20.0}],
|
||||
),
|
||||
(
|
||||
{"schema": {"properties": {"date": {}, "pnl": {}}}, "records": [["2025-01-01", "NaN"]]},
|
||||
[{"date": "2025-01-01", "value": None}],
|
||||
),
|
||||
(
|
||||
{"records": [{"timestamp": 1735689600000, "value": "5"}]},
|
||||
[{"date": "2025-01-01T00:00:00+00:00", "value": 5.0}],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_pnl_schemas_preserve_null(raw, expected):
|
||||
assert pnl_points(raw) == expected
|
||||
|
||||
|
||||
def test_unknown_pnl_schema_fails_instead_of_fabricating():
|
||||
with pytest.raises(ValueError):
|
||||
pnl_points({"records": [["2025-01-01", 100]]})
|
||||
|
||||
|
||||
def test_sensitive_response_keys_are_removed_recursively():
|
||||
assert sanitize(
|
||||
{
|
||||
"id": "a1",
|
||||
"accessToken": "secret",
|
||||
"nested": [{"refresh_token": "secret", "client-secret": "secret", "value": 1}],
|
||||
"Set-Cookie": "secret",
|
||||
}
|
||||
) == {"id": "a1", "nested": [{"value": 1}]}
|
||||
|
||||
|
||||
async def test_concurrent_expiry_authenticates_once(settings):
|
||||
auth_count = 0
|
||||
first_requests = 0
|
||||
both_expired = asyncio.Event()
|
||||
|
||||
async def handler(request):
|
||||
nonlocal auth_count, first_requests
|
||||
if request.method == "POST":
|
||||
auth_count += 1
|
||||
return httpx.Response(201, json={})
|
||||
if first_requests < 2:
|
||||
first_requests += 1
|
||||
if first_requests == 2:
|
||||
both_expired.set()
|
||||
await both_expired.wait()
|
||||
return httpx.Response(401)
|
||||
return httpx.Response(200, json={"id": "user"})
|
||||
|
||||
client = WqClient(settings, transport=httpx.MockTransport(handler))
|
||||
await client.authenticate("test@example.com", "secret")
|
||||
assert await asyncio.gather(client.profile(), client.profile()) == [{"id": "user"}, {"id": "user"}]
|
||||
assert auth_count == 2 # Initial connection plus one shared reauthentication.
|
||||
await client.close()
|
||||
Reference in New Issue
Block a user