Task 002: add shared domain contracts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.domain.contracts import ( # noqa: E402
|
||||
ARTICLE_WORKFLOW_STATUSES,
|
||||
ARTICLE_WORKFLOW_TRANSITIONS,
|
||||
ArticleCreateRequest,
|
||||
ArticleWorkflowStatus,
|
||||
Role,
|
||||
UserSummary,
|
||||
)
|
||||
|
||||
|
||||
class DomainContractTest(unittest.TestCase):
|
||||
def test_role_enum_is_canonical(self) -> None:
|
||||
self.assertEqual(["ADMIN", "EDITOR"], [role.value for role in Role])
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
UserSummary.model_validate(
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"display_name": "Writer",
|
||||
"role": "WRITER",
|
||||
}
|
||||
)
|
||||
|
||||
def test_article_create_rejects_missing_required_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError) as error:
|
||||
ArticleCreateRequest.model_validate({})
|
||||
|
||||
missing_fields = {
|
||||
issue["loc"][0]
|
||||
for issue in error.exception.errors()
|
||||
if issue["type"] == "missing"
|
||||
}
|
||||
self.assertEqual({"brief_description", "target_site_id"}, missing_fields)
|
||||
|
||||
def test_workflow_statuses_and_transitions_are_shared_constants(self) -> None:
|
||||
self.assertEqual(
|
||||
[
|
||||
"ARTICLE_BRIEF_CREATED",
|
||||
"BOUNDARY_QUESTIONS_GENERATED",
|
||||
"BOUNDARY_ANSWERS_SUBMITTED",
|
||||
"PLAN_GENERATED",
|
||||
"PLAN_REVIEW_REQUIRED",
|
||||
"PLAN_REVISION_REQUIRED",
|
||||
"RESEARCH_RUNNING",
|
||||
"EVIDENCE_MATRIX_READY",
|
||||
"PARALLEL_PRODUCTION_RUNNING",
|
||||
"DRAFT_ASSEMBLED",
|
||||
"SEO_AND_LANGUAGE_REVIEW_READY",
|
||||
"FINAL_REVIEW_REQUIRED",
|
||||
"FINAL_REVISION_REQUIRED",
|
||||
"PUBLISH_DRY_RUN_REQUIRED",
|
||||
"PUBLISH_COMMIT_READY",
|
||||
"PUBLISH_COMMIT_CREATED",
|
||||
],
|
||||
[status.value for status in ARTICLE_WORKFLOW_STATUSES],
|
||||
)
|
||||
self.assertEqual(set(ArticleWorkflowStatus), set(ARTICLE_WORKFLOW_TRANSITIONS))
|
||||
for status, next_statuses in ARTICLE_WORKFLOW_TRANSITIONS.items():
|
||||
self.assertIsInstance(status, ArticleWorkflowStatus)
|
||||
for next_status in next_statuses:
|
||||
self.assertIsInstance(next_status, ArticleWorkflowStatus)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
BACKEND_ROOT = REPO_ROOT / "apps" / "backend"
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class GeneratedContractArtifactsTest(unittest.TestCase):
|
||||
def test_openapi_snapshot_matches_backend_openapi(self) -> None:
|
||||
snapshot_path = REPO_ROOT / "packages" / "shared" / "openapi.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(app.openapi(), snapshot)
|
||||
|
||||
def test_typescript_types_are_marked_generated(self) -> None:
|
||||
types_path = REPO_ROOT / "packages" / "shared" / "src" / "api-types.ts"
|
||||
content = types_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(
|
||||
content.startswith(
|
||||
"// Generated from backend OpenAPI by "
|
||||
"scripts/generate_openapi_contracts.py."
|
||||
)
|
||||
)
|
||||
self.assertIn("export type ArticleCreateRequest", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
def _module_available(name: str) -> bool:
|
||||
try:
|
||||
return find_spec(name) is not None
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _install_optional_dependency_stubs() -> None:
|
||||
# Health routes import external clients; this contract test only reads OpenAPI.
|
||||
if not _module_available("boto3"):
|
||||
boto3 = types.ModuleType("boto3")
|
||||
boto3.client = lambda *args, **kwargs: None
|
||||
sys.modules["boto3"] = boto3
|
||||
|
||||
if not _module_available("psycopg"):
|
||||
psycopg = types.ModuleType("psycopg")
|
||||
psycopg.connect = lambda *args, **kwargs: None
|
||||
sys.modules["psycopg"] = psycopg
|
||||
|
||||
if not _module_available("redis"):
|
||||
redis = types.ModuleType("redis")
|
||||
|
||||
class Redis:
|
||||
@staticmethod
|
||||
def from_url(*args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
redis.Redis = Redis
|
||||
sys.modules["redis"] = redis
|
||||
|
||||
if not _module_available("botocore.config"):
|
||||
botocore = types.ModuleType("botocore")
|
||||
config = types.ModuleType("botocore.config")
|
||||
|
||||
class Config:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
config.Config = Config
|
||||
sys.modules["botocore"] = botocore
|
||||
sys.modules["botocore.config"] = config
|
||||
|
||||
|
||||
_install_optional_dependency_stubs()
|
||||
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
class BackendPublicOpenApiContractTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.openapi = app.openapi()
|
||||
self.schemas = self.openapi.get("components", {}).get("schemas", {})
|
||||
|
||||
def test_article_create_request_contract_is_public_and_rejects_invalid_payloads(
|
||||
self,
|
||||
) -> None:
|
||||
failures: list[str] = []
|
||||
|
||||
article_schema = self.schemas.get("ArticleCreateRequest")
|
||||
if article_schema is None:
|
||||
failures.append("components.schemas.ArticleCreateRequest is missing")
|
||||
else:
|
||||
required = set(article_schema.get("required", []))
|
||||
for field in ("brief_description", "target_site_id"):
|
||||
if field not in required:
|
||||
failures.append(f"ArticleCreateRequest must require {field!r}")
|
||||
|
||||
invalid_article_errors = self._validation_errors(
|
||||
schema_name="ArticleCreateRequest",
|
||||
payload={},
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "brief_description"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'brief_description'"
|
||||
)
|
||||
if not self._has_required_error(invalid_article_errors, "target_site_id"):
|
||||
failures.append(
|
||||
"ArticleCreateRequest contract must reject payloads missing "
|
||||
"'target_site_id'"
|
||||
)
|
||||
|
||||
request_ref = self._json_request_schema_ref(path="/api/articles", method="post")
|
||||
if request_ref != "#/components/schemas/ArticleCreateRequest":
|
||||
failures.append(
|
||||
"POST /api/articles must expose ArticleCreateRequest as its JSON "
|
||||
f"request body; got {request_ref or 'no request body'}"
|
||||
)
|
||||
|
||||
role_schema = self.schemas.get("Role")
|
||||
if role_schema is None:
|
||||
failures.append("components.schemas.Role is missing")
|
||||
else:
|
||||
role_values = self._enum_values(role_schema)
|
||||
if role_values != ["ADMIN", "EDITOR"]:
|
||||
failures.append(
|
||||
"Role enum must be exactly ['ADMIN', 'EDITOR']; "
|
||||
f"got {role_values!r}"
|
||||
)
|
||||
if not self._has_enum_error("Role", "WRITER"):
|
||||
failures.append("Role contract must reject invalid value 'WRITER'")
|
||||
|
||||
workflow_status_schema = self.schemas.get("ArticleWorkflowStatus")
|
||||
if workflow_status_schema is None:
|
||||
failures.append("components.schemas.ArticleWorkflowStatus is missing")
|
||||
elif not self._has_enum_error("ArticleWorkflowStatus", "NOT_A_STATUS"):
|
||||
failures.append(
|
||||
"ArticleWorkflowStatus contract must reject invalid workflow statuses"
|
||||
)
|
||||
|
||||
self.assertEqual([], failures)
|
||||
|
||||
def _json_request_schema_ref(self, *, path: str, method: str) -> str | None:
|
||||
operation = self.openapi.get("paths", {}).get(path, {}).get(method, {})
|
||||
content = operation.get("requestBody", {}).get("content", {})
|
||||
schema = content.get("application/json", {}).get("schema", {})
|
||||
return schema.get("$ref")
|
||||
|
||||
def _has_required_error(self, errors: list[str], field: str) -> bool:
|
||||
return any(error == f"missing required field: {field}" for error in errors)
|
||||
|
||||
def _has_enum_error(self, schema_name: str, value: str) -> bool:
|
||||
return f"invalid enum value: {value}" in self._validation_errors(
|
||||
schema_name=schema_name,
|
||||
payload=value,
|
||||
)
|
||||
|
||||
def _validation_errors(self, *, schema_name: str, payload: Any) -> list[str]:
|
||||
schema = self.schemas.get(schema_name)
|
||||
if schema is None:
|
||||
return [f"missing schema: {schema_name}"]
|
||||
|
||||
enum_values = self._enum_values(schema)
|
||||
if enum_values is not None:
|
||||
return [] if payload in enum_values else [f"invalid enum value: {payload}"]
|
||||
|
||||
if schema.get("type") != "object" or not isinstance(payload, dict):
|
||||
return []
|
||||
|
||||
required = schema.get("required", [])
|
||||
return [
|
||||
f"missing required field: {field}"
|
||||
for field in required
|
||||
if field not in payload
|
||||
]
|
||||
|
||||
def _enum_values(self, schema: dict[str, Any]) -> list[str] | None:
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list):
|
||||
return enum_values
|
||||
|
||||
for composite_key in ("allOf", "anyOf", "oneOf"):
|
||||
for item in schema.get(composite_key, []):
|
||||
resolved = self._resolve_schema(item)
|
||||
values = self._enum_values(resolved)
|
||||
if values is not None:
|
||||
return values
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return schema
|
||||
|
||||
prefix = "#/components/schemas/"
|
||||
if not ref.startswith(prefix):
|
||||
return schema
|
||||
|
||||
return self.schemas.get(ref.removeprefix(prefix), schema)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user