Coverage for tests/test_runtime_lifecycle.py: 99%

111 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-13 19:28 +0000

1"""Production REST/MCP wiring, startup, shutdown and runtime refresh.""" 

2 

3import httpx 

4import pytest 

5from conftest import course_payload 

6from fastmcp import Client 

7from test_data_manager import Clock, Publisher 

8 

9from data_api.api import api as api_module 

10from data_api.data.manager import nthudata 

11from data_api.data.nthudata import Freshness 

12from data_api.domain.courses.services import courses_service 

13from data_api.mcp.server import mcp 

14 

15 

16async def test_rest_and_mcp_observe_runtime_course_refresh(monkeypatch): 

17 clock = Clock() 

18 publisher = Publisher( 

19 [course_payload(id="11510A", english_title="Version A")], "courses/latest.json" 

20 ) 

21 monkeypatch.setattr(nthudata, "clock", clock) 

22 async with nthudata.lifespan(publisher.client()): 

23 async with ( 

24 httpx.AsyncClient( 

25 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test" 

26 ) as rest, 

27 Client(mcp) as tools, 

28 ): 

29 first = await rest.get("/courses") 

30 assert first.json()[0]["id"] == "11510A" 

31 assert first.headers["X-Data-Commit-Hash"] == "a" 

32 publisher.version, publisher.payload = "b", [ 

33 course_payload(id="11510B", english_title="Version B") 

34 ] 

35 clock.advance() 

36 second = await rest.get("/courses?english_title=Version") 

37 assert second.json()[0]["id"] == "11510B" 

38 assert second.headers["X-Data-Commit-Hash"] == "b" 

39 result = await tools.call_tool("search_courses", {}) 

40 assert result.data["courses"][0]["id"] == "11510B" 

41 assert courses_service.state is nthudata.states["/courses/latest.json"] 

42 assert ( 

43 await courses_service.get_snapshot() 

44 ).data is courses_service.state.snapshot.data 

45 assert publisher.calls.count("/courses/latest.json") == 2 

46 

47 publisher.version, publisher.payload = "c", [course_payload(id="11510C")] 

48 clock.advance() 

49 result = await tools.call_tool("search_courses", {}) 

50 assert result.data["courses"][0]["id"] == "11510C" 

51 assert (await rest.get("/courses")).json()[0]["id"] == "11510C" 

52 publisher.version, publisher.data_error = "d", 503 

53 clock.advance() 

54 fallback = await rest.get("/courses") 

55 assert fallback.status_code == 200 

56 assert fallback.json()[0]["id"] == "11510C" 

57 assert fallback.headers["X-Data-Commit-Hash"] == "c" 

58 assert courses_service.state.freshness == Freshness.STALE 

59 

60 

61@pytest.mark.parametrize("empty", [True, False]) 

62async def test_cold_start_empty_is_not_unavailable(empty): 

63 publisher = Publisher([], "courses/latest.json") 

64 if not empty: 

65 publisher.data_error = 503 

66 async with nthudata.lifespan(publisher.client()): 

67 async with ( 

68 httpx.AsyncClient( 

69 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test" 

70 ) as rest, 

71 Client(mcp) as tools, 

72 ): 

73 response = await rest.get("/courses") 

74 result = await tools.call_tool("search_courses", {}, raise_on_error=False) 

75 if empty: 

76 assert response.status_code == 200 

77 assert response.json() == [] 

78 assert not result.is_error 

79 assert result.data == {"count": 0, "courses": []} 

80 else: 

81 assert response.status_code == 503 

82 assert response.json() == {"detail": "Service temporarily unavailable"} 

83 assert result.is_error 

84 assert "temporarily unavailable" in result.content[0].text 

85 assert "Traceback" not in result.content[0].text 

86 assert "http" not in result.content[0].text 

87 

88 

