@@ -34,8 +34,25 @@ def client(fake_engine):
3434 yield c
3535
3636
37+ @pytest .fixture
38+ def dist_dir (tmp_path ):
39+ """A minimal stand-in for a built frontend (feature_explorer/dist) — index + one asset."""
40+ d = tmp_path / "dist"
41+ (d / "assets" ).mkdir (parents = True )
42+ (d / "index.html" ).write_text ('<!doctype html><html><body><div id="root"></div></body></html>' )
43+ (d / "assets" / "app.js" ).write_text ("console.log('dashboard')" )
44+ return d
45+
46+
47+ @pytest .fixture
48+ def static_client (fake_engine , dist_dir ):
49+ """A client for the single-container shape: API under /api + the frontend served at /."""
50+ with TestClient (build_app (fake_engine , static_dir = str (dist_dir ))) as c :
51+ yield c
52+
53+
3754def test_health (client ):
38- r = client .get ("/health" )
55+ r = client .get ("/api/ health" )
3956 assert r .status_code == 200 # 200 only when ready
4057 b = r .json ()
4158 assert b ["ready" ] is True and b ["layer" ] == 19
@@ -44,72 +61,74 @@ def test_health(client):
4461
4562def test_annotate_rejects_too_long (client , fake_engine ):
4663 seq = "A" * (fake_engine .max_seq_len + 1 ) # exceeds the context budget
47- assert client .post ("/annotate" , json = {"sequence" : seq }).status_code == 413
64+ assert client .post ("/api/ annotate" , json = {"sequence" : seq }).status_code == 413
4865
4966
5067def test_features (client ):
51- rows = client .get ("/features" ).json ()
68+ rows = client .get ("/api/ features" ).json ()
5269 assert {"id" , "label" , "natural_peak" } <= set (rows [0 ])
5370
5471
5572def test_annotate_returns_per_base_activations (client ):
56- b = client .post ("/annotate" , json = {"sequence" : "ACGTACGT" , "organism" : "None (raw DNA)" }).json ()
73+ b = client .post ("/api/ annotate" , json = {"sequence" : "ACGTACGT" , "organism" : "None (raw DNA)" }).json ()
5774 assert {"sequence" , "features" , "bases" , "tag_len" , "layer" , "n_tokens" } <= set (b )
5875 assert b ["features" ][0 ]["activations" ] # the per-base track the viz plots
5976
6077
6178def test_annotate_rejects_non_dna (client ):
62- assert client .post ("/annotate" , json = {"sequence" : "ZZZZ" }).status_code == 400
79+ assert client .post ("/api/ annotate" , json = {"sequence" : "ZZZZ" }).status_code == 400
6380
6481
6582def test_annotate_pick_mode (client ):
66- b = client .post ("/annotate" , json = {"sequence" : "ACGTACGT" , "mode" : "pick" , "feature_ids" : [1 ]}).json ()
83+ b = client .post ("/api/ annotate" , json = {"sequence" : "ACGTACGT" , "mode" : "pick" , "feature_ids" : [1 ]}).json ()
6784 assert [f ["feature_id" ] for f in b ["features" ]] == [1 ]
6885 assert b ["features" ][0 ]["activations" ] # per-base track returned for the picked feature
6986
7087
7188def test_annotate_pick_requires_ids (client ):
72- assert client .post ("/annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" }).status_code == 400
89+ assert client .post ("/api/ annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" }).status_code == 400
7390
7491
7592def test_annotate_pick_rejects_out_of_range_id (client , fake_engine ):
7693 # user-supplied pick ids: an over-range id must 400 (not 500/IndexError), a negative one must
7794 # 400 (not silently index the wrong feature via torch negative-indexing).
78- over = client .post ("/annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" , "feature_ids" : [fake_engine .n_features ]})
95+ over = client .post (
96+ "/api/annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" , "feature_ids" : [fake_engine .n_features ]}
97+ )
7998 assert over .status_code == 400
80- neg = client .post ("/annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" , "feature_ids" : [- 1 ]})
99+ neg = client .post ("/api/ annotate" , json = {"sequence" : "ACGT" , "mode" : "pick" , "feature_ids" : [- 1 ]})
81100 assert neg .status_code == 400
82101
83102
84103def test_annotate_rejects_invalid_mode (client ):
85- assert client .post ("/annotate" , json = {"sequence" : "ACGT" , "mode" : "bogus" }).status_code == 400
104+ assert client .post ("/api/ annotate" , json = {"sequence" : "ACGT" , "mode" : "bogus" }).status_code == 400
86105
87106
88107def test_annotate_clamps_k_into_range (client , fake_engine ):
89- client .post ("/annotate" , json = {"sequence" : "ACGT" , "k" : 999 })
108+ client .post ("/api/ annotate" , json = {"sequence" : "ACGT" , "k" : 999 })
90109 assert fake_engine .last_k == 64 # upper bound
91- client .post ("/annotate" , json = {"sequence" : "ACGT" , "k" : 0 })
110+ client .post ("/api/ annotate" , json = {"sequence" : "ACGT" , "k" : 0 })
92111 assert fake_engine .last_k == 1 # lower bound
93112
94113
95114def test_generate_returns_sequence (client ):
96- b = client .post ("/generate" , json = {"prompt" : "ACGT" , "organism" : "None (raw DNA)" }).json ()
115+ b = client .post ("/api/ generate" , json = {"prompt" : "ACGT" , "organism" : "None (raw DNA)" }).json ()
97116 assert b ["generation" ]["sequence" ]
98117
99118
100119def test_generate_rejects_out_of_range_feature (client ):
101- r = client .post ("/generate" , json = {"prompt" : "ACGT" , "features" : [{"feature_id" : 999 }]})
120+ r = client .post ("/api/ generate" , json = {"prompt" : "ACGT" , "features" : [{"feature_id" : 999 }]})
102121 assert r .status_code == 400 # the wedge guard, surfaced to the client
103122
104123
105124def test_generate_rejects_too_long (client , fake_engine ):
106125 seq = "A" * (fake_engine .max_seq_len + 1 ) # exceeds the context budget -> 413 (parity w/ annotate)
107- assert client .post ("/generate" , json = {"prompt" : seq }).status_code == 413
126+ assert client .post ("/api/ generate" , json = {"prompt" : seq }).status_code == 413
108127
109128
110129def test_generate_compare_baseline (client ):
111130 b = client .post (
112- "/generate" ,
131+ "/api/ generate" ,
113132 json = {"prompt" : "ACGT" , "features" : [{"feature_id" : 0 , "strength" : 5.0 }], "compare_baseline" : True },
114133 ).json ()
115134 assert b ["baseline" ]["sequence" ] # unsteered comparison returned alongside the steered one
@@ -118,14 +137,54 @@ def test_generate_compare_baseline(client):
118137def test_rejects_oversized_body (monkeypatch , fake_engine ):
119138 monkeypatch .setenv ("MAX_BODY_BYTES" , "100" )
120139 with TestClient (build_app (fake_engine )) as c :
121- assert c .post ("/annotate" , json = {"sequence" : "ACGT" * 100 }).status_code == 413
140+ assert c .post ("/api/ annotate" , json = {"sequence" : "ACGT" * 100 }).status_code == 413
122141
123142
124143def test_endpoints_503_until_ready (fake_engine ):
125144 fake_engine .ready = False
126145 fake_engine .load = lambda : None # startup leaves it not-ready
127146 with TestClient (build_app (fake_engine )) as c :
128- assert c .get ("/health" ).status_code == 503 # readiness probe sheds the pod
129- assert c .get ("/features" ).status_code == 503
130- assert c .post ("/annotate" , json = {"sequence" : "ACGT" }).status_code == 503
131- assert c .post ("/generate" , json = {"prompt" : "ACGT" , "organism" : "None (raw DNA)" }).status_code == 503
147+ assert c .get ("/api/health" ).status_code == 503 # readiness probe sheds the pod
148+ assert c .get ("/api/features" ).status_code == 503
149+ assert c .post ("/api/annotate" , json = {"sequence" : "ACGT" }).status_code == 503
150+ assert c .post ("/api/generate" , json = {"prompt" : "ACGT" , "organism" : "None (raw DNA)" }).status_code == 503
151+
152+
153+ # ---------------------------------------------------------- single-container: SPA at / + API at /api
154+ def test_serves_spa_index (static_client ):
155+ """With a built frontend mounted, GET / returns the SPA index (HTML), not an API response."""
156+ r = static_client .get ("/" )
157+ assert r .status_code == 200
158+ assert "text/html" in r .headers ["content-type" ]
159+ assert 'id="root"' in r .text
160+
161+
162+ def test_serves_static_asset (static_client ):
163+ """Frontend assets are served from the mount (so the SPA's JS/CSS load same-origin)."""
164+ r = static_client .get ("/assets/app.js" )
165+ assert r .status_code == 200
166+ assert "dashboard" in r .text
167+
168+
169+ def test_api_reachable_under_prefix_with_frontend (static_client ):
170+ """The API stays fully reachable under /api even with the SPA mounted at / (no shadowing)."""
171+ b = static_client .get ("/api/health" ).json ()
172+ assert b ["ready" ] is True and b ["layer" ] == 19
173+
174+
175+ def test_unknown_api_path_is_404_not_spa (static_client ):
176+ """An unknown /api/* path 404s — the /api namespace never falls through to the SPA index."""
177+ r = static_client .get ("/api/does-not-exist" )
178+ assert r .status_code == 404
179+ assert 'id="root"' not in r .text
180+
181+
182+ def test_api_only_when_no_frontend (fake_engine , monkeypatch ):
183+ """No static_dir and no DASHBOARD_DIST -> API-only: / 404s (nothing mounted), /api still works.
184+
185+ Guards dev / a frontend-less image: a missing build degrades to API-only, never a crash.
186+ """
187+ monkeypatch .delenv ("DASHBOARD_DIST" , raising = False )
188+ with TestClient (build_app (fake_engine )) as c :
189+ assert c .get ("/" ).status_code == 404
190+ assert c .get ("/api/health" ).status_code == 200
0 commit comments