diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..d7a9ee7f --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: test + +PYTHON ?= python3 + +test: + $(PYTHON) -m pytest --cov=backend --cov-report=term diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..645f39cc --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Python test helpers for the documented backend API contract.""" diff --git a/backend/api_contract.py b/backend/api_contract.py new file mode 100644 index 00000000..13080fa1 --- /dev/null +++ b/backend/api_contract.py @@ -0,0 +1,297 @@ +"""Utilities for pytest-based validation of the documented backend API. + +The runtime backend in this repository is Rust, while the public HTTP API +contract is documented in `docs/openapi/v3.yaml`. These helpers load that +contract, expose its operations in a test-friendly shape, and provide an +offline mock client for success, error, and edge-case tests. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +import yaml + +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} +DEFAULT_SPEC_PATH = Path(__file__).resolve().parents[1] / "docs" / "openapi" / "v3.yaml" + + +@dataclass(frozen=True) +class ApiOperation: + """A public API operation extracted from the OpenAPI path map.""" + + method: str + path: str + operation_id: str + responses: tuple[int, ...] + request_body_required: bool + required_fields: tuple[str, ...] + parameters: tuple[str, ...] + + @property + def success_statuses(self) -> tuple[int, ...]: + """Return documented 2xx response codes.""" + + return tuple(code for code in self.responses if 200 <= code < 300) + + @property + def error_statuses(self) -> tuple[int, ...]: + """Return documented 4xx/5xx response codes.""" + + return tuple(code for code in self.responses if code >= 400) + + +@dataclass(frozen=True) +class MockResponse: + """Small response object used by offline API contract tests.""" + + status_code: int + body: Mapping[str, Any] + + +def load_openapi_spec(path: Path = DEFAULT_SPEC_PATH) -> Mapping[str, Any]: + """Load the repository OpenAPI document from disk without network access.""" + + with path.open(encoding="utf-8") as handle: + spec = yaml.safe_load(handle) + if not isinstance(spec, Mapping): + raise ValueError(f"OpenAPI spec at {path} did not parse to a mapping") + return spec + + +def iter_operations(spec: Mapping[str, Any]) -> tuple[ApiOperation, ...]: + """Return every public HTTP operation declared in the OpenAPI spec.""" + + paths = spec.get("paths", {}) + if not isinstance(paths, Mapping): + raise ValueError("OpenAPI spec is missing a paths mapping") + + operations: list[ApiOperation] = [] + for path, path_item in sorted(paths.items()): + if not isinstance(path_item, Mapping): + continue + for method, operation in sorted(path_item.items()): + if method not in HTTP_METHODS or not isinstance(operation, Mapping): + continue + operations.append(_build_operation(spec, path, method, operation)) + return tuple(operations) + + +def operation_key(method: str, path: str) -> tuple[str, str]: + """Normalize a method/path pair for dictionary lookups.""" + + return method.lower(), path + + +def build_valid_payload(spec: Mapping[str, Any], operation: ApiOperation) -> dict[str, Any]: + """Build a minimal payload for an operation with a required request body.""" + + schema = _request_schema(spec, operation.path, operation.method) + required_fields = operation.required_fields + if not required_fields: + return {"request_id": "test-request"} + + properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {} + payload: dict[str, Any] = {} + for field in required_fields: + field_schema = properties.get(field, {}) if isinstance(properties, Mapping) else {} + payload[field] = _sample_value(field, field_schema) + return payload + + +class MockBackendApiClient: + """Offline client that returns responses from the OpenAPI contract shape.""" + + def __init__(self, spec: Mapping[str, Any], operations: Iterable[ApiOperation]): + self.spec = spec + self.operations = {operation_key(op.method, op.path): op for op in operations} + + def request( + self, + method: str, + path: str, + *, + payload: Mapping[str, Any] | None = None, + token: str | None = None, + ) -> MockResponse: + """Return a deterministic mock response for a documented operation.""" + + operation = self.operations.get(operation_key(method, path)) + if operation is None: + return MockResponse(404, {"code": 4004, "message": "Resource not found"}) + + if payload and payload.get("__force_internal_error__"): + return MockResponse(500, {"code": 5001, "message": "Internal server error"}) + + if self._requires_authorization(operation) and not token: + status_code = _first_available(operation.responses, (401, 403), default=400) + return MockResponse(status_code, {"code": 4002, "message": "Authentication required"}) + + if operation.request_body_required: + if payload is None: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": "Invalid request parameters"}) + + # Check for missing required fields + missing_fields = [field for field in operation.required_fields if field not in payload] + if missing_fields: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": f"Missing required parameters: {', '.join(missing_fields)}"}) + + # Check for malformed fields (incorrect types) + schema = _request_schema(self.spec, operation.path, operation.method) + properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {} + for field, val in payload.items(): + if field in properties: + field_schema = properties[field] + if isinstance(field_schema, Mapping): + expected_type = field_schema.get("type") + is_malformed = False + if expected_type == "boolean": + if not isinstance(val, bool): + is_malformed = True + elif expected_type == "integer": + if isinstance(val, bool) or not isinstance(val, int): + is_malformed = True + elif expected_type == "number": + if isinstance(val, bool) or not isinstance(val, (int, float)): + is_malformed = True + elif expected_type == "array": + if not isinstance(val, list): + is_malformed = True + elif expected_type == "object": + if not isinstance(val, dict): + is_malformed = True + elif expected_type == "string": + if not isinstance(val, str): + is_malformed = True + + if is_malformed: + status_code = _first_available(operation.responses, (422, 400, 409), default=422) + return MockResponse(status_code, {"code": 4001, "message": f"Malformed field: {field}"}) + + status_code = min(operation.success_statuses or (200,)) + return MockResponse( + status_code, + {"operation_id": operation.operation_id, "path": operation.path, "ok": True}, + ) + + async def request_async( + self, + method: str, + path: str, + *, + payload: Mapping[str, Any] | None = None, + token: str | None = None, + ) -> MockResponse: + """Async wrapper used by pytest-asyncio tests.""" + + return self.request(method, path, payload=payload, token=token) + + @staticmethod + def _requires_authorization(operation: ApiOperation) -> bool: + return not operation.path.startswith("/auth/") + + +def _build_operation( + spec: Mapping[str, Any], + path: str, + method: str, + operation: Mapping[str, Any], +) -> ApiOperation: + responses = tuple(sorted(_response_codes(operation.get("responses", {})))) + request_body = operation.get("requestBody", {}) + request_required = bool(isinstance(request_body, Mapping) and request_body.get("required")) + schema = _request_schema(spec, path, method) + required_fields = tuple(schema.get("required", ())) if isinstance(schema, Mapping) else () + parameters = tuple(_parameter_names(operation.get("parameters", ()))) + operation_id = str(operation.get("operationId") or f"{method}_{path}".replace("/", "_")) + return ApiOperation( + method=method.upper(), + path=path, + operation_id=operation_id, + responses=responses, + request_body_required=request_required, + required_fields=required_fields, + parameters=parameters, + ) + + +def _request_schema(spec: Mapping[str, Any], path: str, method: str) -> Mapping[str, Any]: + operation = spec.get("paths", {}).get(path, {}).get(method.lower(), {}) + request_body = operation.get("requestBody", {}) if isinstance(operation, Mapping) else {} + content = request_body.get("content", {}) if isinstance(request_body, Mapping) else {} + json_content = content.get("application/json", {}) if isinstance(content, Mapping) else {} + schema = json_content.get("schema", {}) if isinstance(json_content, Mapping) else {} + if isinstance(schema, Mapping) and "$ref" in schema: + try: + return _resolve_ref(spec, str(schema["$ref"])) + except KeyError: + return {} + return schema if isinstance(schema, Mapping) else {} + + +def _resolve_ref(spec: Mapping[str, Any], ref: str) -> Mapping[str, Any]: + if not ref.startswith("#/"): + raise ValueError(f"Only local OpenAPI refs are supported: {ref}") + current: Any = spec + for part in ref.removeprefix("#/").split("/"): + if not isinstance(current, Mapping) or part not in current: + raise KeyError(f"OpenAPI ref segment not found: {ref}") + current = current[part] + if not isinstance(current, Mapping): + raise ValueError(f"OpenAPI ref does not resolve to a mapping: {ref}") + return current + + +def _response_codes(responses: Any) -> set[int]: + if not isinstance(responses, Mapping): + return set() + codes: set[int] = set() + for code in responses: + try: + codes.add(int(code)) + except (TypeError, ValueError): + continue + return codes + + +def _parameter_names(parameters: Any) -> list[str]: + if not isinstance(parameters, list): + return [] + names: list[str] = [] + for parameter in parameters: + if isinstance(parameter, Mapping) and "name" in parameter: + names.append(str(parameter["name"])) + return names + + +def _first_available(responses: tuple[int, ...], candidates: tuple[int, ...], *, default: int) -> int: + for candidate in candidates: + if candidate in responses: + return candidate + return default + + +def _sample_value(field: str, schema: Any) -> Any: + if not isinstance(schema, Mapping): + return f"test-{field}" + + field_type = schema.get("type") + if field == "email": + return "user@example.com" + if field == "password": + return "correct-horse-battery-staple" + if field_type == "boolean": + return True + if field_type == "integer": + return 1 + if field_type == "number": + return 1.0 + if field_type == "array": + return [] + if field_type == "object": + return {} + return f"test-{field}" diff --git a/build.py b/build.py index 9b82104b..24bd46ab 100644 --- a/build.py +++ b/build.py @@ -185,7 +185,7 @@ def _normalize_arch(machine: str) -> Optional[str]: def _normalize_os() -> Optional[str]: system = platform.system().lower() - if system == "linux": + if system in {"linux", "android"}: return "linux" if system == "darwin": return "macos" diff --git a/diagnostic/build-00000000.json b/diagnostic/build-00000000.json deleted file mode 100644 index 33e2ca62..00000000 --- a/diagnostic/build-00000000.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "generated_at": "2026-06-16T15:23:47.496569+00:00", - "commit": "00000000", - "diagnostic_logd": "diagnostic/build-00000000.logd", - "diagnostic_logd_error": null, - "chunked": false, - "chunk_size_bytes": null, - "password": "4c7df15ab09fbb066197", - "decrypt_command": "encryptly unpack diagnostic/build-00000000.logd --password 4c7df15ab09fbb066197", - "total_modules": 1, - "passed": 0, - "failed": 1, - "modules": [ - { - "name": "frailbox", - "status": "FAIL", - "elapsed_seconds": 0, - "artifact": null, - "output": "Command not found: [Errno 2] No such file or directory: 'make'" - } - ], - "pr_note": "Include this JSON diagnostic report and diagnostic/build-00000000.logd in your PR. Maintainers may ask you to remove these diagnostic artifacts before merging." -} diff --git a/diagnostic/build-00000000.logd b/diagnostic/build-00000000.logd deleted file mode 100644 index b5a046a2..00000000 --- a/diagnostic/build-00000000.logd +++ /dev/null @@ -1 +0,0 @@ -stub diagnostic logd placeholder diff --git a/diagnostic/build-e84e063f.json b/diagnostic/build-e84e063f.json new file mode 100644 index 00000000..29b164d6 --- /dev/null +++ b/diagnostic/build-e84e063f.json @@ -0,0 +1,86 @@ +{ + "generated_at": "2026-06-21T02:17:27.106652+00:00", + "commit": "e84e063f", + "diagnostic_logd": "diagnostic/build-e84e063f.logd", + "diagnostic_logd_error": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "250c1ab15dd61da344f7", + "decrypt_command": "encryptly unpack diagnostic/build-e84e063f.logd --password 250c1ab15dd61da344f7", + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "FAIL", + "elapsed_seconds": 1.38, + "artifact": null, + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\nsh: 1: tsc: not found" + }, + { + "name": "market", + "status": "PASS", + "elapsed_seconds": 3.997, + "artifact": "/data/data/com.termux/files/home/jackjin-zeroeye/market/market", + "output": "" + }, + { + "name": "frailbox", + "status": "PASS", + "elapsed_seconds": 0.031, + "artifact": "/data/data/com.termux/files/home/jackjin-zeroeye/frailbox/frailbox", + "output": "make: Nothing to be done for 'all'." + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'cmake'" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'javac'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ruby'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-e84e063f.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-e84e063f.logd b/diagnostic/build-e84e063f.logd new file mode 100644 index 00000000..21380fa9 Binary files /dev/null and b/diagnostic/build-e84e063f.logd differ diff --git a/market/orderbook/orderbook_test.go b/market/orderbook/orderbook_test.go new file mode 100644 index 00000000..1321e1e0 --- /dev/null +++ b/market/orderbook/orderbook_test.go @@ -0,0 +1,209 @@ +package orderbook + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/tent-of-trials/market/types" +) + +func TestOrderBook_CancelBidOrder(t *testing.T) { + symbol := types.Symbol("BTCUSD") + config := Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 4, + } + ob := NewOrderBook(symbol, config) + + order := &types.Order{ + ID: "order-1", + Symbol: symbol, + Side: types.Buy, + Type: types.Limit, + Price: decimal.NewFromFloat(50000.0), + RemainingQty: decimal.NewFromFloat(1.5), + } + + _, err := ob.AddOrder(order) + if err != nil { + t.Fatalf("failed to add order: %v", err) + } + + // Verify order exists in the book's internal orders map + ob.mu.RLock() + _, exists := ob.orders["order-1"] + ob.mu.RUnlock() + if !exists { + t.Fatalf("order not registered in internal map") + } + + // Verify bid level is present + bids := ob.GetBids() + if len(bids) != 1 || !bids[0].Price.Equal(order.Price) { + t.Fatalf("expected bid level at %v", order.Price) + } + + // Cancel the order + err = ob.CancelOrder("order-1") + if err != nil { + t.Fatalf("failed to cancel order: %v", err) + } + + // Verify order ID is deleted from internal orders map + ob.mu.RLock() + _, exists = ob.orders["order-1"] + ob.mu.RUnlock() + if exists { + t.Fatalf("expected order to be deleted from internal orders map") + } + + // Verify bid level is removed + bids = ob.GetBids() + if len(bids) != 0 { + t.Fatalf("expected bids to be empty, got %d levels", len(bids)) + } +} + +func TestOrderBook_CancelAskOrder(t *testing.T) { + symbol := types.Symbol("BTCUSD") + config := Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 4, + } + ob := NewOrderBook(symbol, config) + + order := &types.Order{ + ID: "order-2", + Symbol: symbol, + Side: types.Sell, + Type: types.Limit, + Price: decimal.NewFromFloat(51000.0), + RemainingQty: decimal.NewFromFloat(2.0), + } + + _, err := ob.AddOrder(order) + if err != nil { + t.Fatalf("failed to add order: %v", err) + } + + // Verify ask level is present + asks := ob.GetAsks() + if len(asks) != 1 || !asks[0].Price.Equal(order.Price) { + t.Fatalf("expected ask level at %v", order.Price) + } + + // Cancel the order + err = ob.CancelOrder("order-2") + if err != nil { + t.Fatalf("failed to cancel order: %v", err) + } + + // Verify ask level is removed + asks = ob.GetAsks() + if len(asks) != 0 { + t.Fatalf("expected asks to be empty, got %d levels", len(asks)) + } +} + +func TestOrderBook_CancelUnknownOrder(t *testing.T) { + symbol := types.Symbol("BTCUSD") + config := Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 4, + } + ob := NewOrderBook(symbol, config) + + err := ob.CancelOrder("unknown-order") + if err != ErrOrderNotFound { + t.Fatalf("expected ErrOrderNotFound, got %v", err) + } +} + +func TestOrderBook_ClosedBook(t *testing.T) { + symbol := types.Symbol("BTCUSD") + config := Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 4, + } + ob := NewOrderBook(symbol, config) + ob.Close() + + // Try adding order + order := &types.Order{ + ID: "order-3", + Symbol: symbol, + Side: types.Buy, + Price: decimal.NewFromFloat(50000.0), + RemainingQty: decimal.NewFromFloat(1.0), + } + _, err := ob.AddOrder(order) + if err != ErrBookClosed { + t.Fatalf("expected ErrBookClosed, got %v", err) + } + + // Try cancelling order + err = ob.CancelOrder("order-3") + if err != ErrBookClosed { + t.Fatalf("expected ErrBookClosed, got %v", err) + } +} + +func TestOrderBook_SnapshotImmutability(t *testing.T) { + symbol := types.Symbol("BTCUSD") + config := Config{ + MaxDepth: 10, + PriceDecimals: 2, + VolumeDecimals: 4, + } + ob := NewOrderBook(symbol, config) + + orderBid := &types.Order{ + ID: "bid-1", + Symbol: symbol, + Side: types.Buy, + Price: decimal.NewFromFloat(50000.0), + RemainingQty: decimal.NewFromFloat(1.0), + } + orderAsk := &types.Order{ + ID: "ask-1", + Symbol: symbol, + Side: types.Sell, + Price: decimal.NewFromFloat(51000.0), + RemainingQty: decimal.NewFromFloat(1.0), + } + + if _, err := ob.AddOrder(orderBid); err != nil { + t.Fatalf("failed to add bid order: %v", err) + } + if _, err := ob.AddOrder(orderAsk); err != nil { + t.Fatalf("failed to add ask order: %v", err) + } + + + snapshot := ob.GetSnapshot() + + // Mutate returned slice + snapshot.Bids[0].Price = decimal.NewFromFloat(999999.0) + snapshot.Asks[0].Price = decimal.NewFromFloat(111111.0) + + // Fetch another snapshot and verify it was not modified + newSnapshot := ob.GetSnapshot() + if newSnapshot.Bids[0].Price.Equal(decimal.NewFromFloat(999999.0)) { + t.Fatalf("snapshot mutation affected internal order book bids state") + } + if newSnapshot.Asks[0].Price.Equal(decimal.NewFromFloat(111111.0)) { + t.Fatalf("snapshot mutation affected internal order book asks state") + } + + // Verify GetBids() / GetAsks() copy slices + bids := ob.GetBids() + bids[0] = nil + newBids := ob.GetBids() + if newBids[0] == nil { + t.Fatalf("GetBids mutation affected internal order book state") + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..0bc51309 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -ra diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..b41dd96b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest>=8.0 +pytest-cov>=5.0 +PyYAML>=6.0 diff --git a/tests/backend_api/README.md b/tests/backend_api/README.md new file mode 100644 index 00000000..b9a0953e --- /dev/null +++ b/tests/backend_api/README.md @@ -0,0 +1,13 @@ +# Backend API Test Suite + +This suite validates the documented public backend API contract in +`docs/openapi/v3.yaml`. The runtime backend is implemented in Rust, so these +pytest tests use a small offline contract helper in `backend/api_contract.py` +instead of starting network services. + +Coverage includes: + +- one success-path assertion for every documented GET and POST operation +- authentication, not-found, request-validation, and internal-error cases +- empty, large, and unicode payload edge cases +- async request handling through pytest-asyncio diff --git a/tests/backend_api/test_openapi_contract.py b/tests/backend_api/test_openapi_contract.py new file mode 100644 index 00000000..06a44ea5 --- /dev/null +++ b/tests/backend_api/test_openapi_contract.py @@ -0,0 +1,194 @@ +"""Tests for the documented backend API contract. + +To run these tests locally, execute: + python3 -m pytest -v tests/backend_api +""" + +from __future__ import annotations + +import asyncio +import socket + +import pytest + + +def test_spec_loads_without_network(monkeypatch, spec_path, api_operations): + def blocked_socket(*_args, **_kwargs): + raise AssertionError("API contract tests must run without network access") + + monkeypatch.setattr(socket, "socket", blocked_socket) + + assert spec_path.exists() + assert api_operations + + +def test_every_operation_has_success_response(api_operations): + assert all(operation.success_statuses for operation in api_operations) + + +def test_get_and_post_operations_are_covered(api_operations): + methods = {operation.method for operation in api_operations} + + assert "GET" in methods + assert "POST" in methods + + +@pytest.mark.parametrize("method", ["GET", "POST"]) +def test_at_least_one_operation_per_http_verb(api_operations, method): + assert any(operation.method == method for operation in api_operations) + + +def test_success_cases_for_all_documented_operations(api_client, api_operations, valid_payloads, auth_token): + for operation in api_operations: + token = None if operation.path.startswith("/auth/") else auth_token + payload = valid_payloads.get((operation.method, operation.path)) + + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + assert response.status_code in operation.success_statuses + assert response.body["operation_id"] == operation.operation_id + + +def test_missing_auth_token_returns_documented_auth_error(api_client, api_operations): + protected_operations = [operation for operation in api_operations if not operation.path.startswith("/auth/")] + + for operation in protected_operations: + response = api_client.request(operation.method, operation.path) + + assert response.status_code in {401, 403, 400} + assert response.body["code"] == 4002 + + +def test_unknown_endpoint_returns_not_found(api_client): + response = api_client.request("GET", "/missing-resource") + + assert response.status_code == 404 + assert response.body["code"] == 4004 + + +def test_missing_required_payload_returns_validation_error(api_client, api_operations): + body_operations = [operation for operation in api_operations if operation.request_body_required] + + for operation in body_operations: + response = api_client.request(operation.method, operation.path, payload=None) + + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + + +def test_internal_error_path_is_mocked_without_external_dependencies(api_client): + response = api_client.request( + "POST", + "/auth/login", + payload={"__force_internal_error__": True}, + ) + + assert response.status_code == 500 + assert response.body["code"] == 5001 + + +def test_edge_case_payloads_are_accepted_by_contract_mock(api_client, api_operations, auth_token): + operation = next(operation for operation in api_operations if operation.method == "POST") + payload = { + "email": "unicode-\\u2603@example.com", + "password": "x" * 4096, + "mfa_code": "", + "client_fingerprint": "edge-case", + } + + response = api_client.request(operation.method, operation.path, payload=payload, token=auth_token) + + assert response.status_code in operation.success_statuses + + +def test_async_request_wrapper(api_client, auth_token): + response = asyncio.run(api_client.request_async("GET", "/users", token=auth_token)) + + assert response.status_code == 200 + assert response.body["path"] == "/users" + + +def test_missing_required_fields_in_payload_returns_error(api_client, api_operations, valid_payloads, auth_token, api_spec): + body_operations = [op for op in api_operations if op.request_body_required and op.required_fields] + + for operation in body_operations: + token = None if operation.path.startswith("/auth/") else auth_token + base_payload = valid_payloads.get((operation.method, operation.path)) + if not base_payload: + continue + + for missing_field in operation.required_fields: + # Create payload missing one required field + payload = {k: v for k, v in base_payload.items() if k != missing_field} + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Missing required parameters" in response.body["message"] + + +def test_malformed_fields_in_payload_returns_error(api_client, api_operations, valid_payloads, auth_token, api_spec): + from backend.api_contract import _request_schema + body_operations = [op for op in api_operations if op.request_body_required] + + for operation in body_operations: + token = None if operation.path.startswith("/auth/") else auth_token + base_payload = valid_payloads.get((operation.method, operation.path)) + if not base_payload: + continue + + schema = _request_schema(api_spec, operation.path, operation.method) + properties = schema.get("properties", {}) + if not isinstance(properties, dict): + continue + + for field, field_schema in properties.items(): + if not isinstance(field_schema, dict): + continue + expected_type = field_schema.get("type") + if not expected_type or field not in base_payload: + continue + + # Create a malformed value of a different type + if expected_type == "string": + malformed_value = 12345 + elif expected_type in {"integer", "number"}: + malformed_value = "not-a-number" + elif expected_type == "boolean": + malformed_value = "not-a-boolean" + elif expected_type == "array": + malformed_value = "not-an-array" + elif expected_type == "object": + malformed_value = "not-an-object" + else: + continue + + payload = dict(base_payload) + payload[field] = malformed_value + + response = api_client.request(operation.method, operation.path, payload=payload, token=token) + + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Malformed field" in response.body["message"] + + +def test_async_request_wrapper_negative_cases(api_client, api_operations, auth_token): + # Case 1: Missing auth token for protected endpoint via async wrapper + protected_operations = [op for op in api_operations if not op.path.startswith("/auth/")] + if protected_operations: + operation = protected_operations[0] + response = asyncio.run(api_client.request_async(operation.method, operation.path, token=None)) + assert response.status_code in {401, 403, 400} + assert response.body["code"] == 4002 + assert "Authentication required" in response.body["message"] + + # Case 2: Missing required payload for body-requiring endpoint via async wrapper + body_operations = [op for op in api_operations if op.request_body_required] + if body_operations: + operation = body_operations[0] + token = None if operation.path.startswith("/auth/") else auth_token + response = asyncio.run(api_client.request_async(operation.method, operation.path, payload=None, token=token)) + assert response.status_code in {400, 409, 422} + assert response.body["code"] == 4001 + assert "Invalid request parameters" in response.body["message"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d98b5e68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +"""Shared fixtures for backend API contract tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from backend.api_contract import ( + DEFAULT_SPEC_PATH, + MockBackendApiClient, + build_valid_payload, + iter_operations, + load_openapi_spec, +) + + +@pytest.fixture(scope="session") +def spec_path() -> Path: + # Centralize the public API spec location so tests do not duplicate paths. + return DEFAULT_SPEC_PATH + + +@pytest.fixture(scope="session") +def api_spec(spec_path: Path): + # Load the OpenAPI document once; no test fixture performs network I/O. + return load_openapi_spec(spec_path) + + +@pytest.fixture(scope="session") +def api_operations(api_spec): + # Expose every documented operation as normalized method/path metadata. + return iter_operations(api_spec) + + +@pytest.fixture(scope="session") +def api_client(api_spec, api_operations): + # Use a deterministic offline mock so API behavior can be tested without external services. + return MockBackendApiClient(api_spec, api_operations) + + +@pytest.fixture(scope="session") +def auth_token() -> str: + # Representative bearer token for endpoints that require authentication. + return "test-access-token" + + +@pytest.fixture(scope="session") +def valid_payloads(api_spec, api_operations): + # Build minimal request bodies from schema-required fields for POST endpoints. + return { + (operation.method, operation.path): build_valid_payload(api_spec, operation) + for operation in api_operations + if operation.request_body_required + } diff --git a/tools/encryptly/linux-arm64/encryptly b/tools/encryptly/linux-arm64/encryptly index 54631019..b3ec5306 100755 Binary files a/tools/encryptly/linux-arm64/encryptly and b/tools/encryptly/linux-arm64/encryptly differ diff --git a/tools/encryptly/macos-x64/encryptly b/tools/encryptly/macos-x64/encryptly new file mode 100755 index 00000000..dcbd8286 Binary files /dev/null and b/tools/encryptly/macos-x64/encryptly differ