Skip to content
134 changes: 134 additions & 0 deletions backend/app/api/v1/_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Test-only endpoints exposed when ``Settings.environment == "development"``.

These endpoints exist solely to support deterministic E2E coverage of
surfaces that are normally driven by long-running workers (e.g. the
orchestrator + digest worker producing a completed study with a digest).

**Security model.** Each endpoint guards on ``Settings.environment`` and
returns 404 ``RESOURCE_NOT_FOUND`` outside development. There is no auth
in MVP1 — the environment guard is the sole gate. Staging (MVP3+) and
production (MVP4+) MUST set ``ENVIRONMENT=staging``/``production`` so the
test surface disappears.

Origin: ``infra_e2e_seed_completed_study/idea.md`` (option 1 — API-direct
insertion path).
"""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession

from backend.app.core.settings import Settings, get_settings
from backend.app.db.session import get_db
from backend.app.services.test_seeding import seed_study_completed_with_digest

router = APIRouter()

# Subpath chosen to make the test surface visually distinct from the
# production API. Anything under ``/api/v1/_test/...`` is gated and
# should never appear in operator scripts.
_TEST_PREFIX = "/_test"


def _require_development_env(
settings: Annotated[Settings, Depends(get_settings)],
) -> None:
"""Dependency: return 404 unless ``Settings.environment == "development"``.

Returns 404 rather than 403 so the endpoint shape is indistinguishable
from "not registered" — an operator probing a production install
cannot discover this surface exists.
"""
if settings.environment != "development":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error_code": "RESOURCE_NOT_FOUND",
"message": "Not found",
"retryable": False,
},
)


class SeedCompletedStudyRequest(BaseModel):
"""Payload for ``POST /api/v1/_test/studies/seed-completed``.

All four FK fields are required; the caller is responsible for
seeding the parent rows first (typically via the public
``seedFullChain`` E2E helper).
"""

model_config = ConfigDict(extra="forbid")

cluster_id: str = Field(min_length=1)
query_set_id: str = Field(min_length=1)
template_id: str = Field(min_length=1)
judgment_list_id: str = Field(min_length=1)
with_pending_proposal: bool = Field(
default=True,
description=(
"When true (default), also insert a `status='pending'` proposal "
"linked to the study so the digest panel's Open PR button "
"renders enabled. Set false to test the AC-11 "
"aria-disabled-button + tooltip path."
),
)


class SeedCompletedStudyResponse(BaseModel):
"""IDs of the inserted rows; mirrors :class:`SeededStudyTriple`."""

study_id: str
digest_id: str
proposal_id: str | None


@router.post(
f"{_TEST_PREFIX}/studies/seed-completed",
response_model=SeedCompletedStudyResponse,
status_code=status.HTTP_201_CREATED,
tags=["test-only"],
dependencies=[Depends(_require_development_env)],
summary="Seed a completed study + digest + (optional) pending proposal",
description=(
"Test-only endpoint. Returns 404 unless `ENVIRONMENT=development`. "
"Inserts a study (driven through queued → running → completed via "
"the legal state-machine transitions), 2 trials (one winner, one "
"comparison), a digest, and optionally a pending proposal in a "
"single transaction. Used by the Playwright E2E suite to cover "
"the digest-panel surfaces (7 tooltip placements + AC-7 body "
"content + AC-11 Open PR enabled/disabled branches) without "
"waiting on the orchestrator + Optuna workers."
),
)
async def seed_completed_study( # pragma: no cover - integration only
body: SeedCompletedStudyRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> SeedCompletedStudyResponse:
"""See module docstring.

