Skip to content

Implement External Results v1 case-result API (create/update/get) with auto-upsert, transition guards, traceability linking, and audit coverage - #317

Merged
bg-playground merged 8 commits into
mainfrom
copilot/303-case-result-endpoints
May 8, 2026
Merged

Implement External Results v1 case-result API (create/update/get) with auto-upsert, transition guards, traceability linking, and audit coverage#317
bg-playground merged 8 commits into
mainfrom
copilot/303-case-result-endpoints

Conversation

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor
  • Reproduce and inspect CI/migration failure context for the case_outcome enum creation path
  • Update Alembic migration to avoid PG enum double-create by setting create_type=False on the PG enum
  • Update ORM model enum configuration to use name="case_outcome" and create_type=False for parity with migration
  • Run targeted lint/tests plus migration round-trip check (upgrade head, downgrade -1, upgrade head) with PostgreSQL
  • Commit changes, run final validation, and reply to PR comment with commit hash
Original prompt

Implement the case-result endpoints for the External Results v1 contract. Closes #303 (parent: #291).

This is the case-result link in the v0.1 contract chain:

The Pydantic schemas, audit-write helper, and the parent session model already exist on main — see backend/app/schemas/external_results.py, backend/app/crud/audit_log.py::write_audit, and backend/app/models/external_results.py::ExternalRunSession. Don't re-define any of those. Build on them.

Endpoints

All under /api/v1/external-results:

Method Path Auth Returns
POST /case runner token, scope external_results:write 201 Created + CaseResultResponse (or 200 OK on idempotent collision)
PATCH /case/{case_result_id} runner token, scope external_results:write 200 OK + CaseResultResponse
GET /case/{case_result_id} runner token (any scope) OR user JWT 200 OK + CaseResultResponse

Mirror the auth pattern in the existing session endpoints (backend/app/api/external_results.py):

  • For write paths: Depends(require_runner_scope("external_results:write")).
  • For the read path: reuse the dual-auth helper _get_session_auth from the same module if it can be parameterized cleanly, otherwise factor it into a shared dependency in app/auth/dependencies.py named get_runner_or_user_auth. Don't duplicate the helper inline — that's the kind of drift we're trying to avoid.

Schemas — already merged, do NOT modify

backend/app/schemas/external_results.py already defines:

  • CaseResultCreate — has session_id, test_case_id?, external_id?, title, outcome, duration_ms, error_message?, requirement_ids: list[UUID], plus a model_validator that enforces "at least one of test_case_id or external_id must be provided"
  • CaseResultResponse — has id, session_id, test_case_id?, external_id?, title, outcome, duration_ms, error_message?, requirement_ids: list[UUID], created_at, auto_registered
  • CaseResultUpdate — has outcome?, duration_ms?, error_message?
  • CaseOutcome enum — passed | failed | skipped | flaky

These are frozen for v0.1. Don't edit them.

Auto-upsert rules

