diff --git a/backend/app/api/external_results.py b/backend/app/api/external_results.py index 6b7849d..e12988c 100644 --- a/backend/app/api/external_results.py +++ b/backend/app/api/external_results.py @@ -276,6 +276,26 @@ async def create_external_case_result( raise if created: + details = { + "session_id": str(case_result.session_id), + "outcome": case_result.outcome.value, + "external_id": case_result.external_id, + "test_case_id": str(case_result.test_case_id) if case_result.test_case_id is not None else None, + "auto_registered": case_result.auto_registered, + "unresolved_requirement_ids": [ + str(requirement_id) for requirement_id in getattr(case_result, "unresolved_requirement_ids", []) + ], + } + if payload.requirement_external_ids: + details.update( + { + "requirement_external_ids_submitted": payload.requirement_external_ids, + "unresolved_requirement_external_ids": getattr( + case_result, "unresolved_requirement_external_ids", [] + ), + "auto_register_requirements": payload.auto_register_requirements, + } + ) await write_audit( db, actor_kind="runner_token", @@ -283,16 +303,7 @@ async def create_external_case_result( action="external_results.case.create", resource_type="external_case_result", resource_id=case_result.id, - details={ - "session_id": str(case_result.session_id), - "outcome": case_result.outcome.value, - "external_id": case_result.external_id, - "test_case_id": str(case_result.test_case_id) if case_result.test_case_id is not None else None, - "auto_registered": case_result.auto_registered, - "unresolved_requirement_ids": [ - str(requirement_id) for requirement_id in getattr(case_result, "unresolved_requirement_ids", []) - ], - }, + details=details, ) else: response.status_code = status.HTTP_200_OK diff --git a/backend/app/crud/external_case_results.py b/backend/app/crud/external_case_results.py index 1f3b699..65fea35 100644 --- a/backend/app/crud/external_case_results.py +++ b/backend/app/crud/external_case_results.py @@ -12,7 +12,7 @@ from app.models.external_case_result import CaseStatus, ExternalCaseResult from app.models.external_results import ExternalRunSession from app.models.link import LinkSource, LinkType, RequirementTestCaseLink -from app.models.requirement import PriorityLevel, Requirement +from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType from app.models.test_case import TestCase, TestCaseStatus, TestCaseType from app.schemas.external_results import CaseResultCreate, CaseResultUpdate @@ -21,6 +21,32 @@ def _outcome_value(outcome: Any) -> str: return getattr(outcome, "value", str(outcome)) +def _dedupe_requirement_ids(requirement_ids: list[UUID]) -> list[UUID]: + seen_requirement_ids: set[UUID] = set() + deduped_requirement_ids: list[UUID] = [] + + for requirement_id in requirement_ids: + if requirement_id in seen_requirement_ids: + continue + seen_requirement_ids.add(requirement_id) + deduped_requirement_ids.append(requirement_id) + + return deduped_requirement_ids + + +def _dedupe_requirement_external_ids(requirement_external_ids: list[str]) -> list[str]: + seen_external_ids: set[str] = set() + deduped_external_ids: list[str] = [] + + for external_id in requirement_external_ids: + if external_id in seen_external_ids: + continue + seen_external_ids.add(external_id) + deduped_external_ids.append(external_id) + + return deduped_external_ids + + async def _get_requirement_ids_for_test_case( db: AsyncSession, *, @@ -90,7 +116,7 @@ async def _link_requirements( if not requirement_ids: return [], [] - deduped_ids = list(dict.fromkeys(requirement_ids)) + deduped_ids = _dedupe_requirement_ids(requirement_ids) resolvable_ids, unresolved_ids = await _resolve_requirement_ids(db, requirement_ids=deduped_ids) values = [ @@ -113,12 +139,7 @@ async def _link_requirements( stmt = insert_stmt.on_conflict_do_nothing(index_elements=["requirement_id", "test_case_id"]) await db.execute(stmt) - linked_rows = await db.execute( - select(RequirementTestCaseLink.requirement_id) - .where(RequirementTestCaseLink.test_case_id == test_case_id) - .where(RequirementTestCaseLink.requirement_id.in_(deduped_ids)) - ) - return list(linked_rows.scalars().all()), unresolved_ids + return await _get_requirement_ids_for_test_case(db, test_case_id=test_case_id), unresolved_ids async def _resolve_requirement_ids( @@ -138,6 +159,52 @@ async def _resolve_requirement_ids( return resolvable_ids, unresolved_ids +async def _resolve_requirement_external_ids( + db: AsyncSession, + *, + requirement_external_ids: list[str] | None, + auto_register_requirements: bool, +) -> tuple[list[UUID], list[str]]: + if not requirement_external_ids: + return [], [] + + deduped_external_ids = _dedupe_requirement_external_ids(requirement_external_ids) + requirement_rows = await db.execute(select(Requirement).where(Requirement.external_id.in_(deduped_external_ids))) + requirements_by_external_id: dict[str, Requirement] = {} + for requirement in requirement_rows.scalars().all(): + external_id = requirement.external_id + if isinstance(external_id, str): + requirements_by_external_id[external_id] = requirement + + resolved_ids: list[UUID] = [] + unresolved_ids: list[str] = [] + + for submitted_external_id in deduped_external_ids: + requirement = requirements_by_external_id.get(submitted_external_id) + if requirement is not None: + resolved_ids.append(requirement.id) + continue + + if not auto_register_requirements: + unresolved_ids.append(submitted_external_id) + continue + + requirement = Requirement( + external_id=submitted_external_id, + title=submitted_external_id, + description=f"Auto-registered from external ID {submitted_external_id}", + type=RequirementType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=RequirementStatus.DRAFT, + ) + db.add(requirement) + await db.flush() + requirements_by_external_id[submitted_external_id] = requirement + resolved_ids.append(requirement.id) + + return resolved_ids, unresolved_ids + + async def create_case_result( db: AsyncSession, *, @@ -158,7 +225,13 @@ async def create_case_result( db, requirement_ids=payload.requirement_ids, ) + _resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids( + db, + requirement_external_ids=payload.requirement_external_ids, + auto_register_requirements=False, + ) existing.unresolved_requirement_ids = unresolved_ids + existing.unresolved_requirement_external_ids = unresolved_external_ids return existing, False session_result = await db.execute(select(ExternalRunSession).where(ExternalRunSession.id == session_id)) @@ -190,15 +263,25 @@ async def create_case_result( ) db.add(case_result) await db.flush() - linked_ids, unresolved_ids = await _link_requirements( + resolvable_ids, unresolved_ids = await _resolve_requirement_ids( db, - test_case_id=test_case.id, requirement_ids=payload.requirement_ids, ) + resolved_external_ids, unresolved_external_ids = await _resolve_requirement_external_ids( + db, + requirement_external_ids=payload.requirement_external_ids, + auto_register_requirements=payload.auto_register_requirements, + ) + linked_ids, _ = await _link_requirements( + db, + test_case_id=test_case.id, + requirement_ids=_dedupe_requirement_ids(resolvable_ids + resolved_external_ids), + ) await db.commit() await db.refresh(case_result) case_result.requirement_ids = linked_ids case_result.unresolved_requirement_ids = unresolved_ids + case_result.unresolved_requirement_external_ids = unresolved_external_ids return case_result, True diff --git a/backend/app/schemas/external_results.py b/backend/app/schemas/external_results.py index 558c01b..5f362a4 100644 --- a/backend/app/schemas/external_results.py +++ b/backend/app/schemas/external_results.py @@ -15,7 +15,7 @@ from typing import Any from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator, model_validator # --------------------------------------------------------------------------- # Enumerations @@ -143,6 +143,31 @@ class CaseResultCreate(BaseModel): default_factory=list, description="Requirement UUIDs to link; duplicate insertion is a no-op.", ) + requirement_external_ids: list[str] | None = Field( + default=None, + description="Reporter-supplied external IDs. Each is resolved against requirements.external_id; " + "unresolved IDs are dropped (or auto-registered if auto_register_requirements=True) and " + "recorded in the audit-log details.", + ) + auto_register_requirements: bool = Field( + default=False, + description="If true, unknown requirement_external_ids cause stub Requirement rows to be created " + "and linked. Default false — unknown IDs are dropped silently (with audit-log diagnostics).", + ) + + @field_validator("requirement_external_ids") + @classmethod + def _normalize_requirement_external_ids(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None + + normalized_ids: list[str] = [] + for external_id in value: + stripped_external_id = external_id.strip() + if not stripped_external_id: + raise ValueError("requirement_external_ids entries must not be empty.") + normalized_ids.append(stripped_external_id) + return normalized_ids @model_validator(mode="after") def _require_at_least_one_id(self) -> CaseResultCreate: diff --git a/backend/tests/test_external_results_requirement_links.py b/backend/tests/test_external_results_requirement_links.py new file mode 100644 index 0000000..5e304c9 --- /dev/null +++ b/backend/tests/test_external_results_requirement_links.py @@ -0,0 +1,364 @@ +"""Requirement-link integration tests for External Results case-result endpoints.""" + +from __future__ import annotations + +import uuid + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.crud.runner_token import create_runner_token +from app.db.session import get_db +from app.main import app +from app.models.audit_log import AuditLog +from app.models.base import Base +from app.models.link import RequirementTestCaseLink +from app.models.project import Project +from app.models.requirement import PriorityLevel, Requirement, RequirementStatus, RequirementType +from app.models.user import User, UserRole + +_PROJECT_ID = str(uuid.uuid4()) + + +def _auth_header(plaintext: str) -> dict[str, str]: + return {"Authorization": f"Bearer {plaintext}"} + + +def _make_user(role: UserRole = UserRole.admin) -> User: + return User( + id=uuid.uuid4(), + email=f"{role.value}-{uuid.uuid4().hex[:6]}@example.com", + hashed_password="hashed", + full_name=f"{role.value.capitalize()} User", + role=role, + is_active=True, + ) + + +def _session_payload() -> dict[str, str | dict[str, str]]: + return { + "runner": "pytest-bgstm@1.0.0", + "project_id": _PROJECT_ID, + "git_sha": "abc123", + "git_branch": "main", + "ci_url": f"https://ci.example.com/runs/{uuid.uuid4()}", + "metadata": {"os": "ubuntu-22.04"}, + } + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as session: + project = Project(id=uuid.UUID(_PROJECT_ID), name=f"project-{uuid.uuid4().hex[:6]}") + session.add(project) + await session.commit() + + async def _override_get_db(): + yield session + + app.dependency_overrides[get_db] = _override_get_db + yield session + app.dependency_overrides.clear() + + await engine.dispose() + + +@pytest_asyncio.fixture +async def admin_user(db_session): + admin = _make_user(UserRole.admin) + db_session.add(admin) + await db_session.commit() + return admin + + +@pytest_asyncio.fixture +async def write_token(db_session, admin_user): + return await create_runner_token( + db_session, + label="write-token", + scopes=["external_results:write"], + created_by_user_id=admin_user.id, + ) + + +def _create_session(client: TestClient, plaintext: str) -> str: + response = client.post("/api/v1/external-results/session", json=_session_payload(), headers=_auth_header(plaintext)) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def _create_requirement( + db_session: AsyncSession, + *, + external_id: str | None = None, +) -> Requirement: + requirement = Requirement( + id=uuid.uuid4(), + title=f"Req {uuid.uuid4().hex[:6]}", + description="desc", + type=RequirementType.FUNCTIONAL, + priority=PriorityLevel.MEDIUM, + status=RequirementStatus.DRAFT, + external_id=external_id, + ) + db_session.add(requirement) + await db_session.commit() + await db_session.refresh(requirement) + return requirement + + +class TestRequirementExternalIds: + @pytest.mark.asyncio + async def test_known_external_id_resolves_and_links(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session, external_id="REQ-KNOWN") + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"known-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-KNOWN"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [str(requirement.id)] + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 + + @pytest.mark.asyncio + async def test_unknown_external_id_without_auto_register_is_audited_and_unlinked(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"missing-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-MISSING"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [] + + audit_result = await db_session.execute( + select(AuditLog) + .where(AuditLog.action == "external_results.case.create") + .order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert audit.details["requirement_external_ids_submitted"] == ["REQ-MISSING"] + assert audit.details["unresolved_requirement_external_ids"] == ["REQ-MISSING"] + assert audit.details["auto_register_requirements"] is False + + @pytest.mark.asyncio + async def test_unknown_external_id_with_auto_register_creates_requirement_and_link(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"auto-register-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-MISSING"], + "auto_register_requirements": True, + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + requirement_id = uuid.UUID(response.json()["requirement_ids"][0]) + + requirement_result = await db_session.execute(select(Requirement).where(Requirement.id == requirement_id)) + requirement = requirement_result.scalar_one() + assert requirement.external_id == "REQ-MISSING" + assert requirement.title == "REQ-MISSING" + assert requirement.description == "Auto-registered from external ID REQ-MISSING" + assert requirement.type == RequirementType.FUNCTIONAL + assert requirement.priority == PriorityLevel.MEDIUM + assert requirement.status == RequirementStatus.DRAFT + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 + + @pytest.mark.asyncio + async def test_mixed_uuid_and_external_ids_deduplicate_union(self, db_session, write_token): + _token_model, plaintext = write_token + shared_requirement = await _create_requirement(db_session, external_id="REQ-SHARED") + uuid_only_requirement = await _create_requirement(db_session) + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"mixed-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_ids": [str(shared_requirement.id), str(uuid_only_requirement.id)], + "requirement_external_ids": ["REQ-SHARED"], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert set(response.json()["requirement_ids"]) == { + str(shared_requirement.id), + str(uuid_only_requirement.id), + } + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(response.json()["test_case_id"])) + ) + assert link_count_result.scalar_one() == 2 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("submitted_value", "include_field"), + [ + (None, True), + ([], True), + (None, False), + ], + ) + async def test_empty_or_null_external_ids_are_noop_and_do_not_pollute_audit( + self, + db_session, + write_token, + submitted_value, + include_field, + ): + _token_model, plaintext = write_token + + payload: dict[str, object] = { + "session_id": None, + "external_id": f"noop-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + } + + with TestClient(app) as client: + payload["session_id"] = _create_session(client, plaintext) + if include_field: + payload["requirement_external_ids"] = submitted_value + response = client.post( + "/api/v1/external-results/case", + json=payload, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 201, response.text + assert response.json()["requirement_ids"] == [] + + audit_result = await db_session.execute( + select(AuditLog) + .where(AuditLog.action == "external_results.case.create") + .order_by(AuditLog.created_at.desc()) + ) + audit = audit_result.scalar_one() + assert "requirement_external_ids_submitted" not in audit.details + assert "unresolved_requirement_external_ids" not in audit.details + assert "auto_register_requirements" not in audit.details + + @pytest.mark.asyncio + async def test_whitespace_only_external_id_entry_returns_422(self, db_session, write_token): + _token_model, plaintext = write_token + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + response = client.post( + "/api/v1/external-results/case", + json={ + "session_id": session_id, + "external_id": f"validation-{uuid.uuid4()}", + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": [" "], + }, + headers=_auth_header(plaintext), + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_idempotent_repost_with_same_external_ids_does_not_duplicate_links(self, db_session, write_token): + _token_model, plaintext = write_token + requirement = await _create_requirement(db_session, external_id="REQ-IDEMPOTENT") + external_id = f"idem-{uuid.uuid4()}" + payload = { + "external_id": external_id, + "title": "case", + "outcome": "passed", + "duration_ms": 10, + "requirement_external_ids": ["REQ-IDEMPOTENT"], + } + + with TestClient(app) as client: + session_id = _create_session(client, plaintext) + first = client.post( + "/api/v1/external-results/case", + json={"session_id": session_id, **payload}, + headers=_auth_header(plaintext), + ) + second = client.post( + "/api/v1/external-results/case", + json={"session_id": session_id, **payload}, + headers=_auth_header(plaintext), + ) + + assert first.status_code == 201, first.text + assert second.status_code == 200, second.text + assert first.json()["id"] == second.json()["id"] + assert second.json()["requirement_ids"] == [str(requirement.id)] + + link_count_result = await db_session.execute( + select(func.count()) + .select_from(RequirementTestCaseLink) + .where(RequirementTestCaseLink.test_case_id == uuid.UUID(first.json()["test_case_id"])) + .where(RequirementTestCaseLink.requirement_id == requirement.id) + ) + assert link_count_result.scalar_one() == 1 diff --git a/docs/specs/external_results_v1.md b/docs/specs/external_results_v1.md index 83cbbc3..f0c1669 100644 --- a/docs/specs/external_results_v1.md +++ b/docs/specs/external_results_v1.md @@ -237,7 +237,11 @@ Records one test-case execution. Duplicate `external_id` within the same `sessio "error_message": null, "requirement_ids": [ "c1234567-89ab-cdef-0123-456789abcdef" - ] + ], + "requirement_external_ids": [ + "REQ-LOGIN-001" + ], + "auto_register_requirements": false } ``` @@ -251,6 +255,8 @@ Records one test-case execution. Duplicate `external_id` within the same `sessio | `duration_ms` | `integer ≥ 0` | ✅ | Wall-clock duration in milliseconds. | | `error_message` | `string \| null` | — | First error line or assertion message. | | `requirement_ids` | `UUID[]` | — | Requirement UUIDs to link. Duplicate insertions are no-ops. Defaults to `[]`. | +| `requirement_external_ids` | `string[] \| null` | — | Reporter-supplied requirement external IDs. Values are trimmed; empty/whitespace-only entries are rejected with `422`. | +| `auto_register_requirements` | `boolean` | — | When `true`, unknown `requirement_external_ids` auto-create stub requirements and link them. Defaults to `false`. | #### Success response — `201 Created` @@ -272,6 +278,8 @@ Records one test-case execution. Duplicate `external_id` within the same `sessio `auto_registered: true` when BGSTM created a new test-case record from `external_id` (i.e. no `test_case_id` was supplied and the `external_id` had not been seen before in this project). +`requirement_external_ids` are resolved against `requirements.external_id` before links are inserted. By default, unknown external IDs are silently dropped from the response and link set, but they are recorded in the create audit entry under `unresolved_requirement_external_ids` for diagnostics. Setting `auto_register_requirements=true` opts into creating stub `Requirement` rows (`external_id` populated, draft/functional/medium defaults) so the resolved UUID is linked immediately and future re-POSTs resolve to the same requirement. + #### Error codes | Status | `code` | Cause | @@ -435,7 +443,7 @@ When a runner POSTs a case result with an `external_id` that already exists for ### Traceability links — `requirement_ids` -Inserting a `(test_case_id, requirement_id)` link that already exists is a **no-op**. No error is raised. +Inserting a `(test_case_id, requirement_id)` link that already exists is a **no-op**. No error is raised. The same rule applies when the caller supplies `requirement_external_ids`: after each external ID is resolved (or auto-registered) to a requirement UUID, duplicate links are ignored. Artifact uploads are not deduplicated; reporters that retry an upload (for example on transient network errors) may create duplicate artifact rows, and this is acceptable for v0.1.