1"""Every route that swaps the session config wholesale does it the way /api/config/upload does.
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.
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.
14from __future__
import annotations
25sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27pytest.importorskip(
"fastapi", reason=
"API tests need fastapi")
28pytest.importorskip(
"httpx", reason=
"fastapi TestClient needs httpx")
30from fastapi.testclient
import TestClient
31from starlette.datastructures
import Headers, UploadFile
32from starlette.requests
import Request
35from backend
import session
as session_mod
36from backend
import state
as state_mod
39DEFAULT = yaml.safe_load(session_mod._DEFAULT_CONFIG_PATH.read_text())
44 data[
"pressure_curves"] =
None
46 data[
"chamber_geometry"] = {**data[
"chamber_geometry"],
"design_thrust": 6500.0}
47 return yaml.safe_dump(data).encode()
52 data[
"chamber_geometry"] = {**data[
"chamber_geometry"],
"design_thrust": 6400.0}
53 return yaml.safe_dump(data).encode()
56@pytest.fixture(autouse=True)
58 monkeypatch.setenv(
"USERDATA_DIR", str(tmp_path))
59 monkeypatch.setattr(backend_main,
"project_root", tmp_path)
64 return TestClient(backend_main.app, raise_server_exceptions=
False)
68 body = client.get(
"/api/config", headers=headers).
json()
69 return body[
"config"][
"chamber_geometry"][
"design_thrust"], body.get(
"load_token")
73 h = {
"X-Auth-Email":
"wholesale-nocurves@berkeley.edu"}
76 "/api/timeseries/from-csv",
77 files={
"file": (
"engine.yaml",
_without_curves(),
"application/x-yaml")},
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"
88 h = {
"X-Auth-Email":
"wholesale-checkout@berkeley.edu"}
90 "/api/engine/documents", headers=h, json={
"name":
"Held",
"config": {
"combustion": {}}}
92 doc_id = r.json()[
"id"]
93 assert client.delete(f
"/api/engine/documents/{doc_id}/checkout", headers=h).status_code == 200
95 monkeypatch.setattr(timeseries_router,
"compute_timeseries_results",
lambda *a, **k: ran.append(1))
98 "/api/timeseries/from-csv",
99 files={
"file": (
"engine.yaml",
_with_curves(),
"application/x-yaml")},
100 headers={**h,
"X-Design-Id": doc_id},
102 assert r.status_code == 423, r.text[:300]
103 assert _state(client, h) == before
and not ran
107 h = {
"X-Auth-Email":
"wholesale-curves@berkeley.edu"}
109 timeseries_router,
"compute_timeseries_results",
lambda *a, **k: ({
"time": [0.0]}, {})
111 _, token_before =
_state(client, h)
113 "/api/timeseries/from-csv",
114 files={
"file": (
"engine.yaml",
_with_curves(),
"application/x-yaml")},
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
124@pytest.mark.parametrize("layer", ["layer2", "layer3"])
126 h = {
"X-Auth-Email": f
"wholesale-{layer}@berkeley.edu"}
127 _, token_before =
_state(client, h)
129 f
"/api/optimizer/{layer}/upload-config",
130 files={
"file": (
"engine.yaml",
_with_curves(),
"application/x-yaml")},
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
140@pytest.mark.parametrize("layer", ["layer2", "layer3"])
142 h = {
"X-Auth-Email": f
"wholesale-{layer}-checkout@berkeley.edu"}
144 "/api/engine/documents", headers=h, json={
"name":
"Held",
"config": {
"combustion": {}}}
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)
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},
154 assert r.status_code == 423, r.text[:300]
155 assert _state(client, h) == before
168 "headers": [(b
"x-auth-email", email.encode())],
173def _upload(name: str, body: bytes) -> UploadFile:
174 return UploadFile(file=io.BytesIO(body), filename=name, headers=Headers({}))
178 seen: dict[str, int] = {}
179 real_set_config = state_mod.AppState.set_config
181 def spy_set_config(self, *a, **k):
182 seen[
"set_config"] = threading.get_ident()
183 return real_set_config(self, *a, **k)
185 def spy_compute(*_a, **_k):
186 seen.setdefault(
"compute", threading.get_ident())
187 return {
"time": [0.0]}, {}
189 monkeypatch.setattr(state_mod.AppState,
"set_config", spy_set_config)
190 monkeypatch.setattr(timeseries_router,
"compute_timeseries_results", spy_compute)
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(
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"
209 """A controller run on an uploaded file is a simulation, not a load.
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.
219 def __init__(self, config):
222 def evaluate(self, **_kwargs):
225 def no_controller(*_a, **_k):
226 raise RuntimeError(
"controller not under test")
229 real_set_config = state_mod.AppState.set_config
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)
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)
242 "/api/control/upload-config-and-simulate",
243 files={
"file": (
"engine.yaml",
_with_curves(),
"application/x-yaml")},
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
test_a_yaml_without_curves_is_refused_and_changes_nothing(client)
test_layer_upload_config_needs_the_checkout(client, layer)
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_layer_upload_config_rotates_and_returns_the_token(client, layer)
test_a_yaml_from_csv_needs_the_checkout_and_changes_nothing_without_it(client, monkeypatch)
UploadFile _upload(str name, bytes body)
Request _request(str email)
test_from_csv_runs_its_solve_and_its_swap_off_the_event_loop(monkeypatch)
_isolate(tmp_path, monkeypatch)