Marked ``pragma: no cover`` for the handler body — the env-guard
dependency + request/response schemas are covered by
``backend/tests/contract/test_test_endpoint_guard.py``; the actual
DB write path is covered by
``backend/tests/integration/test_test_seeding.py``. The handler is
one-line wire glue between those two layers.
"""
triple = await seed_study_completed_with_digest(
db,
cluster_id=body.cluster_id,
query_set_id=body.query_set_id,
template_id=body.template_id,
judgment_list_id=body.judgment_list_id,
with_pending_proposal=body.with_pending_proposal,
)
await db.commit()
return SeedCompletedStudyResponse(
study_id=triple.study_id,
digest_id=triple.digest_id,
proposal_id=triple.proposal_id,
)
19 changes: 19 additions & 0 deletions backend/app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,25 @@ def _validate_judgments_resume_sweep_minutes(cls, value: int) -> int:
"Optuna trial. Operator-tunable without redeploy.",
)

# `environment` gates dev-only surfaces (e.g. the test-seeding endpoint
# added by infra_e2e_seed_completed_study) that MUST NOT exist in staging
# or production. Strict equality check — anything other than the literal
# string "development" causes those endpoints to return 404.
#
# Canonical values per CLAUDE.md §"Environments":
# - "development" — local dev (`make up`) + CI (GitHub Actions service
# containers). Same toolchain; no auth; no TLS.
# - "staging" — MVP3+ operator deployment (TLS on; trusted network).
# - "production" — MVP4+ operator deployment (TLS + SSO + multi-tenant).
environment: str = Field(
default="development",
description=(
"Deployment environment tag. Controls dev-only surfaces such as "
"test-seeding endpoints. Must be one of {development, staging, "
"production}. Defaults to development for local + CI."
),
)

@cached_property
def database_url(self) -> str:
"""Resolved Postgres URL from ``DATABASE_URL_FILE``. Required."""
Expand Down
4 changes: 4 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from backend.app.api import health
from backend.app.api.errors import install_exception_handlers
from backend.app.api.middleware import RequestIDMiddleware
from backend.app.api.v1 import _test as test_router
from backend.app.api.v1 import clusters as clusters_router
from backend.app.api.v1 import config_repos as config_repos_router
from backend.app.api.v1 import conversations as conversations_router
Expand Down Expand Up @@ -170,4 +171,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app.include_router(proposals_router.router, prefix="/api/v1") # feat_digest_proposal Epic 3
app.include_router(config_repos_router.router, prefix="/api/v1") # feat_github_pr_worker Epic 3
app.include_router(conversations_router.router, prefix="/api/v1") # feat_chat_agent Epic 3
app.include_router(
test_router.router, prefix="/api/v1"
) # infra_e2e_seed_completed_study — dev-only; 404 outside
app.include_router(webhook_github_router.router) # feat_github_webhook /webhooks/github
181 changes: 181 additions & 0 deletions backend/app/services/test_seeding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Test-only seeding helper for E2E coverage of completed-study surfaces.

Drives a study deterministically through ``queued → running → completed`` and
populates the digest + a pending proposal so the frontend's digest panel
(seven InfoTooltip placements + AC-7 body content + the Open PR enabled
button) renders against real backend rows instead of mocked component data.

**Production-safe by construction.** The router that exposes this helper
(``backend/app/api/v1/_test.py``) gates on ``Settings.environment ==
"development"`` and returns 404 otherwise; the helper module itself has no
auth check — its sole caller is the gated router. Do not import this from
any production code path.

Origin: ``infra_e2e_seed_completed_study/idea.md`` (option 1 — API-direct
insertion path; alternative options 2/3 rejected for non-determinism and
brittleness).
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime, timedelta

import uuid_utils
from sqlalchemy.ext.asyncio import AsyncSession

from backend.app.db import repo
from backend.app.services import study_state


@dataclass(frozen=True)
class SeededStudyTriple:
"""IDs returned by :func:`seed_study_completed_with_digest`."""

study_id: str
digest_id: str
proposal_id: str | None


async def seed_study_completed_with_digest( # pragma: no cover - integration only
db: AsyncSession,
*,
cluster_id: str,
query_set_id: str,
template_id: str,
judgment_list_id: str,
with_pending_proposal: bool = True,
) -> SeededStudyTriple:
"""Insert a complete study + 2 trials + digest (+ optional pending proposal).

Drives the study through the legal state-machine transitions
(``queued → running → completed``) via :mod:`study_state` so the
FR-7 protection listener does not raise. Trials, digest, and proposal
rows are inserted directly via the repo layer — they have no state
machine.

Caller is responsible for committing. The router commits once at the
end of its handler.