Input Behavior
test_case_id provided + exists in test_cases table Link case result to it. auto_registered=False.
test_case_id provided + does not exist 404 with code: case.test_case_not_found
Only external_id provided Look up (project_id, external_id) in test_cases. Found → link, auto_registered=False. Not found → create new test_cases row with auto_registered=True and link.
Neither provided 422 (already enforced by CaseResultCreate.model_validator; let Pydantic handle it — don't duplicate)

The session's project_id comes from the parent external_run_sessions row. Resolve session_id → project_id via a single SELECT before the upsert. If session_id doesn't exist, return 404 case.session_not_found.

For the auto-upsert path, the new test_cases row needs sensible defaults. Look at backend/app/models/test_case.py for required fields. Required minimums:

  • id: new UUID
  • project_id: from session
  • external_id: from payload
  • title: from payload
  • auto_registered: True (add this column to the test-case model if it doesn't exist; it's referenced in the response and acceptance criteria — CaseResultResponse.auto_registered exists in the schema already, but verify the model has an auto_registered column. If not, add via migration in this PR — single column, default false, server-side default. Keep migration minimal.)

Important: CaseResultResponse.auto_registered is per-case-result, not per-test-case. It reflects "did this POST trigger an auto-creation." If external_id looked up an existing test case, return auto_registered=False even if that test case happens to have been auto-registered by an earlier call.

Status transitions

  • started → any: allowed
  • passed / failed / skipped → only flaky (re-classification window): allowed
  • flakyflaky: allowed (idempotent)
  • aborted → anything: blocked (terminal)
  • Any other transition: blocked

Violations on PATCH /case/{id} return 409 with code: case.transition.invalid and a structured details: { current_status, requested_status }. Match the pattern in app/crud/external_results.py::finish_session_db for the error envelope.

Traceability auto-link

If requirement_ids: [uuid, ...] is provided on POST /case:

  1. For each requirement_id, write an idempotent row into the traceability table linking the test case (the resolved or auto-created on...

This pull request was created from Copilot chat.

Copilot AI and others added 6 commits May 8, 2026 12:34
… and tests

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/434f0dd2-8f2f-440c-b288-e95f447e8ea3

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/434f0dd2-8f2f-440c-b288-e95f447e8ea3

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/434f0dd2-8f2f-440c-b288-e95f447e8ea3

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
…esolution

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/434f0dd2-8f2f-440c-b288-e95f447e8ea3

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement case-result endpoints for External Results v1 contract Implement External Results v1 case-result API (create/update/get) with auto-upsert, transition guards, traceability linking, and audit coverage May 8, 2026
Copilot AI requested a review from bg-playground May 8, 2026 12:42
@bg-playground

Copy link
Copy Markdown
Owner

@copilot

The CI failure here is the classic Alembic + PG enum double-create bug. Backend container exits during alembic upgrade head with:

sqlalchemy.exc.ProgrammingError: type "case_outcome" already exists
[SQL: CREATE TYPE case_outcome AS ENUM ('started', 'passed', 'failed', 'skipped', 'flaky', 'aborted')]

In backend/alembic/versions/k0l1m2n3o4p5_add_external_case_results.py, the enum is created twice:

  1. Explicitly via case_outcome_enum.create(op.get_bind(), checkfirst=True) — fine, idempotent.
  2. Implicitly when op.create_table(...) binds the column to case_outcome_enum. postgresql.ENUM(...) defaults to create_type=True, so it emits another CREATE TYPE with no checkfirst → boom.

Required fix

Add create_type=False to the postgresql.ENUM(...) constructor on the PG branch:

case_outcome_enum = postgresql.ENUM(
    "started", "passed", "failed", "skipped", "flaky", "aborted",
    name="case_outcome",
    create_type=False,   # ← add this; manual .create(checkfirst=True) below handles creation
)
case_outcome_enum.create(op.get_bind(), checkfirst=True)

That's the only change needed in the migration to unblock CI.

Also please fix (correctness, before re-running CI)

The model-side enum in backend/app/models/external_case_result.py is currently:

outcome = Column(Enum(CaseStatus, values_callable=_enum_values), nullable=False)

SQLAlchemy will derive a name from the Python enum class (something like casestatus), which doesn't match the migration's case_outcome. This means ORM-emitted DDL (e.g., Base.metadata.create_all in tests) creates a different PG type than the migration, and any production reflection that compares model-side type vs DB-side type will mismatch.

Please change to:

outcome = Column(
    Enum(
        CaseStatus,
        name="case_outcome",
        values_callable=_enum_values,
        create_type=False,
    ),
    nullable=False,
)

This matches the precedent set by runstatus on external_run_sessions — the model-side type name must match the migration's, and create_type=False prevents ORM-driven creation from racing with Alembic.

One reasonability check (no code change expected — just confirm)

downgrade() currently does drop_tableDROP TYPE IF EXISTS case_outcome. Order looks correct, but please run alembic upgrade head && alembic downgrade -1 && alembic upgrade head against a fresh PG instance to confirm clean round-trip. The pattern of create_type=False + manual .create(checkfirst=True) on upgrade should pair with manual DROP TYPE IF EXISTS on downgrade, which you have.

Once those two changes land, CI should be green. Thanks!

…odel enum name

Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/a476c823-ef02-40ac-a4db-b6221128af09

Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>

Copilot AI commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

@copilot

The CI failure here is the classic Alembic + PG enum double-create bug. Backend container exits during alembic upgrade head with:
...

Addressed in eefe4b4.

I updated the migration to set create_type=False on the PostgreSQL case_outcome enum while keeping the explicit create(..., checkfirst=True), and updated the ORM model enum to name="case_outcome" with create_type=False for parity.

I also validated the downgrade/upgrade round-trip on a fresh Postgres container:

  • alembic upgrade head
  • alembic downgrade -1
  • alembic upgrade head

Round-trip completed successfully.

@bg-playground
bg-playground marked this pull request as ready for review May 8, 2026 13:25
@bg-playground
bg-playground merged commit d02d554 into main May 8, 2026
10 checks passed
@bg-playground
bg-playground deleted the copilot/303-case-result-endpoints branch May 8, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Endpoints] Case result submission + auto-upsert

2 participants