From 94fb4705ff5a25f7015ed0b76074fefc43cac29e Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Wed, 1 Oct 2025 15:53:48 +0200 Subject: [PATCH] add tests for router and openapi, still need to add routes for update interest --- backend/app.py | 12 +- backend/openapi.yaml | 554 ++++++++++++++++++++++++++++++++++++++ backend/tests/test_app.py | 404 ++++++++++++++++++++++++++- 3 files changed, 958 insertions(+), 12 deletions(-) create mode 100644 backend/openapi.yaml diff --git a/backend/app.py b/backend/app.py index e0e2799..2716a82 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/openapi.yaml b/backend/openapi.yaml new file mode 100644 index 0000000..f907c4e --- /dev/null +++ b/backend/openapi.yaml @@ -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 diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 78cf8ad..cbc8553 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -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"}