Marked ``pragma: no cover`` because the function is exercised only
against a live Postgres — its repo-write path can't be unit-tested
without mocking out the entire repo + service layer, which would only
exercise the mocks. The integration test at
``backend/tests/integration/test_test_seeding.py`` provides real
coverage; this pragma is the safety net for coverage-tooling cases
where integration coverage isn't picked up (matches the precedent set
by ``feat_github_pr_worker`` PR #45 on ``backend/workers/git_pr.py``).
"""
study_id = str(uuid_utils.uuid7())

await repo.create_study(
db,
id=study_id,
name=f"e2e-seed-{study_id[:8]}",
cluster_id=cluster_id,
target="products",
template_id=template_id,
query_set_id=query_set_id,
judgment_list_id=judgment_list_id,
search_space={
"params": {
"title.boost": {"type": "float", "low": 0.5, "high": 5.0, "log": False},
},
},
objective={"metric": "ndcg", "k": 10, "direction": "maximize"},
config={"max_trials": 2, "sampler": "tpe", "pruner": "none"},
status="queued",
optuna_study_name=study_id,
)

# Transition queued → running BEFORE inserting trials so the seeded data
# mirrors the real orchestrator flow (study starts, then run_trial writes
# rows as trials execute) per Gemini feedback on PR #130. start_study
# returns the Study row with ``started_at`` stamped; we anchor trial
# timestamps off that so they're internally consistent with the study.
study = await study_state.start_study(db, study_id)
started = study.started_at or datetime.now(UTC)

# Trial 1 (winner): begins at study start, runs for 1200ms.
# Trial 2 (loser): begins 100ms after trial 1 ends, runs for 1100ms.
# ``ended_at - started_at`` matches the stored ``duration_ms`` so any
# downstream code that re-derives duration from the timestamp pair gets
# the same answer the orchestrator's writer would have produced.
winning_trial_id = str(uuid_utils.uuid7())
losing_trial_id = str(uuid_utils.uuid7())
await repo.create_trial(
db,
id=winning_trial_id,
study_id=study_id,
optuna_trial_number=0,
params={"title.boost": 2.5},
primary_metric=0.487,
metrics={"ndcg@10": 0.487, "map": 0.412, "p@10": 0.5},
duration_ms=1200,
status="complete",
error=None,
started_at=started,
ended_at=started + timedelta(milliseconds=1200),
)
await repo.create_trial(
db,
id=losing_trial_id,
study_id=study_id,
optuna_trial_number=1,
params={"title.boost": 0.8},
primary_metric=0.412,
metrics={"ndcg@10": 0.412, "map": 0.351, "p@10": 0.4},
duration_ms=1100,
status="complete",
error=None,
started_at=started + timedelta(milliseconds=1300),
ended_at=started + timedelta(milliseconds=2400),
)

await study_state.complete_study(
db,
study_id,
best_metric=0.487,
best_trial_id=winning_trial_id,
stop_reason="max_trials_reached",
)

digest_id = str(uuid_utils.uuid7())
await repo.create_digest(
db,
id=digest_id,
study_id=study_id,
narrative=(
"Seeded digest narrative for E2E coverage. Tuning `title.boost` from 1.0 to "
"2.5 lifted ndcg@10 from 0.412 (baseline) to 0.487 (+18.2%). The winning "
"configuration is recommended for production rollout."
),
parameter_importance={"title.boost": 1.0},
recommended_config={"title.boost": 2.5},
suggested_followups=[
"Try varying `description.boost` next.",
"Run with a larger query set to confirm the lift holds.",
],
generated_by="local:e2e_seed",
)

proposal_id: str | None = None
if with_pending_proposal:
proposal_id = str(uuid_utils.uuid7())
await repo.create_proposal(
db,
id=proposal_id,
study_id=study_id,
study_trial_id=winning_trial_id,
cluster_id=cluster_id,
template_id=template_id,
config_diff={"title.boost": {"from": 1.0, "to": 2.5}},
metric_delta={"ndcg@10": {"baseline": 0.412, "achieved": 0.487, "delta_pct": 18.2}},
status="pending",
)

return SeededStudyTriple(
study_id=study_id,
digest_id=digest_id,
proposal_id=proposal_id,
)
2 changes: 2 additions & 0 deletions backend/tests/contract/test_openapi_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@
("get", "/api/v1/conversations/{conversation_id}", "200"),
("delete", "/api/v1/conversations/{conversation_id}", "204"),
("post", "/api/v1/conversations/{conversation_id}/messages", "200"),
# ----- /api/v1/_test (infra_e2e_seed_completed_study; dev-only — 404 outside) -----
("post", "/api/v1/_test/studies/seed-completed", "201"),
]


Expand Down
Loading
Loading