1253 lines
41 KiB
Python
1253 lines
41 KiB
Python
# ============================================================
|
|
# R71.4 plan-layer MPS golden tests
|
|
# MPS draft generation, finite rough-cut capacity assessment,
|
|
# plan -> schedule -> publish -> execution feedback trace,
|
|
# and plan-layer KPI vs detailed scheduling gap summary.
|
|
# ============================================================
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from server.agent_core.plan_runtime import PlanStore
|
|
from server.aps_domain.mps_planning import (
|
|
MpsTraceStore,
|
|
_draft_hash,
|
|
_draft_hash_for_version,
|
|
bind_mps_draft,
|
|
build_mps_draft,
|
|
compare_mps_schedule_gap,
|
|
get_mps_trace,
|
|
link_mps_trace_local_fact,
|
|
list_mps_schedule_versions,
|
|
persist_mps_draft,
|
|
replay_mps_trace,
|
|
schedule_fingerprint,
|
|
validate_mps_draft_binding,
|
|
)
|
|
from server.gateway import mps_api
|
|
from server.state.seed import seed_world
|
|
|
|
TENANT = "tenant-a"
|
|
PROJECT = "project-a"
|
|
WORLD_KEY = "project-a"
|
|
|
|
|
|
def _bound_draft(
|
|
world: dict,
|
|
draft: dict,
|
|
*,
|
|
tenant_uuid: str = TENANT,
|
|
project_id: str = PROJECT,
|
|
world_key: str = WORLD_KEY,
|
|
) -> dict:
|
|
return bind_mps_draft(
|
|
world,
|
|
draft,
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
|
|
|
|
def _trace_store(
|
|
path,
|
|
*,
|
|
tenant_uuid: str = TENANT,
|
|
project_id: str = PROJECT,
|
|
world_key: str = WORLD_KEY,
|
|
) -> MpsTraceStore:
|
|
return MpsTraceStore(
|
|
str(path),
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
|
|
|
|
def test_mps_draft_buckets_forecast_and_long_cycle():
|
|
world = seed_world()
|
|
world["salesOrders"][0]["isLongCycle"] = True
|
|
draft = build_mps_draft(world, mode="WEEK", horizon_days=90)
|
|
|
|
assert draft["kind"] == "MPS_DRAFT"
|
|
assert draft["summary"]["firmQty"] > 0
|
|
assert draft["summary"]["forecastQty"] > 0
|
|
assert draft["calendar"]["source"] in ("flexCalendar", "shiftCalendar", "default")
|
|
assert draft["calendar"]["shiftMinutes"] >= 60
|
|
assert sum(bucket["workdays"] for bucket in draft["buckets"]) > 0
|
|
assert all(
|
|
"mpsLines" in bucket and "byProduct" in bucket for bucket in draft["buckets"]
|
|
)
|
|
sources = {
|
|
line["source"] for bucket in draft["buckets"] for line in bucket["mpsLines"]
|
|
}
|
|
assert "FORECAST" in sources
|
|
assert "LONG_CYCLE" in sources
|
|
|
|
|
|
def test_mps_draft_hash_survives_browser_number_roundtrip():
|
|
world = seed_world()
|
|
world["salesOrders"][0]["items"][0]["quantity"] = 1.0
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
|
|
def browser_numbers(value):
|
|
if isinstance(value, dict):
|
|
return {key: browser_numbers(item) for key, item in value.items()}
|
|
if isinstance(value, list):
|
|
return [browser_numbers(item) for item in value]
|
|
if isinstance(value, float) and value.is_integer():
|
|
return int(value)
|
|
return value
|
|
|
|
browser_payload = json.loads(json.dumps(browser_numbers(draft), ensure_ascii=False))
|
|
assert browser_payload["draftHashVersion"] == "mps-draft-hash.v2"
|
|
validated = validate_mps_draft_binding(
|
|
world,
|
|
browser_payload,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
assert validated["draftHash"] == draft["draftHash"]
|
|
|
|
browser_payload["summary"]["weightedDemand"] += 1
|
|
with pytest.raises(ValueError, match="content hash mismatch"):
|
|
validate_mps_draft_binding(
|
|
world,
|
|
browser_payload,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
|
|
def test_mps_v2_hash_has_no_number_string_or_business_object_collision():
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
original = draft["summary"]["weightedDemand"]
|
|
|
|
for replacement in (
|
|
str(original),
|
|
{"__apsJsonNumber__": str(original)},
|
|
["number", str(original)],
|
|
):
|
|
tampered = copy.deepcopy(draft)
|
|
tampered["summary"]["weightedDemand"] = replacement
|
|
with pytest.raises(ValueError, match="content hash mismatch"):
|
|
validate_mps_draft_binding(
|
|
world,
|
|
tampered,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
|
|
def test_mps_legacy_v1_draft_remains_replayable_server_side(tmp_path):
|
|
world = seed_world()
|
|
legacy = build_mps_draft(world, horizon_days=30)
|
|
legacy["scope"] = {
|
|
"tenantUuid": TENANT,
|
|
"projectId": PROJECT,
|
|
"worldKey": WORLD_KEY,
|
|
}
|
|
legacy["worldFingerprint"] = _bound_draft(
|
|
world, build_mps_draft(world, horizon_days=30)
|
|
)["worldFingerprint"]
|
|
legacy["draftHash"] = _draft_hash(legacy)
|
|
|
|
validated = validate_mps_draft_binding(
|
|
world,
|
|
legacy,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
allow_legacy=True,
|
|
)
|
|
assert "draftHashVersion" not in validated
|
|
assert validated["draftHash"] == legacy["draftHash"]
|
|
|
|
trace_store = _trace_store(tmp_path / "legacy-trace.json")
|
|
envelope = trace_store.create(
|
|
plan_id="legacy-plan",
|
|
plan_version=1,
|
|
draft_id=legacy["draftId"],
|
|
draft_snapshot=legacy,
|
|
input_summary={
|
|
"tenantUuid": TENANT,
|
|
"projectId": PROJECT,
|
|
"worldKey": WORLD_KEY,
|
|
"draftId": legacy["draftId"],
|
|
"draftHash": legacy["draftHash"],
|
|
"worldFingerprint": legacy["worldFingerprint"],
|
|
"parameters": {},
|
|
},
|
|
world_fingerprint=legacy["worldFingerprint"],
|
|
)
|
|
assert envelope["draftHashVersion"] == "mps-draft-hash.v1"
|
|
replayed = replay_mps_trace(envelope["traceId"], world=world, trace_store=trace_store)
|
|
assert replayed["draft"] == legacy
|
|
|
|
|
|
def test_mps_interim_unversioned_numeric_hash_trace_remains_replayable(tmp_path):
|
|
world = seed_world()
|
|
interim = build_mps_draft(world, horizon_days=30)
|
|
interim["scope"] = {
|
|
"tenantUuid": TENANT,
|
|
"projectId": PROJECT,
|
|
"worldKey": WORLD_KEY,
|
|
}
|
|
interim["worldFingerprint"] = _bound_draft(
|
|
world, build_mps_draft(world, horizon_days=30)
|
|
)["worldFingerprint"]
|
|
interim["draftHash"] = _draft_hash_for_version(
|
|
interim, "mps-draft-hash.v1-json-numbers"
|
|
)
|
|
assert "draftHashVersion" not in interim
|
|
|
|
validated = validate_mps_draft_binding(
|
|
world,
|
|
interim,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
allow_legacy=True,
|
|
)
|
|
trace_store = _trace_store(tmp_path / "interim-trace.json")
|
|
envelope = trace_store.create(
|
|
plan_id="interim-plan",
|
|
plan_version=1,
|
|
draft_id=validated["draftId"],
|
|
draft_snapshot=validated,
|
|
input_summary={
|
|
"tenantUuid": TENANT,
|
|
"projectId": PROJECT,
|
|
"worldKey": WORLD_KEY,
|
|
"draftId": validated["draftId"],
|
|
"draftHash": validated["draftHash"],
|
|
"worldFingerprint": validated["worldFingerprint"],
|
|
"parameters": {},
|
|
},
|
|
world_fingerprint=validated["worldFingerprint"],
|
|
)
|
|
assert envelope["draftHashVersion"] == "mps-draft-hash.v1-json-numbers"
|
|
assert replay_mps_trace(
|
|
envelope["traceId"], world=world, trace_store=trace_store
|
|
)["draft"] == validated
|
|
|
|
|
|
def test_mps_interim_hash_algorithm_matches_frozen_fixture():
|
|
fixture = {
|
|
"kind": "MPS_DRAFT",
|
|
"summary": {
|
|
"weightedDemand": 1.0,
|
|
"marker": {"__apsJsonNumber__": "1"},
|
|
},
|
|
}
|
|
assert _draft_hash_for_version(
|
|
fixture, "mps-draft-hash.v1-json-numbers"
|
|
) == "5d42e32ede0efc008ebb122a69a18c2c87cc68b18cf71ada47d266598d81bac2"
|
|
|
|
|
|
def test_mps_api_rejects_legacy_hash_downgrade_and_marker_attack(mps_api_client):
|
|
client, _state, _stores, _ensure_scope = mps_api_client
|
|
draft = client.post("/api/mps/draft", json={"horizonDays": 30}).json()["draft"]
|
|
downgraded = copy.deepcopy(draft)
|
|
downgraded.pop("draftHashVersion", None)
|
|
downgraded["summary"]["weightedDemand"] = {
|
|
"__apsJsonNumber__": str(draft["summary"]["weightedDemand"])
|
|
}
|
|
downgraded["draftHash"] = _draft_hash_for_version(
|
|
downgraded, "mps-draft-hash.v1-json-numbers"
|
|
)
|
|
|
|
response = client.post("/api/mps/persist", json={"draft": downgraded})
|
|
assert response.status_code == 409
|
|
assert "requires draftHashVersion v2" in response.json()["detail"]
|
|
|
|
|
|
def test_mps_draft_long_cycle_only_filter():
|
|
world = seed_world()
|
|
world["salesOrders"][0]["isLongCycle"] = True
|
|
draft = build_mps_draft(
|
|
world,
|
|
mode="WEEK",
|
|
horizon_days=90,
|
|
long_cycle_only=True,
|
|
include_forecast=False,
|
|
)
|
|
|
|
sources = {
|
|
line["source"] for bucket in draft["buckets"] for line in bucket["mpsLines"]
|
|
}
|
|
assert sources == {"LONG_CYCLE"}
|
|
|
|
|
|
def test_mps_finite_capacity_assessment_reports_feasibility_and_bottlenecks():
|
|
world = seed_world()
|
|
for line in world["lines"]:
|
|
line["capacityPerDay"] = 50
|
|
draft = build_mps_draft(world, mode="WEEK", horizon_days=60, capacity_mode="FINITE")
|
|
|
|
assert draft["capacity"]["capacityMode"] == "FINITE"
|
|
assert draft["capacity"]["feasibleRate"] < 1.0
|
|
assert draft["capacity"]["bottleneckBucketCount"] >= 1
|
|
first = draft["capacity"]["bottlenecks"][0]
|
|
assert first["loadRatio"] >= 1.0
|
|
assert first["drivers"]
|
|
|
|
over_lines = [
|
|
line
|
|
for bucket in draft["buckets"]
|
|
for line in bucket["mpsLines"]
|
|
if bucket["status"] == "OVER"
|
|
]
|
|
assert over_lines
|
|
assert all(line["commitment"] == "INFEASIBLE" for line in over_lines)
|
|
|
|
infinite = build_mps_draft(
|
|
world, mode="WEEK", horizon_days=60, capacity_mode="INFINITE"
|
|
)
|
|
assert infinite["capacityMode"] == "INFINITE"
|
|
assert infinite["summary"]["overCount"] == 0
|
|
assert all(bucket["capacity"] is None for bucket in infinite["buckets"])
|
|
|
|
|
|
def test_mps_trace_envelope_chain_is_persistent(tmp_path):
|
|
plan_store = PlanStore(str(tmp_path / "plans.json"))
|
|
trace_store = _trace_store(tmp_path / "mps_trace.json")
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, mode="WEEK", horizon_days=30))
|
|
persisted = persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=plan_store,
|
|
trace_store=trace_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
node = persisted["planNode"]
|
|
envelope = persisted["envelope"]
|
|
assert node.layer == "L2"
|
|
assert node.parentId == "strategy-mps"
|
|
assert node.payload["kind"] == "mps-draft"
|
|
assert envelope["planId"] == node.planId
|
|
assert envelope["scheduleId"] is None
|
|
assert envelope["publishId"] is None
|
|
assert envelope["executionFeedback"] == []
|
|
assert persisted["draft"]["planId"] == node.planId
|
|
assert envelope["tenantUuid"] == TENANT
|
|
assert envelope["projectId"] == PROJECT
|
|
assert envelope["worldKey"] == WORLD_KEY
|
|
assert envelope["draftSnapshot"] == persisted["draft"]
|
|
assert envelope["inputSummary"]["draftHash"] == draft["draftHash"]
|
|
assert envelope["draftHashVersion"] == "mps-draft-hash.v2"
|
|
assert envelope["inputSummary"]["draftHashVersion"] == "mps-draft-hash.v2"
|
|
assert node.payload["draftSnapshot"] == draft
|
|
assert node.payload["worldFingerprint"] == draft["worldFingerprint"]
|
|
|
|
trace_store.link_schedule(
|
|
envelope["traceId"],
|
|
42,
|
|
schedule_version_no="V1",
|
|
track="fixed",
|
|
)
|
|
trace_store.link_publish(
|
|
envelope["traceId"],
|
|
"PUB-1",
|
|
published_at="2026-08-12T00:00:00Z",
|
|
)
|
|
trace_store.link_feedback(
|
|
envelope["traceId"],
|
|
feedback_id="FB-1",
|
|
work_order_id=7,
|
|
progress_pct=50,
|
|
status="RUNNING",
|
|
qty_done=250,
|
|
)
|
|
|
|
got = get_mps_trace(envelope["traceId"], trace_store=trace_store)
|
|
assert got["scheduleId"] == 42
|
|
assert got["scheduleVersionNo"] == "V1"
|
|
assert got["scheduleTrack"] == "fixed"
|
|
assert got["publishId"] == "PUB-1"
|
|
assert len(got["executionFeedback"]) == 1
|
|
assert [event["event"] for event in got["events"]] == [
|
|
"OPEN",
|
|
"SCHEDULE",
|
|
"PUBLISH",
|
|
"FEEDBACK",
|
|
]
|
|
|
|
reloaded = _trace_store(tmp_path / "mps_trace.json")
|
|
assert reloaded.get(envelope["traceId"])["scheduleId"] == 42
|
|
replayed = replay_mps_trace(envelope["traceId"], world=world, trace_store=reloaded)
|
|
assert replayed["draft"] == persisted["draft"]
|
|
assert replayed["inputSummary"] == envelope["inputSummary"]
|
|
assert plan_store.latest("intent-mps").layer == "L0"
|
|
assert plan_store.latest("strategy-mps").layer == "L1"
|
|
|
|
|
|
def test_compare_mps_schedule_gap_summary():
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, mode="WEEK", horizon_days=30))
|
|
world["scheduleVersions"].append(
|
|
{
|
|
"id": 1,
|
|
"versionNo": "V20260812-001",
|
|
"status": "PUBLISHED",
|
|
"orderCount": 9,
|
|
"poCount": 7,
|
|
"woCount": 21,
|
|
"conflictCount": 1,
|
|
"totalTardiness": 5.5,
|
|
"avgUtilization": 0.82,
|
|
}
|
|
)
|
|
world["productionOrders"] = [
|
|
{"id": index, "schedulingVersionId": 1, "quantity": qty}
|
|
for index, qty in enumerate([500, 300, 800, 400, 600, 200, 500], start=1)
|
|
]
|
|
draft = _bound_draft(
|
|
world,
|
|
{
|
|
key: value
|
|
for key, value in draft.items()
|
|
if key not in {"draftHash", "scope", "worldFingerprint"}
|
|
},
|
|
)
|
|
report = compare_mps_schedule_gap(
|
|
world,
|
|
draft,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
schedule_id=1,
|
|
track="fixed",
|
|
)
|
|
|
|
assert report["schedule"]["versionNo"] == "V20260812-001"
|
|
assert report["gaps"]["demandGapQty"] == round(
|
|
draft["summary"]["weightedDemand"] - 3300,
|
|
1,
|
|
)
|
|
assert {row["key"] for row in report["rows"]} == {
|
|
"demandQty",
|
|
"loadRatio",
|
|
"feasibleRate",
|
|
"riskCount",
|
|
}
|
|
assert all(
|
|
"plan" in row and "schedule" in row and "delta" in row for row in report["rows"]
|
|
)
|
|
assert report["trace"]["scheduleId"] == 1
|
|
|
|
|
|
def test_compare_mps_schedule_gap_uses_trace_refs(tmp_path):
|
|
world = seed_world()
|
|
draft = build_mps_draft(world, mode="WEEK", horizon_days=30)
|
|
world["scheduleVersions"].append(
|
|
{
|
|
"id": 2,
|
|
"versionNo": "V2",
|
|
"status": "PUBLISHED",
|
|
"orderCount": 9,
|
|
"poCount": 7,
|
|
"woCount": 21,
|
|
"conflictCount": 0,
|
|
"totalTardiness": 0.0,
|
|
"avgUtilization": 0.78,
|
|
}
|
|
)
|
|
world["productionOrders"] = [
|
|
{"id": index, "schedulingVersionId": 2, "quantity": qty}
|
|
for index, qty in enumerate([500, 300, 800, 400, 600, 200, 500], start=1)
|
|
]
|
|
draft = _bound_draft(world, draft)
|
|
plan_store = PlanStore(str(tmp_path / "plans.json"))
|
|
trace_store = _trace_store(tmp_path / "mps_trace.json")
|
|
persisted = persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=plan_store,
|
|
trace_store=trace_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
trace_id = persisted["envelope"]["traceId"]
|
|
trace_store.link_schedule(
|
|
trace_id,
|
|
2,
|
|
schedule_version_no="V2",
|
|
track="fixed",
|
|
schedule_fingerprint=schedule_fingerprint(
|
|
world, "fixed", world["scheduleVersions"][-1]
|
|
),
|
|
)
|
|
trace_store.link_publish(trace_id, "PUB-2")
|
|
trace_store.link_feedback(
|
|
trace_id,
|
|
feedback_id="FB-1",
|
|
work_order_id=7,
|
|
progress_pct=100,
|
|
status="COMPLETED",
|
|
qty_done=300,
|
|
)
|
|
trace = get_mps_trace(trace_id, trace_store=trace_store)
|
|
report = compare_mps_schedule_gap(
|
|
world,
|
|
draft,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
track="fixed",
|
|
trace=trace,
|
|
)
|
|
|
|
assert report["schedule"]["versionNo"] == "V2"
|
|
assert report["trace"]["planId"] == persisted["envelope"]["planId"]
|
|
assert report["trace"]["publishId"] == "PUB-2"
|
|
assert report["trace"]["feedbackCount"] == 1
|
|
|
|
|
|
def test_compare_mps_schedule_gap_without_schedule_returns_null():
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, mode="WEEK", horizon_days=30))
|
|
report = compare_mps_schedule_gap(
|
|
world,
|
|
draft,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
assert report["schedule"] is None
|
|
assert report["gaps"] is None
|
|
assert report["rows"] == []
|
|
|
|
|
|
class _MpsApiMemStore:
|
|
def __init__(self, world: dict, world_key: str) -> None:
|
|
self.data = world
|
|
self.world_key = world_key
|
|
self.path = "memory://mps-api-test"
|
|
|
|
def next_id(self, _kind: str) -> int:
|
|
return 1
|
|
|
|
def save(self) -> None:
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def mps_api_client(tmp_path, monkeypatch):
|
|
def make_world() -> dict:
|
|
world = seed_world()
|
|
world["scheduleVersions"] = [
|
|
{
|
|
"id": 1,
|
|
"versionNo": "V20260812-001",
|
|
"status": "PUBLISHED",
|
|
"orderCount": 9,
|
|
"poCount": 7,
|
|
"woCount": 21,
|
|
"conflictCount": 1,
|
|
"totalTardiness": 5.5,
|
|
"avgUtilization": 0.82,
|
|
}
|
|
]
|
|
world["productionOrders"] = [
|
|
{"id": index, "schedulingVersionId": 1, "quantity": qty}
|
|
for index, qty in enumerate([500, 300, 800, 400, 600, 200, 500], start=1)
|
|
]
|
|
return world
|
|
|
|
state = {
|
|
"tenantUuid": TENANT,
|
|
"projectId": PROJECT,
|
|
"worldKey": WORLD_KEY,
|
|
"userId": 101,
|
|
"username": "alice",
|
|
}
|
|
stores: dict[tuple[str, str], _MpsApiMemStore] = {}
|
|
plan_stores: dict[tuple[str, str], PlanStore] = {}
|
|
|
|
def ensure_scope(tenant_uuid: str, project_id: str, world_key: str) -> None:
|
|
stores.setdefault(
|
|
(tenant_uuid, world_key),
|
|
_MpsApiMemStore(make_world(), world_key),
|
|
)
|
|
plan_stores.setdefault(
|
|
(tenant_uuid, world_key),
|
|
PlanStore(
|
|
str(tmp_path / f"plans-{tenant_uuid}-{project_id}-{world_key}.json")
|
|
),
|
|
)
|
|
|
|
ensure_scope(TENANT, PROJECT, WORLD_KEY)
|
|
|
|
def identity(*, required: bool = False):
|
|
del required
|
|
return SimpleNamespace(
|
|
tenant_uuid=state["tenantUuid"],
|
|
user_id=state["userId"],
|
|
username=state["username"],
|
|
auth_kind="user",
|
|
)
|
|
|
|
monkeypatch.setattr(mps_api, "get_identity", identity)
|
|
monkeypatch.setattr(
|
|
mps_api,
|
|
"get_project_store",
|
|
lambda: SimpleNamespace(active_world_key=lambda: state["projectId"]),
|
|
)
|
|
monkeypatch.setattr(
|
|
mps_api,
|
|
"get_store",
|
|
lambda: stores[(state["tenantUuid"], state["worldKey"])],
|
|
)
|
|
monkeypatch.setattr(
|
|
mps_api,
|
|
"get_plan_store",
|
|
lambda: plan_stores[(state["tenantUuid"], state["worldKey"])],
|
|
)
|
|
monkeypatch.setattr(mps_api, "aps_home", lambda: tmp_path)
|
|
app = FastAPI(title="mps-router-test")
|
|
app.include_router(mps_api.router)
|
|
return TestClient(app), state, stores, ensure_scope
|
|
|
|
|
|
def test_mps_api_persist_trace_and_gap_contract(mps_api_client):
|
|
client, _state, _stores, _ensure_scope = mps_api_client
|
|
draft_resp = client.post("/api/mps/draft", json={"mode": "WEEK", "horizonDays": 30})
|
|
assert draft_resp.status_code == 200
|
|
draft = draft_resp.json()["draft"]
|
|
|
|
persist_resp = client.post(
|
|
"/api/mps/persist",
|
|
json={"draft": draft, "createdBy": "SYSTEM"},
|
|
)
|
|
assert persist_resp.status_code == 200
|
|
persisted = persist_resp.json()
|
|
trace_id = persisted["envelope"]["traceId"]
|
|
assert persisted["planNode"]["layer"] == "L2"
|
|
assert persisted["planNode"]["createdBy"] == "USER"
|
|
assert persisted["draft"]["planId"] == persisted["planNode"]["planId"]
|
|
assert persisted["envelope"]["events"][0]["by"] == "alice"
|
|
assert persisted["envelope"]["draftSnapshot"] == persisted["draft"]
|
|
assert persisted["envelope"]["inputSummary"]["draftHash"] == draft["draftHash"]
|
|
|
|
traces_resp = client.get("/api/mps/traces")
|
|
assert traces_resp.status_code == 200
|
|
assert any(row["traceId"] == trace_id for row in traces_resp.json())
|
|
|
|
trace_resp = client.get(f"/api/mps/trace/{trace_id}")
|
|
assert trace_resp.status_code == 200
|
|
assert trace_resp.json()["events"][0]["event"] == "OPEN"
|
|
|
|
gap_resp = client.post(
|
|
"/api/mps/gap", json={"draft": draft, "traceId": trace_id, "track": "fixed"}
|
|
)
|
|
assert gap_resp.status_code == 200
|
|
report = gap_resp.json()
|
|
assert report["schedule"] is not None
|
|
assert report["gaps"] is not None
|
|
assert {row["key"] for row in report["rows"]} == {
|
|
"demandQty",
|
|
"loadRatio",
|
|
"feasibleRate",
|
|
"riskCount",
|
|
}
|
|
|
|
|
|
def test_mps_api_trace_404_and_gap_without_schedule(mps_api_client):
|
|
client, state, stores, _ensure_scope = mps_api_client
|
|
store = stores[(state["tenantUuid"], state["worldKey"])]
|
|
assert client.get("/api/mps/trace/TR-NOPE").status_code == 404
|
|
|
|
store.data["scheduleVersions"] = []
|
|
store.data["productionOrders"] = []
|
|
draft_resp = client.post("/api/mps/draft", json={"mode": "WEEK", "horizonDays": 30})
|
|
draft = draft_resp.json()["draft"]
|
|
gap_resp = client.post("/api/mps/gap", json={"draft": draft, "track": "fixed"})
|
|
assert gap_resp.status_code == 200
|
|
assert gap_resp.json()["schedule"] is None
|
|
assert gap_resp.json()["rows"] == []
|
|
|
|
|
|
def test_mps_trace_store_rejects_cross_scope_reads(tmp_path):
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
owner_store = _trace_store(tmp_path / "shared.json")
|
|
persisted = persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=PlanStore(str(tmp_path / "plans.json")),
|
|
trace_store=owner_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
trace_id = persisted["envelope"]["traceId"]
|
|
|
|
other_tenant = _trace_store(
|
|
tmp_path / "shared.json",
|
|
tenant_uuid="tenant-b",
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
assert other_tenant.list() == []
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
other_tenant.get(trace_id)
|
|
with pytest.raises(ValueError, match="store scope"):
|
|
persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=PlanStore(str(tmp_path / "other-plans.json")),
|
|
trace_store=other_tenant,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
other_project = _trace_store(
|
|
tmp_path / "shared.json",
|
|
project_id="project-b",
|
|
world_key="project-b",
|
|
)
|
|
assert other_project.list() == []
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
other_project.get(trace_id)
|
|
|
|
|
|
def test_mps_api_trace_isolated_by_authenticated_tenant_and_project(mps_api_client):
|
|
client, state, _stores, ensure_scope = mps_api_client
|
|
draft = client.post("/api/mps/draft", json={"horizonDays": 30}).json()["draft"]
|
|
persisted = client.post("/api/mps/persist", json={"draft": draft}).json()
|
|
trace_id = persisted["envelope"]["traceId"]
|
|
|
|
state.update(
|
|
{"tenantUuid": "tenant-b", "projectId": PROJECT, "worldKey": WORLD_KEY}
|
|
)
|
|
ensure_scope("tenant-b", PROJECT, WORLD_KEY)
|
|
assert client.get("/api/mps/traces").json() == []
|
|
assert client.get(f"/api/mps/trace/{trace_id}").status_code == 404
|
|
assert client.post("/api/mps/persist", json={"draft": draft}).status_code == 409
|
|
|
|
state.update(
|
|
{"tenantUuid": TENANT, "projectId": "project-b", "worldKey": "project-b"}
|
|
)
|
|
ensure_scope(TENANT, "project-b", "project-b")
|
|
assert client.get("/api/mps/traces").json() == []
|
|
assert client.get(f"/api/mps/trace/{trace_id}").status_code == 404
|
|
assert client.post("/api/mps/persist", json={"draft": draft}).status_code == 409
|
|
|
|
state.update({"tenantUuid": TENANT, "projectId": PROJECT, "worldKey": WORLD_KEY})
|
|
assert client.get(f"/api/mps/trace/{trace_id}").status_code == 200
|
|
|
|
|
|
def test_mps_api_gap_rejects_draft_and_world_version_drift(mps_api_client):
|
|
client, state, stores, _ensure_scope = mps_api_client
|
|
draft = client.post("/api/mps/draft", json={"horizonDays": 30}).json()["draft"]
|
|
persisted = client.post("/api/mps/persist", json={"draft": draft}).json()
|
|
trace_id = persisted["envelope"]["traceId"]
|
|
|
|
tampered = copy.deepcopy(draft)
|
|
tampered["summary"]["weightedDemand"] += 1
|
|
tampered_resp = client.post(
|
|
"/api/mps/gap",
|
|
json={"draft": tampered, "traceId": trace_id, "track": "fixed"},
|
|
)
|
|
assert tampered_resp.status_code == 409
|
|
assert "hash" in str(tampered_resp.json()["detail"]).lower()
|
|
|
|
world = stores[(state["tenantUuid"], state["worldKey"])].data
|
|
world["lines"][0]["capacityPerDay"] += 1
|
|
stale_resp = client.post(
|
|
"/api/mps/gap",
|
|
json={"traceId": trace_id, "track": "fixed"},
|
|
)
|
|
assert stale_resp.status_code == 409
|
|
assert "fingerprint" in str(stale_resp.json()["detail"]).lower()
|
|
|
|
|
|
def test_mps_persist_does_not_leave_plan_when_trace_write_fails(monkeypatch, tmp_path):
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
plan_store = PlanStore(str(tmp_path / "plans.json"))
|
|
trace_store = _trace_store(tmp_path / "traces.json")
|
|
|
|
def fail_trace_write():
|
|
raise OSError("injected trace write failure")
|
|
|
|
monkeypatch.setattr(trace_store, "_write", fail_trace_write)
|
|
with pytest.raises(OSError, match="injected trace write failure"):
|
|
persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=plan_store,
|
|
trace_store=trace_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
assert plan_store._plans == {}
|
|
assert trace_store.list() == []
|
|
|
|
|
|
def test_mps_persist_discards_trace_when_l2_plan_write_fails(monkeypatch, tmp_path):
|
|
world = seed_world()
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
plan_store = PlanStore(str(tmp_path / "plans.json"))
|
|
trace_store = _trace_store(tmp_path / "traces.json")
|
|
original_create = plan_store.create
|
|
|
|
def fail_l2_create(**kwargs):
|
|
if kwargs.get("layer") == "L2":
|
|
raise OSError("injected L2 write failure")
|
|
return original_create(**kwargs)
|
|
|
|
monkeypatch.setattr(plan_store, "create", fail_l2_create)
|
|
with pytest.raises(OSError, match="injected L2 write failure"):
|
|
persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=plan_store,
|
|
trace_store=trace_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
|
|
assert set(plan_store._plans) == {"intent-mps", "strategy-mps"}
|
|
assert trace_store.list() == []
|
|
|
|
|
|
def _append_fixed_schedule(
|
|
world: dict,
|
|
*,
|
|
version_id: int = 42,
|
|
version_no: str = "V-LOCAL-42",
|
|
status: str = "DRAFT",
|
|
) -> dict:
|
|
version = {
|
|
"id": version_id,
|
|
"versionNo": version_no,
|
|
"status": status,
|
|
"engineType": "RULE",
|
|
"note": "strategy=DELIVERY_FIRST",
|
|
"orderCount": 1,
|
|
"poCount": 1,
|
|
"woCount": 1,
|
|
"conflictCount": 0,
|
|
"totalTardiness": 0.0,
|
|
"avgUtilization": 0.75,
|
|
"createdAt": "2026-08-17 10:00:00",
|
|
"publishedAt": None,
|
|
}
|
|
world.setdefault("scheduleVersions", []).append(version)
|
|
production_order_id = version_id * 10
|
|
world.setdefault("productionOrders", []).append(
|
|
{
|
|
"id": production_order_id,
|
|
"schedulingVersionId": version_id,
|
|
"quantity": 100,
|
|
"status": "DRAFT",
|
|
"plannedStartDate": "2026-08-18 08:00:00",
|
|
"plannedEndDate": "2026-08-18 12:00:00",
|
|
}
|
|
)
|
|
world.setdefault("workOrders", []).append(
|
|
{
|
|
"id": version_id * 100,
|
|
"productionOrderId": production_order_id,
|
|
"quantity": 100,
|
|
"plannedStartTime": "2026-08-18 08:00:00",
|
|
"plannedEndTime": "2026-08-18 12:00:00",
|
|
"status": "PENDING",
|
|
}
|
|
)
|
|
return version
|
|
|
|
|
|
def _append_flex_schedule(
|
|
world: dict,
|
|
*,
|
|
version_id: int = 42,
|
|
version_no: str = "FV-LOCAL-42",
|
|
) -> dict:
|
|
version = {
|
|
"id": version_id,
|
|
"versionNo": version_no,
|
|
"status": "DRAFT",
|
|
"engineType": "FLEX",
|
|
"sortMode": "SKILL_FIRST",
|
|
"orderCount": 1,
|
|
"woCount": 1,
|
|
"conflictCount": 0,
|
|
"totalTardiness": 0.0,
|
|
"avgUtilization": 0.7,
|
|
"createdAt": "2026-08-17 10:01:00",
|
|
}
|
|
world.setdefault("flexScheduleVersions", []).append(version)
|
|
world.setdefault("flexVirtualLines", []).append(
|
|
{
|
|
"id": version_id * 10,
|
|
"versionId": version_id,
|
|
"quantity": 100,
|
|
"plannedStart": "2026-08-18 08:00:00",
|
|
"plannedEnd": "2026-08-18 12:00:00",
|
|
}
|
|
)
|
|
world.setdefault("flexWorkOrders", []).append(
|
|
{
|
|
"id": version_id * 100,
|
|
"versionId": version_id,
|
|
"quantity": 100,
|
|
"plannedStartTime": "2026-08-18 08:00:00",
|
|
"plannedEndTime": "2026-08-18 12:00:00",
|
|
"status": "PENDING",
|
|
}
|
|
)
|
|
return version
|
|
|
|
|
|
def _persist_trace_before_schedule(tmp_path, world: dict):
|
|
draft = _bound_draft(world, build_mps_draft(world, horizon_days=30))
|
|
trace_store = _trace_store(tmp_path / "local-link-traces.json")
|
|
persisted = persist_mps_draft(
|
|
draft,
|
|
world=world,
|
|
plan_store=PlanStore(str(tmp_path / "local-link-plans.json")),
|
|
trace_store=trace_store,
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
)
|
|
return persisted, trace_store
|
|
|
|
|
|
def test_mps_local_adopt_link_is_exact_atomic_idempotent_and_gap_replays(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
world = seed_world()
|
|
persisted, trace_store = _persist_trace_before_schedule(tmp_path, world)
|
|
version = _append_fixed_schedule(world)
|
|
summary = next(
|
|
row
|
|
for row in list_mps_schedule_versions(world)
|
|
if row["track"] == "fixed" and row["scheduleId"] == version["id"]
|
|
)
|
|
mes_called = False
|
|
|
|
from server.aps_domain import mes
|
|
|
|
def reject_mes_dispatch(*_args, **_kwargs):
|
|
nonlocal mes_called
|
|
mes_called = True
|
|
raise AssertionError("MPS local link must not call MES dispatch")
|
|
|
|
monkeypatch.setattr(mes, "stage_dispatch", reject_mes_dispatch)
|
|
envelope = persisted["envelope"]
|
|
linked = link_mps_trace_local_fact(
|
|
world,
|
|
trace_store=trace_store,
|
|
trace_id=envelope["traceId"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
draft_hash=envelope["draftHash"],
|
|
world_fingerprint=envelope["worldFingerprint"],
|
|
schedule_id=summary["scheduleId"],
|
|
schedule_version_no=summary["scheduleVersionNo"],
|
|
schedule_fingerprint=summary["scheduleFingerprint"],
|
|
track=summary["track"],
|
|
fact_type="ADOPT",
|
|
actor="planner",
|
|
)
|
|
|
|
assert linked["idempotent"] is False
|
|
assert linked["trace"]["scheduleId"] == version["id"]
|
|
assert linked["trace"]["scheduleVersionNo"] == version["versionNo"]
|
|
assert linked["trace"]["scheduleTrack"] == "fixed"
|
|
assert linked["trace"]["scheduleFingerprint"] == summary["scheduleFingerprint"]
|
|
assert linked["trace"]["adoptId"] == "ADOPT:fixed:V-LOCAL-42"
|
|
assert [event["event"] for event in linked["trace"]["events"]] == [
|
|
"OPEN",
|
|
"SCHEDULE",
|
|
"ADOPT",
|
|
]
|
|
assert mes_called is False
|
|
|
|
retried = link_mps_trace_local_fact(
|
|
world,
|
|
trace_store=trace_store,
|
|
trace_id=envelope["traceId"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
draft_hash=envelope["draftHash"],
|
|
world_fingerprint=envelope["worldFingerprint"],
|
|
schedule_id=summary["scheduleId"],
|
|
schedule_version_no=summary["scheduleVersionNo"],
|
|
schedule_fingerprint=summary["scheduleFingerprint"],
|
|
track=summary["track"],
|
|
fact_type="ADOPT",
|
|
actor="planner",
|
|
)
|
|
assert retried["idempotent"] is True
|
|
assert len(retried["trace"]["events"]) == 3
|
|
|
|
report = compare_mps_schedule_gap(
|
|
world,
|
|
persisted["draft"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
track="auto",
|
|
trace=retried["trace"],
|
|
)
|
|
assert report["schedule"]["versionNo"] == version["versionNo"]
|
|
assert report["trace"]["scheduleFingerprint"] == summary["scheduleFingerprint"]
|
|
assert report["trace"]["adoptId"] == "ADOPT:fixed:V-LOCAL-42"
|
|
|
|
|
|
def test_mps_local_link_rejects_conflict_track_schedule_and_world_drift(tmp_path):
|
|
world = seed_world()
|
|
persisted, trace_store = _persist_trace_before_schedule(tmp_path, world)
|
|
fixed = _append_fixed_schedule(world)
|
|
fixed_summary = next(
|
|
row for row in list_mps_schedule_versions(world) if row["track"] == "fixed"
|
|
)
|
|
envelope = persisted["envelope"]
|
|
linked = link_mps_trace_local_fact(
|
|
world,
|
|
trace_store=trace_store,
|
|
trace_id=envelope["traceId"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
draft_hash=envelope["draftHash"],
|
|
world_fingerprint=envelope["worldFingerprint"],
|
|
schedule_id=fixed_summary["scheduleId"],
|
|
schedule_version_no=fixed_summary["scheduleVersionNo"],
|
|
schedule_fingerprint=fixed_summary["scheduleFingerprint"],
|
|
track="fixed",
|
|
fact_type="ADOPT",
|
|
)
|
|
before_conflict = copy.deepcopy(linked["trace"])
|
|
|
|
flex = _append_flex_schedule(world, version_id=fixed["id"])
|
|
flex_summary = next(
|
|
row
|
|
for row in list_mps_schedule_versions(world)
|
|
if row["track"] == "flex" and row["scheduleId"] == flex["id"]
|
|
)
|
|
with pytest.raises(ValueError, match="different schedule"):
|
|
link_mps_trace_local_fact(
|
|
world,
|
|
trace_store=trace_store,
|
|
trace_id=envelope["traceId"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
draft_hash=envelope["draftHash"],
|
|
world_fingerprint=envelope["worldFingerprint"],
|
|
schedule_id=flex_summary["scheduleId"],
|
|
schedule_version_no=flex_summary["scheduleVersionNo"],
|
|
schedule_fingerprint=flex_summary["scheduleFingerprint"],
|
|
track="flex",
|
|
fact_type="ADOPT",
|
|
)
|
|
assert trace_store.get(envelope["traceId"]) == before_conflict
|
|
|
|
with pytest.raises(ValueError, match="track"):
|
|
compare_mps_schedule_gap(
|
|
world,
|
|
persisted["draft"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
track="flex",
|
|
trace=before_conflict,
|
|
)
|
|
|
|
world["workOrders"][-1]["plannedEndTime"] = "2026-08-18 13:00:00"
|
|
with pytest.raises(ValueError, match="schedule fingerprint"):
|
|
compare_mps_schedule_gap(
|
|
world,
|
|
persisted["draft"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
track="auto",
|
|
trace=before_conflict,
|
|
)
|
|
|
|
world["workOrders"][-1]["plannedEndTime"] = "2026-08-18 12:00:00"
|
|
world["lines"][0]["capacityPerDay"] += 1
|
|
with pytest.raises(ValueError, match="world fingerprint"):
|
|
compare_mps_schedule_gap(
|
|
world,
|
|
persisted["draft"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
track="auto",
|
|
trace=before_conflict,
|
|
)
|
|
|
|
|
|
def test_mps_local_publish_fact_requires_published_version_and_keeps_adopt(tmp_path):
|
|
world = seed_world()
|
|
persisted, trace_store = _persist_trace_before_schedule(tmp_path, world)
|
|
version = _append_fixed_schedule(world)
|
|
summary = next(
|
|
row for row in list_mps_schedule_versions(world) if row["track"] == "fixed"
|
|
)
|
|
envelope = persisted["envelope"]
|
|
request = {
|
|
"world": world,
|
|
"trace_store": trace_store,
|
|
"trace_id": envelope["traceId"],
|
|
"tenant_uuid": TENANT,
|
|
"project_id": PROJECT,
|
|
"world_key": WORLD_KEY,
|
|
"draft_hash": envelope["draftHash"],
|
|
"world_fingerprint": envelope["worldFingerprint"],
|
|
"schedule_id": summary["scheduleId"],
|
|
"schedule_version_no": summary["scheduleVersionNo"],
|
|
"schedule_fingerprint": summary["scheduleFingerprint"],
|
|
"track": "fixed",
|
|
}
|
|
with pytest.raises(ValueError, match="published local version"):
|
|
link_mps_trace_local_fact(**request, fact_type="PUBLISH")
|
|
assert trace_store.get(envelope["traceId"])["scheduleId"] is None
|
|
|
|
adopted = link_mps_trace_local_fact(**request, fact_type="ADOPT")
|
|
version["status"] = "PUBLISHED"
|
|
version["publishedAt"] = "2026-08-17 11:00:00"
|
|
published = link_mps_trace_local_fact(**request, fact_type="PUBLISH")
|
|
assert published["trace"]["adoptId"] == adopted["trace"]["adoptId"]
|
|
assert published["trace"]["publishId"] == "PUBLISH:fixed:V-LOCAL-42"
|
|
assert published["trace"]["publishedAt"] == version["publishedAt"]
|
|
assert [event["event"] for event in published["trace"]["events"]] == [
|
|
"OPEN",
|
|
"SCHEDULE",
|
|
"ADOPT",
|
|
"PUBLISH",
|
|
]
|
|
retried = link_mps_trace_local_fact(**request, fact_type="PUBLISH")
|
|
assert retried["idempotent"] is True
|
|
assert len(retried["trace"]["events"]) == 4
|
|
|
|
|
|
def test_mps_local_link_write_failure_rolls_back_all_fields(tmp_path, monkeypatch):
|
|
world = seed_world()
|
|
persisted, trace_store = _persist_trace_before_schedule(tmp_path, world)
|
|
_append_fixed_schedule(world)
|
|
summary = next(
|
|
row for row in list_mps_schedule_versions(world) if row["track"] == "fixed"
|
|
)
|
|
envelope = persisted["envelope"]
|
|
before = trace_store.get(envelope["traceId"])
|
|
|
|
def fail_write():
|
|
raise OSError("injected local-link write failure")
|
|
|
|
monkeypatch.setattr(trace_store, "_write", fail_write)
|
|
with pytest.raises(OSError, match="injected local-link write failure"):
|
|
link_mps_trace_local_fact(
|
|
world,
|
|
trace_store=trace_store,
|
|
trace_id=envelope["traceId"],
|
|
tenant_uuid=TENANT,
|
|
project_id=PROJECT,
|
|
world_key=WORLD_KEY,
|
|
draft_hash=envelope["draftHash"],
|
|
world_fingerprint=envelope["worldFingerprint"],
|
|
schedule_id=summary["scheduleId"],
|
|
schedule_version_no=summary["scheduleVersionNo"],
|
|
schedule_fingerprint=summary["scheduleFingerprint"],
|
|
track="fixed",
|
|
fact_type="ADOPT",
|
|
)
|
|
assert trace_store.get(envelope["traceId"]) == before
|
|
|
|
|
|
def test_mps_api_lists_and_links_exact_local_version_with_scope_isolation(
|
|
mps_api_client,
|
|
):
|
|
client, state, _stores, ensure_scope = mps_api_client
|
|
draft = client.post("/api/mps/draft", json={"horizonDays": 30}).json()["draft"]
|
|
persisted = client.post("/api/mps/persist", json={"draft": draft}).json()
|
|
trace = persisted["envelope"]
|
|
|
|
versions_resp = client.get("/api/mps/schedule-versions")
|
|
assert versions_resp.status_code == 200
|
|
fixed = next(row for row in versions_resp.json() if row["track"] == "fixed")
|
|
payload = {
|
|
"draftHash": trace["draftHash"],
|
|
"worldFingerprint": trace["worldFingerprint"],
|
|
"scheduleId": fixed["scheduleId"],
|
|
"scheduleVersionNo": fixed["scheduleVersionNo"],
|
|
"scheduleFingerprint": fixed["scheduleFingerprint"],
|
|
"track": "fixed",
|
|
"factType": "ADOPT",
|
|
}
|
|
linked_resp = client.post(
|
|
f"/api/mps/trace/{trace['traceId']}/link",
|
|
json=payload,
|
|
)
|
|
assert linked_resp.status_code == 200
|
|
linked = linked_resp.json()
|
|
assert linked["idempotent"] is False
|
|
assert linked["trace"]["scheduleId"] == fixed["scheduleId"]
|
|
assert linked["trace"]["adoptId"].startswith("ADOPT:fixed:")
|
|
|
|
retry_resp = client.post(
|
|
f"/api/mps/trace/{trace['traceId']}/link",
|
|
json=payload,
|
|
)
|
|
assert retry_resp.status_code == 200
|
|
assert retry_resp.json()["idempotent"] is True
|
|
assert len(retry_resp.json()["trace"]["events"]) == 3
|
|
|
|
conflict_resp = client.post(
|
|
f"/api/mps/trace/{trace['traceId']}/link",
|
|
json={**payload, "factId": "ADOPT:fixed:conflicting-choice"},
|
|
)
|
|
assert conflict_resp.status_code == 409
|
|
assert len(client.get(f"/api/mps/trace/{trace['traceId']}").json()["events"]) == 3
|
|
|
|
gap_resp = client.post(
|
|
"/api/mps/gap",
|
|
json={"traceId": trace["traceId"], "track": "auto"},
|
|
)
|
|
assert gap_resp.status_code == 200
|
|
assert gap_resp.json()["schedule"]["versionNo"] == fixed["scheduleVersionNo"]
|
|
assert gap_resp.json()["trace"]["scheduleFingerprint"] == fixed[
|
|
"scheduleFingerprint"
|
|
]
|
|
|
|
state.update(
|
|
{"tenantUuid": "tenant-b", "projectId": PROJECT, "worldKey": WORLD_KEY}
|
|
)
|
|
ensure_scope("tenant-b", PROJECT, WORLD_KEY)
|
|
cross_scope = client.post(
|
|
f"/api/mps/trace/{trace['traceId']}/link",
|
|
json=payload,
|
|
)
|
|
assert cross_scope.status_code == 404
|