89@pytest.mark.parametrize( 

90 "endpoint,path,payload,tool,args", 

91 [ 

92 ("dining.json", "/dining", [], "find_dining", {}), 

93 ( 

94 "dining.json", 

95 "/dining?schedule=today", 

96 [], 

97 "find_dining", 

98 {"check_open": "today"}, 

99 ), 

100 ( 

101 "dining.json", 

102 "/dining?schedule=today&fuzzy=false", 

103 [], 

104 "find_dining", 

105 {"check_open": "today"}, 

106 ), 

107 ("announcements.json", "/announcements", [], "get_announcements", {}), 

108 ("newsletters.json", "/newsletters", [], "get_newsletters", {}), 

109 ("buses.json", "/buses/routes", {}, "get_bus_schedule", {}), 

110 ("buses.json", "/buses/stops", {}, "get_bus_schedule", {}), 

111 ( 

112 "buses.json", 

113 "/buses/schedule?route=main&day=weekday&direction=up", 

114 {}, 

115 "get_bus_schedule", 

116 {}, 

117 ), 

118 ( 

119 "buses.json", 

120 "/buses/schedule?stop=北校門口&route=main&day=weekday&direction=up&details=true", 

121 {}, 

122 "get_bus_schedule", 

123 {}, 

124 ), 

125 ( 

126 "buses.json", 

127 "/buses/schedule?stop=台積館", 

128 {}, 

129 "get_bus_schedule", 

130 {"stop": "台積館"}, 

131 ), 

132 ], 

133) 

134async def test_other_datasets_empty_unversioned_and_unavailable( 

135 endpoint, path, payload, tool, args 

136): 

137 publisher = Publisher(payload, endpoint) 

138 publisher.version = None 

139 async with nthudata.lifespan(publisher.client()): 

140 async with ( 

141 httpx.AsyncClient( 

142 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test" 

143 ) as rest, 

144 Client(mcp) as tools, 

145 ): 

146 response = await rest.get(path) 

147 assert response.status_code == 200 

148 assert "X-Data-Commit-Hash" not in response.headers 

149 assert not (await tools.call_tool(tool, args)).is_error 

150 publisher.data_error = 503 

151 async with nthudata.lifespan(publisher.client()): 

152 async with ( 

153 httpx.AsyncClient( 

154 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test" 

155 ) as rest, 

156 Client(mcp) as tools, 

157 ): 

158 assert (await rest.get(path)).status_code == 503 

159 result = await tools.call_tool(tool, args, raise_on_error=False) 

160 assert result.is_error 

161 assert "temporarily unavailable" in result.content[0].text 

162 

163 

164@pytest.mark.parametrize("upstream_available", [True, False]) 

165async def test_real_data_lifespan_client_cleanup(monkeypatch, upstream_available): 

166 publisher = Publisher([course_payload()], "courses/latest.json") 

167 if not upstream_available: 

168 publisher.data_error = 503 

169 

170 async def handler(self, request): 

171 return await publisher(request) 

172 

173 monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", handler) 

174 async with api_module.lifespan(api_module.app): 

175 client = nthudata.fetcher.client 

176 assert client is not None 

177 assert not client.is_closed 

178 assert api_module.app.state.datasets is nthudata 

179 assert courses_service.state.usable is upstream_available 

180 if upstream_available: 

181 assert client.timeout.read == 15 

182 async with httpx.AsyncClient( 

183 transport=httpx.ASGITransport(app=api_module.app), base_url="http://test" 

184 ) as rest: 

185 assert (await rest.get("/courses")).status_code == (200 if upstream_available else 503) 

186 assert client.is_closed 

187 assert nthudata.fetcher.client is None 

188 assert not courses_service.state.usable 

189 

190 

191async def test_app_startup_exception_closes_client(monkeypatch): 

192 client = None 

193 

194 async def fail(endpoints): 

195 nonlocal client 

196 client = nthudata.fetcher.client 

197 raise RuntimeError("unexpected startup bug") 

198 

199 monkeypatch.setattr(nthudata, "prefetch", fail) 

200 with pytest.raises(RuntimeError, match="startup bug"): 

201 async with api_module.lifespan(api_module.app): 

202 pass 

203 assert client is not None 

204 assert client.is_closed 

205 assert nthudata.fetcher.client is None