STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_config_wholesale_replacement.py
Go to the documentation of this file.
1"""Every route that swaps the session config wholesale does it the way /api/config/upload does.
2
3That is: nothing changes on a refusal, the design checkout is asked, set_config runs off the event
4loop, and the session's ``load_token`` is new and handed back. The token is how a second tab of
5the same user (one session per user, shared by every tab) notices the design it loaded is gone and
6stops autosaving; a swap that kept the old token let that tab write the newcomer over its design.
7
8Found on the LE4 (3) stand's engine YAML (no ``pressure_curves``): POST /api/timeseries/from-csv
9answered 400 "does not contain a 'pressure_curves' section" *after* replacing the config, the UI
10kept showing the old design, and the design bar autosaved the "failed" upload into it. The Layer 2
11and Layer 3 upload-config routes swapped the config with no token and no checkout.
12"""
13
14from __future__ import annotations
15
16import asyncio
17import io
18import os
19import sys
20import threading
21
22import pytest
23import yaml
24
25sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
27pytest.importorskip("fastapi", reason="API tests need fastapi")
28pytest.importorskip("httpx", reason="fastapi TestClient needs httpx")
29
30from fastapi.testclient import TestClient # noqa: E402
31from starlette.datastructures import Headers, UploadFile # noqa: E402
32from starlette.requests import Request # noqa: E402
33
34import backend.main as backend_main # noqa: E402
35from backend import session as session_mod # noqa: E402
36from backend import state as state_mod # noqa: E402
37from backend.routers import timeseries as timeseries_router # noqa: E402
38
39DEFAULT = yaml.safe_load(session_mod._DEFAULT_CONFIG_PATH.read_text())
40
41
42def _without_curves() -> bytes:
43 data = dict(DEFAULT)
44 data["pressure_curves"] = None
45 # A different design from the default, so a swap is visible in GET /api/config.
46 data["chamber_geometry"] = {**data["chamber_geometry"], "design_thrust": 6500.0}
47 return yaml.safe_dump(data).encode()
48
49
50def _with_curves() -> bytes:
51 data = dict(DEFAULT)
52 data["chamber_geometry"] = {**data["chamber_geometry"], "design_thrust": 6400.0}
53 return yaml.safe_dump(data).encode()
54
55
56@pytest.fixture(autouse=True)
57def _isolate(tmp_path, monkeypatch):
58 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
59 monkeypatch.setattr(backend_main, "project_root", tmp_path)
60
61
62@pytest.fixture
63def client():
64 return TestClient(backend_main.app, raise_server_exceptions=False)
65
66
67def _state(client, headers):
68 body = client.get("/api/config", headers=headers).json()
69 return body["config"]["chamber_geometry"]["design_thrust"], body.get("load_token")
70
71
73 h = {"X-Auth-Email": "wholesale-nocurves@berkeley.edu"}
74 before = _state(client, h)
75 r = client.post(
76 "/api/timeseries/from-csv",
77 files={"file": ("engine.yaml", _without_curves(), "application/x-yaml")},
78 headers=h,
79 )
80 assert r.status_code == 400, r.text[:300]
81 assert "pressure_curves" in r.json()["detail"]
82 assert _state(client, h) == before, (
83 "a from-csv request answered 400 had already replaced the session config"
84 )
85
86
88 h = {"X-Auth-Email": "wholesale-checkout@berkeley.edu"}
89 r = client.post(
90 "/api/engine/documents", headers=h, json={"name": "Held", "config": {"combustion": {}}}
91 )
92 doc_id = r.json()["id"]
93 assert client.delete(f"/api/engine/documents/{doc_id}/checkout", headers=h).status_code == 200
94 ran: list = []
95 monkeypatch.setattr(timeseries_router, "compute_timeseries_results", lambda *a, **k: ran.append(1))
96 before = _state(client, h)
97 r = client.post(
98 "/api/timeseries/from-csv",
99 files={"file": ("engine.yaml", _with_curves(), "application/x-yaml")},
100 headers={**h, "X-Design-Id": doc_id},
101 )
102 assert r.status_code == 423, r.text[:300]
103 assert _state(client, h) == before and not ran
104
105
107 h = {"X-Auth-Email": "wholesale-curves@berkeley.edu"}
108 monkeypatch.setattr(
109 timeseries_router, "compute_timeseries_results", lambda *a, **k: ({"time": [0.0]}, {})
110 )
111 _, token_before = _state(client, h)
112 r = client.post(
113 "/api/timeseries/from-csv",
114 files={"file": ("engine.yaml", _with_curves(), "application/x-yaml")},
115 headers=h,
116 )
117 assert r.status_code == 200, r.text[:300]
118 thrust, token = _state(client, h)
119 assert thrust == 6400.0
120 assert token and token != token_before, "from-csv replaced the config without a new load_token"
121 assert r.json()["load_token"] == token
122
123
124@pytest.mark.parametrize("layer", ["layer2", "layer3"])
126 h = {"X-Auth-Email": f"wholesale-{layer}@berkeley.edu"}
127 _, token_before = _state(client, h)
128 r = client.post(
129 f"/api/optimizer/{layer}/upload-config",
130 files={"file": ("engine.yaml", _with_curves(), "application/x-yaml")},
131 headers=h,
132 )
133 assert r.status_code == 200, r.text[:300]
134 thrust, token = _state(client, h)
135 assert thrust == 6400.0
136 assert token and token != token_before, f"{layer} upload-config replaced the config, same token"
137 assert r.json().get("load_token") == token
138
139
140@pytest.mark.parametrize("layer", ["layer2", "layer3"])
142 h = {"X-Auth-Email": f"wholesale-{layer}-checkout@berkeley.edu"}
143 r = client.post(
144 "/api/engine/documents", headers=h, json={"name": "Held", "config": {"combustion": {}}}
145 )
146 doc_id = r.json()["id"]
147 assert client.delete(f"/api/engine/documents/{doc_id}/checkout", headers=h).status_code == 200
148 before = _state(client, h)
149 r = client.post(
150 f"/api/optimizer/{layer}/upload-config",
151 files={"file": ("engine.yaml", _with_curves(), "application/x-yaml")},
152 headers={**h, "X-Design-Id": doc_id},
153 )
154 assert r.status_code == 423, r.text[:300]
155 assert _state(client, h) == before
156
157
158# --- Off the event loop -------------------------------------------------------------------------
159
160
161def _request(email: str) -> Request:
162 return Request(
163 {
164 "type": "http",
165 "method": "POST",
166 "path": "/",
167 "query_string": b"",
168 "headers": [(b"x-auth-email", email.encode())],
169 }
170 )
171
172
173def _upload(name: str, body: bytes) -> UploadFile:
174 return UploadFile(file=io.BytesIO(body), filename=name, headers=Headers({}))
175
176
178 seen: dict[str, int] = {}
179 real_set_config = state_mod.AppState.set_config
180
181 def spy_set_config(self, *a, **k):
182 seen["set_config"] = threading.get_ident()
183 return real_set_config(self, *a, **k)
184
185 def spy_compute(*_a, **_k):
186 seen.setdefault("compute", threading.get_ident())
187 return {"time": [0.0]}, {}
188
189 monkeypatch.setattr(state_mod.AppState, "set_config", spy_set_config)
190 monkeypatch.setattr(timeseries_router, "compute_timeseries_results", spy_compute)
191
192 async def main():
193 seen["loop"] = threading.get_ident()
194 req = _request("wholesale-offload@berkeley.edu")
195 session = session_mod.get_session(req)
196 csv = b"time,P_O,P_F\n0,500,450\n1,480,430\n"
197 await timeseries_router.generate_from_csv(req, file=_upload("p.csv", csv), session=session)
198 assert seen.pop("compute") != seen["loop"], "the CSV run went on the event-loop thread"
199 await timeseries_router.generate_from_csv(
200 req, file=_upload("engine.yaml", _with_curves()), session=session
201 )
202
203 asyncio.run(main())
204 assert seen["set_config"] != seen["loop"], "from-csv's YAML swap ran set_config on the loop"
205 assert seen["compute"] != seen["loop"], "from-csv's YAML run went on the event-loop thread"
206
207
209 """A controller run on an uploaded file is a simulation, not a load.
210
211 It used to set_config the uploaded engine for the length of the run (inline on the event loop)
212 and put the old one back after. Nothing in the run read it, but GET /api/config did: the design
213 bar's tick saw its own load token and autosaved the uploaded engine into the open design.
214 """
215 import engine.core.runner as runner_mod
216 from backend.routers import control as control_router
217
218 class FakeRunner:
219 def __init__(self, config):
220 pass
221
222 def evaluate(self, **_kwargs):
223 return {"F": 6800.0}
224
225 def no_controller(*_a, **_k):
226 raise RuntimeError("controller not under test")
227
228 swaps: list = []
229 real_set_config = state_mod.AppState.set_config
230
231 def spy(self, *a, **k):
232 swaps.append(a[0] if a else k.get("config"))
233 return real_set_config(self, *a, **k)
234
235 monkeypatch.setattr(runner_mod, "PintleEngineRunner", FakeRunner)
236 monkeypatch.setattr(control_router, "RobustDDPController", no_controller)
237 h = {"X-Auth-Email": "wholesale-ctrl-upload@berkeley.edu"}
238 before = _state(client, h)
239 session_mod.registry.get("wholesale-ctrl-upload@berkeley.edu").controller.controller = None
240 monkeypatch.setattr(state_mod.AppState, "set_config", spy)
241 r = client.post(
242 "/api/control/upload-config-and-simulate",
243 files={"file": ("engine.yaml", _with_curves(), "application/x-yaml")},
244 headers=h,
245 )
246 assert r.status_code == 200
247 assert "controller not under test" in r.text, "the run did not get as far as the controller"
248 assert swaps == [], f"the run put {len(swaps)} config(s) into the session"
249 assert _state(client, h) == before
a class to store JSON values
Definition json.hpp:19401
test_upload_config_and_simulate_never_swaps_the_session_config(client, monkeypatch)
test_a_yaml_from_csv_that_replaces_the_config_returns_a_new_token(client, monkeypatch)
test_a_yaml_from_csv_needs_the_checkout_and_changes_nothing_without_it(client, monkeypatch)