feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [5/10] — wave 5-1: gate + scoped-events + hired + revoked-null + mine arms + reads-stay-free tests
CI / node (push) Has been cancelled
CI / python (push) Has been cancelled

This commit is contained in:
2026-10-21 04:41:08 +00:00
parent a62acf8241
commit c25e42c005
@@ -0,0 +1,260 @@
"""Learner application-status tests (D-104/G-322, REQ-11-005) — the
mine-read discovery arms, the status read's scoped-history + hired
placement arms, the revoked honest-null arm, and the reads-stay-free
pin (no self-429, G-313)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, PLACEMENT_LEARNERS, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
def _transition(client: TestClient, application_id: str, body: dict) -> None:
resp = client.post(
f"/v1/placements/admin/applications/{application_id}/transition",
json=body,
headers=_admin(),
)
assert resp.status_code == 200, resp.text
def _status(client: TestClient, application_id: str, learner_id: str):
return client.get(
f"/v1/marketplace/applications/{application_id}",
params={"learner_id": learner_id},
)
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the mine-read (G-322): discovery on revisit -------------------------------
def test_mine_own_200(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application_id"] == scaffold["application_id"]
assert body["status"] == "submitted"
def test_mine_none_404(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner-2"},
)
assert resp.status_code == 404
def test_mine_unallowlisted_403(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "intruder"},
)
assert resp.status_code == 403
def test_mine_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
"""A learner on the roster but WITHOUT a verified identity record →
the verified-18+ arm refuses (the D-043 composition)."""
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
resp = entered.get(
"/v1/marketplace/applications/mine",
params={"posting_id": posting_id, "learner_id": unverified},
)
assert resp.status_code == 403
# -- the status read (D-104): status + scoped history + placement ---------------
def test_status_own_200_with_events(scaffold) -> None:
client = scaffold["client"]
_transition(client, scaffold["application_id"], {"to": "in_review"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["id"] == scaffold["application_id"]
assert body["application"]["posting_id"] == scaffold["posting_id"]
assert body["application"]["learner_id"] == "pilot-learner"
assert body["application"]["status"] == "in_review"
assert [e["event_type"] for e in body["events"]] == ["application.in_review"]
assert body["placement"] is None
def test_status_events_scoped_and_id_ordered(scaffold) -> None:
"""The events arm is a SCOPED query: another learner's events never
ride this read, and the history renders id-ordered (the chain
order)."""
client = scaffold["client"]
import uuid
store = client.app.state.marketplace_store
posting2 = store.insert_posting(
Posting(
id=f"post-b-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"b-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Second posting",
)
)
store.set_posting_status(posting2.id, "published")
other = _apply(client, posting2.id, learner_id="pilot-learner-2")
_transition(client, other, {"to": "interview"})
_transition(client, scaffold["application_id"], {"to": "in_review"})
_transition(client, scaffold["application_id"], {"to": "interview"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert [e["event_type"] for e in body["events"]] == [
"application.in_review",
"application.interview",
]
assert all(
e["application_id"] == scaffold["application_id"] for e in body["events"]
)
def test_status_hired_carries_placement(scaffold) -> None:
client = scaffold["client"]
_transition(
client,
scaffold["application_id"],
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["status"] == "hired"
placement = body["placement"]
assert placement is not None
assert placement["role_title"] == "Placement Engineer"
assert placement["start_date"] == "2026-11-02"
assert placement["application_id"] == scaffold["application_id"]
assert placement["learner_id"] == "pilot-learner"
assert placement["employer_id"] == "emp-test"
assert placement["posting_id"] == scaffold["posting_id"]
# hired → the two-event chain: application.hired + placement.created
assert [e["event_type"] for e in body["events"]] == [
"application.hired",
"placement.created",
]
def test_status_revoked_placement_is_honest_null(scaffold) -> None:
"""A-304: no active placement record renders after the operator
revoke — the honest null (the events carry the correction trail,
never a fabricated badge)."""
client = scaffold["client"]
app_id = scaffold["application_id"]
_transition(
client,
app_id,
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
placement_id = _status(client, app_id, "pilot-learner").json()["placement"]["id"]
resp = client.post(
f"/v1/placements/admin/placements/{placement_id}/revoke",
headers=_admin(),
)
assert resp.status_code == 200, resp.text
resp = _status(client, app_id, "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["placement"] is None
assert body["events"][-1]["event_type"] == "placement.revoked"
def test_status_foreign_learner_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "pilot-learner-2")
assert resp.status_code == 403
def test_status_unallowlisted_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "intruder")
assert resp.status_code == 403
def test_status_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
marketplace_store.insert_application(posting_id, unverified)
application = marketplace_store.list_applications(posting_id)[0]
resp = _status(entered, application.id, unverified)
assert resp.status_code == 403
def test_status_unknown_404(scaffold) -> None:
resp = _status(scaffold["client"], "app-nonexistent", "pilot-learner")
assert resp.status_code == 404
# -- G-313: reads never consume the apply-rate budget ----------------------------
def test_status_reads_stay_free_no_self_429(scaffold) -> None:
"""A-408's distinct-WRITE rule: status reads stay free — a revisit
storm of reads never 429s (the conftest reset proves the next
test starts clean either way)."""
client = scaffold["client"]
for _ in range(10):
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text