Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions backend/app/api/external_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,23 +276,34 @@ 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",
actor_id=token.id,
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
Expand Down
103 changes: 93 additions & 10 deletions backend/app/crud/external_case_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
*,
Expand Down Expand Up @@ -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 = [
Expand All @@ -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(
Expand All @@ -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,
*,
Expand All @@ -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))
Expand Down Expand Up @@ -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


Expand Down
27 changes: 26 additions & 1 deletion backend/app/schemas/external_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading