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
16 changes: 7 additions & 9 deletions apps/api/tests/contract/test_conduct_procedure_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,22 +106,20 @@ def test_post_conduct_with_setpoint_to_unconnected_address_returns_not_connected


@pytest.mark.contract
def test_post_conduct_against_unregistered_procedure_returns_200_with_lifecycle_failure() -> None:
"""conduct() catches start_procedure rejections -> lifecycle failure on result."""
def test_post_conduct_against_unregistered_procedure_returns_404() -> None:
"""conduct() re-raises start_procedure's ProcedureNotFoundError so the
BC's central exception handler maps it to 404. Earlier shape (200 with
lifecycle failure on the body) was rejected by routes.py wiring: see
[[project_conduct_procedure_test_contract_drift]] memory."""
with TestClient(create_app()) as client:
unknown_pid = uuid4()
run = client.post(
f"/procedures/{unknown_pid}/conduct",
json={"steps": []},
)
assert run.status_code == 200
assert run.status_code == 404
payload = run.json()
assert payload["succeeded"] is False
failure = payload["failure"]
assert failure["step_index"] is None
assert failure["source_kind"] == "lifecycle"
assert failure["target"] == "start"
assert failure["error_class"] == "ProcedureNotFoundError"
assert str(unknown_pid) in payload["detail"]


@pytest.mark.contract
Expand Down
17 changes: 8 additions & 9 deletions apps/api/tests/contract/test_conduct_procedure_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ def test_mcp_conduct_procedure_with_unknown_action_returns_failure_in_structured


@pytest.mark.contract
def test_mcp_conduct_procedure_against_unregistered_procedure_returns_lifecycle_failure() -> None:
"""conduct() catches start_procedure rejection -> lifecycle failure on result."""
def test_mcp_conduct_procedure_against_unregistered_procedure_returns_iserror() -> None:
"""conduct() re-raises ProcedureNotFoundError; FastMCP surfaces as isError.
Earlier shape (200-with-lifecycle-failure structured content) was rejected
by routes.py wiring: see [[project_conduct_procedure_test_contract_drift]]
memory."""
with TestClient(create_app()) as client:
headers = open_session(client)
unknown_pid = uuid4()
Expand All @@ -127,10 +130,6 @@ def test_mcp_conduct_procedure_against_unregistered_procedure_returns_lifecycle_
},
headers=headers,
)
structured: dict[str, Any] = parse_sse_data(response.text)["result"]["structuredContent"]
assert structured["succeeded"] is False
failure = structured["failure"]
assert failure["step_index"] is None
assert failure["source_kind"] == "lifecycle"
assert failure["target"] == "start"
assert failure["error_class"] == "ProcedureNotFoundError"
body = parse_sse_data(response.text)
assert body["result"]["isError"] is True
assert str(unknown_pid) in body["result"]["content"][0]["text"]
4 changes: 2 additions & 2 deletions apps/api/tests/contract/test_get_actor_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_get_actor_returns_200_with_actor_response() -> None:
"id": str(actor_id),
"name": "Doga",
"kind": "human",
"is_active": True,
"active": True,
}


Expand All @@ -38,7 +38,7 @@ def test_get_actor_reflects_deactivation() -> None:
response = client.get(f"/actors/{actor_id}")

assert response.status_code == 200
assert response.json()["is_active"] is False
assert response.json()["active"] is False


@pytest.mark.contract
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/contract/test_get_actor_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_mcp_get_actor_tool_returns_structured_actor_for_known_id() -> None:
assert structured["id"] == str(actor_id)
assert structured["name"] == "Doga"
assert structured["kind"] == "human"
assert structured["is_active"] is True
assert structured["active"] is True


@pytest.mark.contract
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/e2e/test_actor_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async def test_register_then_get_then_list(
"id": str(actor_id),
"name": "Doga",
"kind": "human",
"is_active": True,
"active": True,
}

# LIST is projection-backed; drain so the bookmark catches up before
Expand Down
2 changes: 1 addition & 1 deletion apps/api/tests/e2e/test_family_and_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ async def test_register_asset_then_add_family_round_trips(
assert fetched.status_code == 200
body = fetched.json()
assert body["id"] == str(asset_id)
assert body["families"] == [str(family_id)]
assert body["family_ids"] == [str(family_id)]
51 changes: 29 additions & 22 deletions apps/api/tests/integration/test_add_run_to_campaign_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,19 +460,22 @@ async def test_concurrent_add_runs_to_same_campaign_settle_atomically(
db_pool: asyncpg.Pool,
) -> None:
"""asyncio.gather on two add_run_to_campaign calls for the same
Campaign + different Runs: under the shared-pool serialization
that asyncpg's per-connection acquire enforces, both succeed and
end up as members. Pins the happy-race path of the multi-stream
OCC contract.

Earlier shipped as `xfail strict=False` per Watch #15 deferral;
promoted to a real test in the Tier 2 cleanup once the XPASS
became the documented behavior. The forced-version-conflict
sibling test (`test_forced_concurrent_add_runs_raises_concurrency_error`)
pins the OCC failure path explicitly.
Campaign + different Runs: under true parallelism one call may lose
the optimistic-version race and raise ConcurrencyError, which the
operator (or a calling saga) is expected to retry. This test pins
that contract end-to-end: both Runs land as members via the
documented retry path, never via a lucky no-conflict shape.

Earlier shipped as `xfail strict=False` per Watch #15 deferral.
The forced-version-conflict sibling test
(`test_forced_concurrent_add_runs_raises_concurrency_error`) pins
the OCC failure-on-first-attempt path explicitly; this test pins
the operator-retries-and-both-land convergent path.
"""
import asyncio

from cora.infrastructure.ports.event_store import ConcurrencyError

campaign_id = uuid4()
run_ids = [uuid4(), uuid4()]
lead = uuid4()
Expand Down Expand Up @@ -501,18 +504,22 @@ async def test_concurrent_add_runs_to_same_campaign_settle_atomically(
await _seed_run_via_event_store(deps, run_id, event_id=uuid4())

add = add_run_to_campaign.bind(deps)
await asyncio.gather(
add(
AddRunToCampaign(campaign_id=campaign_id, run_id=run_ids[0]),
principal_id=_PRINCIPAL_ID,
correlation_id=_CORRELATION_ID,
),
add(
AddRunToCampaign(campaign_id=campaign_id, run_id=run_ids[1]),
principal_id=_PRINCIPAL_ID,
correlation_id=_CORRELATION_ID,
),
)

async def _add_with_retry(run_id: UUID) -> None:
for _attempt in range(3):
try:
await add(
AddRunToCampaign(campaign_id=campaign_id, run_id=run_id),
principal_id=_PRINCIPAL_ID,
correlation_id=_CORRELATION_ID,
)
return
except ConcurrencyError:
continue
msg = f"add_run_to_campaign(run={run_id}) did not settle after 3 retries"
raise AssertionError(msg)

await asyncio.gather(*[_add_with_retry(rid) for rid in run_ids])

# End-state: both Runs landed as Campaign members. Single-pool
# asyncio serialization makes this the realistic semantic today;
Expand Down
Loading