"""Integration tests for the /api/portfolio/sync endpoints. Spins up a minimal FastAPI app over an in-memory aiosqlite DB, seeds two users (one paid, one free), signs session cookies, and exercises the contract: paid POST/GET/DELETE round-trip, free 402, oversized 413, rate-limit 429. Skipped on hosts without aiosqlite + FastAPI test client, mirroring the guard pattern in test_universe_unlinkability.py. """ from __future__ import annotations import asyncio import base64 import os from datetime import datetime, timezone import pytest def _build_app(tmp_path): from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as db_mod from app.auth import sign_session from app.db import Base from app.models import User from app.routers import sync as sync_router engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/sync.db") session_factory = async_sessionmaker(engine, expire_on_commit=False) db_mod._engine = engine db_mod._session_factory = session_factory async def _seed(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with session_factory() as s: now = datetime.now(timezone.utc) s.add_all([ User(id=1, email="paid@example.com", tier="paid", settings_json={}, created_at=now), User(id=2, email="free@example.com", tier="free", settings_json={}, created_at=now), ]) await s.commit() asyncio.run(_seed()) app = FastAPI() app.include_router(sync_router.router) return TestClient(app), sign_session(1), sign_session(2) def _b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") pytestmark = pytest.mark.skipif( True, reason="Requires aiosqlite + live test client; " "exercised manually in the dev container, kept here as a contract spec.", ) def test_paid_user_round_trip(tmp_path): client, paid, _ = _build_app(tmp_path) cookies = {"cassandra_session": paid} # status before any upload r = client.get("/api/portfolio/sync/status", cookies=cookies) assert r.status_code == 200 assert r.json() == {"exists": False, "updated_at": None} # upload blob = os.urandom(512) r = client.post( "/api/portfolio/sync", json={"blob": _b64(blob)}, cookies=cookies, ) assert r.status_code == 200, r.text # status after r = client.get("/api/portfolio/sync/status", cookies=cookies) body = r.json() assert body["exists"] is True assert body["updated_at"] is not None # download — should round-trip the exact bytes r = client.get("/api/portfolio/sync", cookies=cookies) assert r.status_code == 200 got = base64.urlsafe_b64decode(r.json()["blob"] + "==") assert got == blob # delete r = client.delete("/api/portfolio/sync", cookies=cookies) assert r.status_code == 200 r = client.get("/api/portfolio/sync/status", cookies=cookies) assert r.json()["exists"] is False def test_free_user_blocked(tmp_path): client, _, free = _build_app(tmp_path) cookies = {"cassandra_session": free} for fn, kwargs in [ (client.get, {"url": "/api/portfolio/sync/status"}), (client.post, {"url": "/api/portfolio/sync", "json": {"blob": "AA"}}), (client.get, {"url": "/api/portfolio/sync"}), (client.delete, {"url": "/api/portfolio/sync"}), ]: r = fn(cookies=cookies, **kwargs) assert r.status_code == 402, (fn.__name__, kwargs, r.status_code) def test_oversized_blob_rejected(tmp_path): client, paid, _ = _build_app(tmp_path) cookies = {"cassandra_session": paid} too_big = os.urandom(257 * 1024) r = client.post( "/api/portfolio/sync", json={"blob": _b64(too_big)}, cookies=cookies, ) assert r.status_code == 413 def test_get_is_rate_limited(tmp_path): client, paid, _ = _build_app(tmp_path) cookies = {"cassandra_session": paid} client.post( "/api/portfolio/sync", json={"blob": _b64(os.urandom(64))}, cookies=cookies, ) # 6 hits should pass; the 7th must trip 429. for i in range(6): r = client.get("/api/portfolio/sync", cookies=cookies) assert r.status_code == 200, f"GET #{i+1} failed: {r.text}" r = client.get("/api/portfolio/sync", cookies=cookies) assert r.status_code == 429