add tests for router and openapi, still need to add routes for update interest
This commit is contained in:
@@ -52,8 +52,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
|
||||
await asyncio.to_thread(_db.dispose)
|
||||
|
||||
|
||||
origins = [
|
||||
"http://127.0.0.1:18881",
|
||||
]
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(service.AuthMiddleWare)
|
||||
app.add_middleware(
|
||||
service.AuthMiddleWare,
|
||||
)
|
||||
app.state.db_factory = _db
|
||||
|
||||
|
||||
@@ -77,7 +83,7 @@ async def register_user(request: Request, user_in: UserCreate) -> Response:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
logger.exception("Failed to register user: \n")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error") from e
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal Server Error") from e
|
||||
|
||||
|
||||
@app.post(f"{settings.settings.api_base}/login")
|
||||
@@ -110,7 +116,7 @@ async def login(request: Request, user_in: UserLogin) -> Response:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to login user: \n")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error") from e
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal Server Error") from e
|
||||
else:
|
||||
return response
|
||||
|
||||
|
||||
554
backend/openapi.yaml
Normal file
554
backend/openapi.yaml
Normal file
@@ -0,0 +1,554 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: Trading Journal API
|
||||
version: "1.0.0"
|
||||
description: OpenAPI description generated from [`app.py`](app.py) and DTOs in [`trading_journal/dto.py`](trading_journal/dto.py).
|
||||
servers:
|
||||
- url: "http://127.0.0.1:18881{basePath}"
|
||||
variables:
|
||||
basePath:
|
||||
default: "/api/v1"
|
||||
description: "API base path (matches settings.settings.api_base)"
|
||||
components:
|
||||
securitySchemes:
|
||||
session_cookie:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: session_token
|
||||
schemas:
|
||||
UserCreate:
|
||||
$ref: "#/components/schemas/UserCreate_impl"
|
||||
UserCreate_impl:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
is_active:
|
||||
type: boolean
|
||||
default: true
|
||||
password:
|
||||
type: string
|
||||
UserLogin:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
UserRead:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
is_active:
|
||||
type: boolean
|
||||
SessionsBase:
|
||||
type: object
|
||||
required:
|
||||
- user_id
|
||||
properties:
|
||||
user_id:
|
||||
type: integer
|
||||
SessionsCreate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/SessionsBase"
|
||||
- type: object
|
||||
required:
|
||||
- expires_at
|
||||
properties:
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
ExchangesBase:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
nullable: true
|
||||
ExchangesRead:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/ExchangesBase"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
CycleBase:
|
||||
type: object
|
||||
properties:
|
||||
friendly_name:
|
||||
type: string
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
end_date:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
funding_source:
|
||||
type: string
|
||||
nullable: true
|
||||
capital_exposure_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
loan_amount_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
loan_interest_rate_tenth_bps:
|
||||
type: integer
|
||||
nullable: true
|
||||
trades:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/TradeRead"
|
||||
nullable: true
|
||||
exchange:
|
||||
$ref: "#/components/schemas/ExchangesRead"
|
||||
nullable: true
|
||||
CycleCreate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CycleBase"
|
||||
- type: object
|
||||
required:
|
||||
- user_id
|
||||
- symbol
|
||||
- exchange_id
|
||||
- underlying_currency
|
||||
- start_date
|
||||
properties:
|
||||
user_id:
|
||||
type: integer
|
||||
symbol:
|
||||
type: string
|
||||
exchange_id:
|
||||
type: integer
|
||||
underlying_currency:
|
||||
type: string
|
||||
start_date:
|
||||
type: string
|
||||
format: date
|
||||
CycleUpdate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CycleBase"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
CycleRead:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/CycleCreate"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
TradeBase:
|
||||
type: object
|
||||
required:
|
||||
- symbol
|
||||
- underlying_currency
|
||||
- trade_type
|
||||
- trade_strategy
|
||||
- trade_date
|
||||
- quantity
|
||||
- price_cents
|
||||
- commission_cents
|
||||
properties:
|
||||
friendly_name:
|
||||
type: string
|
||||
nullable: true
|
||||
symbol:
|
||||
type: string
|
||||
exchange_id:
|
||||
type: integer
|
||||
underlying_currency:
|
||||
type: string
|
||||
trade_type:
|
||||
type: string
|
||||
trade_strategy:
|
||||
type: string
|
||||
trade_date:
|
||||
type: string
|
||||
format: date
|
||||
quantity:
|
||||
type: integer
|
||||
price_cents:
|
||||
type: integer
|
||||
commission_cents:
|
||||
type: integer
|
||||
notes:
|
||||
type: string
|
||||
nullable: true
|
||||
cycle_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
TradeCreate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TradeBase"
|
||||
- type: object
|
||||
properties:
|
||||
user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
trade_time_utc:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
gross_cash_flow_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
net_cash_flow_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
quantity_multiplier:
|
||||
type: integer
|
||||
default: 1
|
||||
expiry_date:
|
||||
type: string
|
||||
format: date
|
||||
nullable: true
|
||||
strike_price_cents:
|
||||
type: integer
|
||||
nullable: true
|
||||
is_invalidated:
|
||||
type: boolean
|
||||
default: false
|
||||
invalidated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
replaced_by_trade_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
TradeNoteUpdate:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
notes:
|
||||
type: string
|
||||
nullable: true
|
||||
TradeFriendlyNameUpdate:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- friendly_name
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
friendly_name:
|
||||
type: string
|
||||
TradeRead:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TradeCreate"
|
||||
- type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
paths:
|
||||
/status:
|
||||
get:
|
||||
summary: "Get API status"
|
||||
security: [] # no auth required
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
/register:
|
||||
post:
|
||||
summary: "Register user"
|
||||
security: [] # no auth required
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserRead"
|
||||
"400":
|
||||
description: Bad Request (user exists)
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
/login:
|
||||
post:
|
||||
summary: "Login"
|
||||
security: [] # no auth required
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserLogin"
|
||||
responses:
|
||||
"200":
|
||||
description: OK (sets session cookie)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionsBase"
|
||||
headers:
|
||||
Set-Cookie:
|
||||
description: session cookie
|
||||
schema:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
/exchanges:
|
||||
post:
|
||||
summary: "Create exchange"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExchangesBase"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExchangesRead"
|
||||
"400":
|
||||
description: Bad Request
|
||||
"401":
|
||||
description: Unauthorized
|
||||
get:
|
||||
summary: "List user exchanges"
|
||||
security:
|
||||
- session_cookie: []
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ExchangesRead"
|
||||
"401":
|
||||
description: Unauthorized
|
||||
/exchanges/{exchange_id}:
|
||||
patch:
|
||||
summary: "Update exchange"
|
||||
security:
|
||||
- session_cookie: []
|
||||
parameters:
|
||||
- name: exchange_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExchangesBase"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExchangesRead"
|
||||
"404":
|
||||
description: Not found
|
||||
"400":
|
||||
description: Bad request
|
||||
/cycles:
|
||||
post:
|
||||
summary: "Create cycle (currently returns 405 in code)"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CycleBase"
|
||||
responses:
|
||||
"405":
|
||||
description: Method not allowed (app currently returns 405)
|
||||
patch:
|
||||
summary: "Update cycle"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CycleUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CycleRead"
|
||||
"400":
|
||||
description: Invalid data
|
||||
"404":
|
||||
description: Not found
|
||||
/cycles/{cycle_id}:
|
||||
get:
|
||||
summary: "Get cycle by id"
|
||||
security:
|
||||
- session_cookie: []
|
||||
parameters:
|
||||
- name: cycle_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CycleRead"
|
||||
"404":
|
||||
description: Not found
|
||||
/cycles/user/{user_id}:
|
||||
get:
|
||||
summary: "Get cycles by user id"
|
||||
security:
|
||||
- session_cookie: []
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CycleRead"
|
||||
/trades:
|
||||
post:
|
||||
summary: "Create trade"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeRead"
|
||||
"400":
|
||||
description: Invalid trade data
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
/trades/{trade_id}:
|
||||
get:
|
||||
summary: "Get trade by id"
|
||||
security:
|
||||
- session_cookie: []
|
||||
parameters:
|
||||
- name: trade_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeRead"
|
||||
"404":
|
||||
description: Not found
|
||||
/trades/friendlyname:
|
||||
patch:
|
||||
summary: "Update trade friendly name"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeFriendlyNameUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeRead"
|
||||
"404":
|
||||
description: Not found
|
||||
/trades/notes:
|
||||
patch:
|
||||
summary: "Update trade notes"
|
||||
security:
|
||||
- session_cookie: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeNoteUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TradeRead"
|
||||
"404":
|
||||
description: Not found
|
||||
@@ -1,19 +1,405 @@
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import settings
|
||||
from app import app
|
||||
import trading_journal.service as svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
def client_factory(monkeypatch: pytest.MonkeyPatch) -> Callable[..., TestClient]:
|
||||
class NoAuth:
|
||||
def __init__(self, app: FastAPI, **opts) -> None: # noqa: ANN003, ARG002
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None: # noqa: ANN001
|
||||
state = scope.get("state")
|
||||
if state is None:
|
||||
scope["state"] = SimpleNamespace()
|
||||
scope["state"]["user_id"] = 1
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
class DeclineAuth:
|
||||
def __init__(self, app: FastAPI, **opts) -> None: # noqa: ANN003, ARG002
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None: # noqa: ANN001
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
# allow public/exempt paths through
|
||||
if getattr(svc, "EXCEPT_PATHS", []) and path in svc.EXCEPT_PATHS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
# immediately respond 401 for protected paths
|
||||
resp = JSONResponse({"detail": "Unauthorized"}, status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
await resp(scope, receive, send)
|
||||
|
||||
def _factory(*, decline_auth: bool = False, **mocks: dict) -> TestClient:
|
||||
defaults = {
|
||||
"register_user_service": MagicMock(return_value=SimpleNamespace(model_dump=lambda: {"id": 1, "username": "mock"})),
|
||||
"authenticate_user_service": MagicMock(
|
||||
return_value=(SimpleNamespace(user_id=1, expires_at=(datetime.now(timezone.utc) + timedelta(hours=1))), "token"),
|
||||
),
|
||||
"create_exchange_service": MagicMock(
|
||||
return_value=SimpleNamespace(model_dump=lambda: {"name": "Binance", "notes": "some note", "user_id": 1}),
|
||||
),
|
||||
"get_exchanges_by_user_service": MagicMock(return_value=[]),
|
||||
}
|
||||
|
||||
if decline_auth:
|
||||
monkeypatch.setattr(svc, "AuthMiddleWare", DeclineAuth)
|
||||
else:
|
||||
monkeypatch.setattr(svc, "AuthMiddleWare", NoAuth)
|
||||
merged = {**defaults, **mocks}
|
||||
for name, mock in merged.items():
|
||||
monkeypatch.setattr(svc, name, mock)
|
||||
import sys
|
||||
|
||||
if "app" in sys.modules:
|
||||
del sys.modules["app"]
|
||||
from importlib import import_module
|
||||
|
||||
app = import_module("app").app # re-import app module
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
def test_get_status(client: TestClient) -> None:
|
||||
response = client.get(f"{settings.settings.api_base}/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
def test_get_status(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory()
|
||||
with client as c:
|
||||
response = c.get(f"{settings.settings.api_base}/status")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_register_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory() # use defaults
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/register", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_register_user_already_exists(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(register_user_service=MagicMock(side_effect=svc.UserAlreadyExistsError("username already exists")))
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/register", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert r.json() == {"detail": "username already exists"}
|
||||
|
||||
|
||||
def test_register_user_internal_server_error(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(register_user_service=MagicMock(side_effect=Exception("db is down")))
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/register", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert r.json() == {"detail": "Internal Server Error"}
|
||||
|
||||
|
||||
def test_login_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory() # use defaults
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user_id": 1}
|
||||
assert r.cookies.get("session_token") == "token"
|
||||
|
||||
|
||||
def test_login_failed_auth(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(authenticate_user_service=MagicMock(return_value=None))
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert r.json() == {"detail": "Invalid username or password, or user doesn't exist"}
|
||||
|
||||
|
||||
def test_login_internal_server_error(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(authenticate_user_service=MagicMock(side_effect=Exception("db is down")))
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/login", json={"username": "a", "password": "b"})
|
||||
assert r.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert r.json() == {"detail": "Internal Server Error"}
|
||||
|
||||
|
||||
def test_create_exchange_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory()
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/exchanges", json={"name": "Binance"})
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"user_id": 1, "name": "Binance", "notes": "some note"}
|
||||
|
||||
|
||||
def test_create_exchange_already_exists(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(create_exchange_service=MagicMock(side_effect=svc.ExchangeAlreadyExistsError("exchange already exists")))
|
||||
with client as c:
|
||||
r = c.post(f"{settings.settings.api_base}/exchanges", json={"name": "Binance"})
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert r.json() == {"detail": "exchange already exists"}
|
||||
|
||||
|
||||
def test_get_exchanges_unauthenticated(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(decline_auth=True)
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/exchanges")
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert r.json() == {"detail": "Unauthorized"}
|
||||
|
||||
|
||||
def test_get_exchanges_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory()
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/exchanges")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_update_exchanges_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
update_exchanges_service=MagicMock(
|
||||
return_value=SimpleNamespace(model_dump=lambda: {"user_id": 1, "name": "BinanceUS", "notes": "updated note"}),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/exchanges/1", json={"name": "BinanceUS", "notes": "updated note"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user_id": 1, "name": "BinanceUS", "notes": "updated note"}
|
||||
|
||||
|
||||
def test_update_exchanges_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(update_exchanges_service=MagicMock(side_effect=svc.ExchangeNotFoundError("exchange not found")))
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/exchanges/999", json={"name": "NonExistent", "notes": "no note"})
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "exchange not found"}
|
||||
|
||||
|
||||
def test_get_cycles_by_id_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
get_cycle_by_id_service=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
friendly_name="Cycle 1",
|
||||
status="active",
|
||||
id=1,
|
||||
),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/cycles/1")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"id": 1, "friendly_name": "Cycle 1", "status": "active"}
|
||||
|
||||
|
||||
def test_get_cycles_by_id_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(get_cycle_by_id_service=MagicMock(side_effect=svc.CycleNotFoundError("cycle not found")))
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/cycles/999")
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "cycle not found"}
|
||||
|
||||
|
||||
def test_get_cycles_by_user_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
get_cycles_by_user_service=MagicMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
friendly_name="Cycle 1",
|
||||
status="active",
|
||||
id=1,
|
||||
),
|
||||
SimpleNamespace(
|
||||
friendly_name="Cycle 2",
|
||||
status="completed",
|
||||
id=2,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/cycles/user/1")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == [
|
||||
{"id": 1, "friendly_name": "Cycle 1", "status": "active"},
|
||||
{"id": 2, "friendly_name": "Cycle 2", "status": "completed"},
|
||||
]
|
||||
|
||||
|
||||
def test_update_cycles_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
update_cycle_service=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
friendly_name="Updated Cycle",
|
||||
status="completed",
|
||||
id=1,
|
||||
),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/cycles", json={"friendly_name": "Updated Cycle", "status": "completed", "id": 1})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"id": 1, "friendly_name": "Updated Cycle", "status": "completed"}
|
||||
|
||||
|
||||
def test_update_cycles_invalid_cycle_data(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
update_cycle_service=MagicMock(side_effect=svc.InvalidCycleDataError("invalid cycle data")),
|
||||
)
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/cycles", json={"friendly_name": "", "status": "unknown", "id": 1})
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert r.json() == {"detail": "invalid cycle data"}
|
||||
|
||||
|
||||
def test_update_cycles_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(update_cycle_service=MagicMock(side_effect=svc.CycleNotFoundError("cycle not found")))
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/cycles", json={"friendly_name": "NonExistent", "status": "active", "id": 999})
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "cycle not found"}
|
||||
|
||||
|
||||
def test_create_trade_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
create_trade_service=MagicMock(
|
||||
return_value=SimpleNamespace(),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.post(
|
||||
f"{settings.settings.api_base}/trades",
|
||||
json={
|
||||
"cycle_id": 1,
|
||||
"exchange_id": 1,
|
||||
"symbol": "BTCUSD",
|
||||
"underlying_currency": "USD",
|
||||
"trade_type": "LONG_SPOT",
|
||||
"trade_strategy": "FX",
|
||||
"quantity": 1,
|
||||
"price_cents": 15,
|
||||
"commission_cents": 100,
|
||||
"trade_date": "2025-10-01",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_create_trade_invalid_trade_data(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
create_trade_service=MagicMock(side_effect=svc.InvalidTradeDataError("invalid trade data")),
|
||||
)
|
||||
with client as c:
|
||||
r = c.post(
|
||||
f"{settings.settings.api_base}/trades",
|
||||
json={
|
||||
"cycle_id": 1,
|
||||
"exchange_id": 1,
|
||||
"symbol": "BTCUSD",
|
||||
"underlying_currency": "USD",
|
||||
"trade_type": "LONG_SPOT",
|
||||
"trade_strategy": "FX",
|
||||
"quantity": 1,
|
||||
"price_cents": 15,
|
||||
"commission_cents": 100,
|
||||
"trade_date": "2025-10-01",
|
||||
},
|
||||
)
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert r.json() == {"detail": "invalid trade data"}
|
||||
|
||||
|
||||
def test_get_trade_by_id_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
get_trade_by_id_service=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=1,
|
||||
cycle_id=1,
|
||||
exchange_id=1,
|
||||
symbol="BTCUSD",
|
||||
underlying_currency="USD",
|
||||
trade_type="LONG_SPOT",
|
||||
trade_strategy="FX",
|
||||
quantity=1,
|
||||
price_cents=1500,
|
||||
commission_cents=100,
|
||||
trade_date=datetime(2025, 10, 1, tzinfo=timezone.utc),
|
||||
),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/trades/1")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"id": 1,
|
||||
"cycle_id": 1,
|
||||
"exchange_id": 1,
|
||||
"symbol": "BTCUSD",
|
||||
"underlying_currency": "USD",
|
||||
"trade_type": "LONG_SPOT",
|
||||
"trade_strategy": "FX",
|
||||
"quantity": 1,
|
||||
"price_cents": 1500,
|
||||
"commission_cents": 100,
|
||||
"trade_date": "2025-10-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
def test_get_trade_by_id_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(get_trade_by_id_service=MagicMock(side_effect=svc.TradeNotFoundError("trade not found")))
|
||||
with client as c:
|
||||
r = c.get(f"{settings.settings.api_base}/trades/999")
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "trade not found"}
|
||||
|
||||
|
||||
def test_update_trade_friendly_name_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
update_trade_friendly_name_service=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=1,
|
||||
friendly_name="Updated Trade Name",
|
||||
),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/trades/friendlyname", json={"id": 1, "friendly_name": "Updated Trade Name"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"id": 1, "friendly_name": "Updated Trade Name"}
|
||||
|
||||
|
||||
def test_update_trade_friendly_name_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(update_trade_friendly_name_service=MagicMock(side_effect=svc.TradeNotFoundError("trade not found")))
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/trades/friendlyname", json={"id": 999, "friendly_name": "NonExistent Trade"})
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "trade not found"}
|
||||
|
||||
|
||||
def test_update_trade_note_success(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(
|
||||
update_trade_note_service=MagicMock(
|
||||
return_value=SimpleNamespace(
|
||||
id=1,
|
||||
note="Updated trade note",
|
||||
),
|
||||
),
|
||||
)
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/trades/notes", json={"id": 1, "note": "Updated trade note"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"id": 1, "note": "Updated trade note"}
|
||||
|
||||
|
||||
def test_update_trade_note_not_found(client_factory: Callable[..., TestClient]) -> None:
|
||||
client = client_factory(update_trade_note_service=MagicMock(side_effect=svc.TradeNotFoundError("trade not found")))
|
||||
with client as c:
|
||||
r = c.patch(f"{settings.settings.api_base}/trades/notes", json={"id": 999, "note": "NonExistent Trade Note"})
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert r.json() == {"detail": "trade not found"}
|
||||
|
||||
Reference in New Issue
Block a user