66from app .desktop .studio_server .webhost import connect_webhost
77from fastapi import FastAPI , HTTPException
88from fastapi .testclient import TestClient
9+ from kiln_server .custom_errors import connect_custom_errors
10+
11+ WEB_APP_404_BODY = "<html><body>custom not found</body></html>"
912
1013
1114@pytest .fixture
1215def temp_studio ():
1316 with tempfile .TemporaryDirectory () as d :
1417 os .makedirs (d , exist_ok = True )
18+ # The compiled web app always ships a 404.html: StaticFiles in html mode
19+ # will serve it for any miss unless we prevent it on API paths.
20+ with open (os .path .join (d , "404.html" ), "w" , encoding = "utf-8" ) as f :
21+ f .write (WEB_APP_404_BODY )
1522 with patch ("app.desktop.studio_server.webhost.studio_path" , lambda : d ):
1623 yield d
1724
@@ -24,25 +31,122 @@ def app_with_webhost(temp_studio):
2431 def forced_not_found ():
2532 raise HTTPException (status_code = 404 , detail = "test missing resource" )
2633
34+ @app .get ("/api/get-only" )
35+ def get_only ():
36+ return {"ok" : True }
37+
2738 connect_webhost (app )
2839 return app
2940
3041
31- def test_not_found_handler_returns_json_for_api_http_exception (app_with_webhost ):
32- client = TestClient (app_with_webhost )
33- response = client .get ("/api/forced-not-found" )
42+ @pytest .fixture
43+ def client (app_with_webhost ):
44+ return TestClient (app_with_webhost )
45+
46+
47+ def assert_json_404 (response , message = "Not Found" ):
48+ assert response .status_code == 404
49+ assert response .headers .get ("content-type" , "" ).startswith ("application/json" )
50+ assert response .json () == {"message" : message }
51+
52+
53+ def assert_json_405 (response ):
54+ assert response .status_code == 405
55+ assert response .headers .get ("content-type" , "" ).startswith ("application/json" )
56+
57+
58+ def test_not_found_handler_returns_json_for_api_http_exception (client ):
59+ assert_json_404 (client .get ("/api/forced-not-found" ), "test missing resource" )
60+
61+
62+ @pytest .mark .parametrize (
63+ "path" ,
64+ [
65+ "/api" ,
66+ "/api/" ,
67+ "/api/some-unmatched-path" ,
68+ "/api/nested/unmatched/path" ,
69+ "/api/unmatched.html" ,
70+ ],
71+ )
72+ def test_unmatched_api_paths_return_json_not_web_app_404 (client , path ):
73+ response = client .get (path )
74+ assert_json_404 (response )
75+ assert WEB_APP_404_BODY not in response .text
76+
77+
78+ def test_api_head_request_returns_json_404 (client ):
79+ # HEAD responses carry no body, so the JSON shape can't be asserted here.
80+ response = client .head ("/api/some-unmatched-path" )
3481 assert response .status_code == 404
35- assert response .json () == {"detail" : "test missing resource" }
3682 assert response .headers .get ("content-type" , "" ).startswith ("application/json" )
83+ assert response .text == ""
84+
85+
86+ @pytest .mark .parametrize ("method" , ["POST" , "PUT" , "DELETE" , "OPTIONS" ])
87+ @pytest .mark .parametrize ("path" , ["/api/some-unmatched-path" , "/api/get-only" ])
88+ def test_non_get_method_on_api_path_keeps_405 (client , method , path ):
89+ # The web host mount matches every path, so any method the static file
90+ # server doesn't serve reaches it and gets its 405 (which, unlike a
91+ # router-generated one, carries no Allow header). All of this predates the
92+ # JSON 404 handling and is left as-is: 405 is right for a wrong verb on a
93+ # real route, and for a path that doesn't exist at all it's arguably wrong
94+ # (404 would be more defensible) but not something this change touches.
95+ assert_json_405 (client .request (method , path ))
3796
3897
39- def test_not_found_handler_serves_404_html_for_non_api_paths (temp_studio ):
40- with open (os .path .join (temp_studio , "404.html" ), "w" , encoding = "utf-8" ) as f :
41- f .write ("<html><body>custom not found</body></html>" )
98+ def test_matched_api_route_still_works (client ):
99+ response = client .get ("/api/get-only" )
100+ assert response .status_code == 200
101+ assert response .json () == {"ok" : True }
42102
103+
104+ @pytest .mark .parametrize (
105+ "path" ,
106+ [
107+ "/route-that-does-not-exist" ,
108+ # Paths that merely start with the letters "api" are web app paths
109+ "/apiary" ,
110+ "/api-keys" ,
111+ ],
112+ )
113+ def test_non_api_paths_serve_web_app_404 (client , path ):
114+ response = client .get (path )
115+ assert response .status_code == 404
116+ assert response .headers .get ("content-type" , "" ).startswith ("text/html" )
117+ assert WEB_APP_404_BODY in response .text
118+
119+
120+ def test_non_get_method_on_non_api_path_keeps_405 (client ):
121+ assert_json_405 (client .post ("/route-that-does-not-exist" ))
122+
123+
124+ def test_api_404s_with_custom_error_handlers (temp_studio ):
125+ # The real server registers the shared error handlers before the web host.
126+ # This 404 status handler must keep winning over their HTTPException class
127+ # handler, and neither may reintroduce the HTML 404.
43128 app = FastAPI ()
129+
130+ @app .get ("/api/forced-not-found" )
131+ def forced_not_found ():
132+ raise HTTPException (status_code = 404 , detail = "test missing resource" )
133+
134+ connect_custom_errors (app )
44135 connect_webhost (app )
45136 client = TestClient (app )
46- response = client .get ("/route-that-does-not-exist" )
47- assert response .status_code == 404
48- assert "custom not found" in response .text
137+
138+ assert_json_404 (client .get ("/api/some-unmatched-path" ))
139+ assert_json_404 (client .get ("/api/forced-not-found" ), "test missing resource" )
140+
141+ web_app_response = client .get ("/route-that-does-not-exist" )
142+ assert web_app_response .status_code == 404
143+ assert WEB_APP_404_BODY in web_app_response .text
144+
145+
146+ def test_non_api_static_file_still_served (temp_studio , client ):
147+ with open (os .path .join (temp_studio , "page.html" ), "w" , encoding = "utf-8" ) as f :
148+ f .write ("<html><body>real page</body></html>" )
149+
150+ response = client .get ("/page" )
151+ assert response .status_code == 200
152+ assert "real page" in response .text
0 commit comments