Coverage for tests/test_release.py: 100%

220 statements  

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

1"""Release metadata and conservative public compatibility gates.""" 

2 

3import json 

4from copy import deepcopy 

5from importlib.metadata import version 

6from pathlib import Path 

7from textwrap import dedent 

8 

9import pytest 

10 

11from data_api import __version__ 

12from data_api.api.api import app 

13from data_api.checks import release 

14from data_api.checks.mcp import BASELINE, structural, tool_contract 

15from data_api.mcp.server import mcp 

16 

17 

18@pytest.mark.parametrize("missing_section", [False, True]) 

19def test_prepare_release_renders_current_pr_template(monkeypatch, tmp_path, missing_section): 

20 root = Path.cwd() 

21 workflow = (root / ".github" / "workflows" / "prepare-release.yml").read_text(encoding="utf-8") 

22 script = dedent( 

23 workflow.split("- name: Fill repository PR template", 1)[1] 

24 .split("python - <<'PY'\n", 1)[1] 

25 .split("\n PY", 1)[0] 

26 ) 

27 relative_template = Path(".github") / "PULL_REQUEST_TEMPLATE" / "pull_request_template.md" 

28 template = (root / relative_template).read_text(encoding="utf-8") 

29 if missing_section: 

30 template = template.replace("#### Validation\nN/A", "#### Validation\n") 

31 destination = tmp_path / relative_template 

32 destination.parent.mkdir(parents=True) 

33 destination.write_text(template, encoding="utf-8") 

34 monkeypatch.chdir(tmp_path) 

35 monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) 

36 monkeypatch.setenv("RELEASE_VERSION", "3.0.2") 

37 output = tmp_path / "release-pr.md" 

38 if missing_section: 

39 with pytest.raises(ValueError, match="Expected one PR template section: #### Validation"): 

40 exec(compile(script, "prepare-release-template", "exec"), {}) 

41 assert not output.exists() 

42 return 

43 

44 exec(compile(script, "prepare-release-template", "exec"), {}) 

45 rendered = output.read_text(encoding="utf-8") 

46 for guidance in ( 

47 "Add or modify features.\n Including Commit Tags: feat, style, perf, test, revert.", 

48 "Bug fixes.\n Including Commit Tags: fix.", 

49 "Code changes that neither fixes a bug nor adds a feature.\n" 

50 " Including Commit Tags: refactor, update.", 

51 "Changes to the build process or auxiliary tools and libraries.\n" 

52 " Including Commit Tags: chore.", 

53 "Documentation changes.\n Including Commit Tags: docs.", 

54 "Any other notes.", 

55 ): 

56 comment = f"<!--- {guidance}" + (" -->" if "\n" not in guidance else "\n -->") 

57 assert comment in template 

58 assert comment in rendered 

59 assert "### Internal\nPrepare 3.0.2; update pyproject.toml and uv.lock." in rendered 

60 assert "### Notes\nSee docs/releasing.md" in rendered 

61 assert ( 

62 "#### Validation\n- `uv run --no-sync --no-build pre-commit run --all-files` (passed)" 

63 in rendered 

64 ) 

65 assert rendered.count("(passed)") == 5 

66 assert "- <something>" not in rendered 

67 assert [line for line in rendered.splitlines() if line.startswith("#")] == [ 

68 line for line in template.splitlines() if line.startswith("#") 

69 ] 

70 for heading in ("Features", "Fixes", "Refactors", "Documentations"): 

71 assert f"### {heading}\nN/A" in rendered 

72 

73 

74def test_installed_version_has_one_source(): 

75 assert release.validate_version(Path.cwd(), f"v{__version__}") == __version__ 

76 assert __version__ == app.version == version("nthu-data-api") 

77 assert mcp.version == __version__ 

78 

79 

80@pytest.mark.parametrize("value", ["3", "3.0", "v3.0.0", "03.0.0", "3.0.0rc1", "3.0.0\n", "3.0.0"]) 

81def test_nonstable_versions_rejected(value): 

82 with pytest.raises(ValueError, match="stable"): 

83 release.version_tuple(value) 

84 

85 

86@pytest.mark.parametrize("tag", ["", "3.0.0", "v2.0.0", "v3.0.0-rc.1", "v03.0.0"]) 

87def test_invalid_or_mismatched_tags_fail(tag): 

88 root = Path.cwd() 

89 with pytest.raises(ValueError, match="tag"): 

90 release.validate_version(root, tag) 

91 

92 

93def test_lock_mismatch_fails(tmp_path): 

94 (tmp_path / "pyproject.toml").write_text( 

95 '[project]\nname="nthu-data-api"\nversion="3.0.0"\n', encoding="utf-8" 

96 ) 

97 (tmp_path / "uv.lock").write_text( 

98 '[[package]]\nname="nthu-data-api"\nversion="2.0.0"\n', encoding="utf-8" 

99 ) 

100 with pytest.raises(ValueError, match="must match"): 

101 release.validate_version(tmp_path) 

102 

103 

104@pytest.mark.parametrize( 

105 "code,stdout,expected", 

106 [ 

107 (2, "", True), 

108 (0, "a" * 40 + "\trefs/tags/v3.0.0\n", False), 

109 (128, "", None), 

110 (0, "", None), 

111 (2, "unexpected", None), 

112 (0, "a" * 40 + "\trefs/tags/v3.0.1\n", None), 

113 ], 

114) 

115def test_publication_uses_authoritative_remote_and_fails_closed( 

116 monkeypatch, code, stdout, expected 

117): 

118 from types import SimpleNamespace 

119 

120 def run(args, **kwargs): 

121 assert args == [ 

122 "git", 

123 "ls-remote", 

124 "--exit-code", 

125 "--refs", 

126 "https://github.com/NTHU-SA/NTHU-Data-API.git", 

127 "refs/tags/v3.0.0", 

128 ] 

129 assert kwargs["timeout"] == 30 

130 return SimpleNamespace(returncode=code, stdout=stdout) 

131 

132 monkeypatch.setattr(release.subprocess, "run", run) 

133 if expected is None: 

134 with pytest.raises(ValueError, match="Unable to verify"): 

135 release.initial_version_is_unpublished("3.0.0") 

136 else: 

137 assert release.initial_version_is_unpublished("3.0.0") is expected 

138 assert release.initial_version_is_unpublished("3.0.1") is False 

139 

140 

141@pytest.mark.parametrize("failure", [OSError(), release.subprocess.TimeoutExpired("git", 30)]) 

142def test_publication_network_failure_is_not_unpublished(monkeypatch, failure): 

143 def run(*args, **kwargs): 

144 raise failure 

145 

146 monkeypatch.setattr(release.subprocess, "run", run) 

147 with pytest.raises(ValueError, match="Unable to verify"): 

148 release.initial_version_is_unpublished("3.0.0") 

149 

150 

151@pytest.mark.parametrize("unpublished", [True, False, None]) 

152@pytest.mark.parametrize("current", ["3.0.0", "3.0.1", "2.9.0", "4.0.0"]) 

153def test_only_verified_unpublished_initial_version_can_update_contract( 

154 monkeypatch, tmp_path, unpublished, current 

155): 

156 fixtures = tmp_path / "tests" / "fixtures" 

157 fixtures.mkdir(parents=True) 

158 for name in release.CONTRACTS: 

159 (fixtures / name).write_text("{}", encoding="utf-8") 

160 

161 def git_file(sha, path): 

162 return '[project]\nversion="3.0.0"\n' if path == "pyproject.toml" else '{"old": {}}' 

163 

164 def publication(version): 

165 if unpublished is None: 

166 raise ValueError("Unable to verify release publication") 

167 return unpublished 

168 

169 monkeypatch.setattr(release, "git_file", git_file) 

170 monkeypatch.setattr(release, "initial_version_is_unpublished", publication) 

171 if current == "4.0.0" or (current == "3.0.0" and unpublished is True): 

172 assert release.compare_base(tmp_path, "a" * 40, current, check_publication=True) 

173 else: 

174 with pytest.raises(ValueError): 

175 release.compare_base(tmp_path, "a" * 40, current, check_publication=True) 

176 if current == "3.0.0": 

177 with pytest.raises(ValueError, match="major"): 

178 release.compare_base(tmp_path, "a" * 40, current) 

179 

180 

181@pytest.mark.parametrize( 

182 "old,new", 

183 [ 

184 ({"GET /courses": {}}, {}), 

185 ({"type": "string"}, {"type": "integer"}), 

186 ({"enum": ["a", "b"]}, {"enum": ["a"]}), 

187 ({}, {"minimum": 1}), 

188 ({}, {"type": "integer"}), 

189 ({}, {"enum": ["only"]}), 

190 ({}, {"default": 20}), 

191 ({"required": []}, {"required": ["name"]}), 

192 ({"default": 5}, {"default": 20}), 

193 ], 

194) 

195def test_breaking_changes_detected(old, new): 

196 assert release.breaking_changes(old, new) 

197 

198 

199def test_new_required_parameter_is_breaking(): 

200 assert release.breaking_changes({}, {"query:limit": {"required": True}}, "contract.parameters") 

201 

202 

203@pytest.mark.parametrize("name", ["deprecated", "type"]) 

204@pytest.mark.parametrize("container", ["properties", "$defs", "definitions", "schemas"]) 

205def test_schema_names_are_not_keywords(name, container): 

206 old = {container: {name: {"type": "string"}}} 

207 new = {container: {name: {"type": "integer"}}} 

208 assert release.breaking_changes(old, new) == [f"contract.{container}.{name}.type: changed"] 

209 assert release.breaking_changes(old, {container: {}}) == [ 

210 f"contract.{container}.{name}: removed" 

211 ] 

212 

213 

214def test_optional_type_named_argument_is_not_a_constraint(): 

215 old = {"get_bus_schedule": {"input": {"properties": {}}}} 

216 new = { 

217 "get_bus_schedule": { 

218 "input": {"properties": {"type": {"type": "string"}, "required": {"type": "string"}}} 

219 } 

220 } 

221 assert not release.breaking_changes(old, new, "mcp_contract.json") 

222 assert release.breaking_changes(new, old, "mcp_contract.json") == [ 

223 "mcp_contract.json.get_bus_schedule.input.properties.required: removed", 

224 "mcp_contract.json.get_bus_schedule.input.properties.type: removed", 

225 ] 

226 

227 

228def test_tool_and_parameter_names_are_not_schema_keywords(): 

229 assert not release.breaking_changes({}, {"type": {"input": {}}}, "mcp_contract.json") 

230 assert release.breaking_changes( 

231 {"operations": {"GET /test": {"parameters": {"deprecated": {"required": False}}}}}, 

232 {"operations": {"GET /test": {"parameters": {}}}}, 

233 "openapi_contract.json", 

234 ) == ["openapi_contract.json.operations.GET /test.parameters.deprecated: removed"] 

235 

236 

237@pytest.mark.parametrize("keyword", ["default", "const", "enum"]) 

238def test_literal_schema_values_do_not_reenter_schema_context(keyword): 

239 old_value = {"input": {"deprecated": "old"}} 

240 new_value = {"input": {"deprecated": "new"}} 

241 old = {keyword: [old_value] if keyword == "enum" else old_value} 

242 new = {keyword: [new_value] if keyword == "enum" else new_value} 

243 assert release.breaking_changes(old, new) == [f"contract.{keyword}: changed"] 

244 if keyword == "enum": 

245 assert not release.breaking_changes(old, {"enum": [old_value, new_value]}) 

246 

247 

248@pytest.mark.parametrize("keyword", ["additionalItems", "unevaluatedProperties"]) 

249def test_schema_valued_keywords_propagate_context(keyword): 

250 assert release.breaking_changes({keyword: {}}, {keyword: {"type": "string"}}) == [ 

251 f"contract.{keyword}.type: new constraint or required input" 

252 ] 

253 

254 

255@pytest.mark.parametrize("change", ["extension", "narrowed", "branch", "removed_branch"]) 

256def test_nullable_stop_enum_compatibility(change): 

257 old = json.loads(BASELINE.read_text(encoding="utf-8")) 

258 new = deepcopy(old) 

259 stop = new["get_bus_schedule"]["input"]["properties"]["stop"] 

260 if change == "extension": 

261 stop["anyOf"][0]["enum"].append("additional stop") 

262 elif change == "narrowed": 

263 stop["anyOf"][0]["enum"].pop() 

264 elif change == "branch": 

265 stop["anyOf"][1]["type"] = "integer" 

266 else: 

267 stop["anyOf"].pop() 

268 changes = release.breaking_changes(old, new, "mcp_contract.json") 

269 if change == "extension": 

270 assert not changes 

271 else: 

272 assert changes 

273 

274 

275@pytest.mark.parametrize( 

276 "old,new", 

277 [ 

278 ({}, {"GET /new": {"responses": {}}}), 

279 ({}, {"query:new": {"required": False, "schema": {"type": "string"}}}), 

280 ({"enum": ["a"]}, {"enum": ["a", "b"]}), 

281 ({"deprecated": False}, {"deprecated": True}), 

282 ({"properties": {}}, {"properties": {"title": {"type": "string"}}}), 

283 ({}, {"NewSchema": {"type": "object", "required": ["id"]}}), 

284 ], 

285) 

286def test_compatible_additions_allowed(old, new): 

287 assert not release.breaking_changes(old, new) 

288 

289 

290@pytest.mark.parametrize("current,allowed", [("3.0.1", False), ("4.0.0", True)]) 

291def test_refreshed_baseline_cannot_hide_same_major_removal(monkeypatch, tmp_path, current, allowed): 

292 fixtures = tmp_path / "tests" / "fixtures" 

293 fixtures.mkdir(parents=True) 

294 for name in release.CONTRACTS: 

295 (fixtures / name).write_text("{}", encoding="utf-8") 

296 

297 def git_file(sha, path): 

298 if path == "pyproject.toml": 

299 return '[project]\nversion="3.0.0"\n' 

300 return '{"public-operation": {}}' 

301 

302 monkeypatch.setattr(release, "git_file", git_file) 

303 if allowed: 

304 assert release.compare_base(tmp_path, "a" * 40, current) 

305 else: 

306 with pytest.raises(ValueError, match="major"): 

307 release.compare_base(tmp_path, "a" * 40, current) 

308 

309 

310@pytest.mark.parametrize( 

311 "change,breaking", 

312 [ 

313 ("remove_deprecated", True), 

314 ("change_deprecated", True), 

315 ("add_optional_type", False), 

316 ("extend_stop_enum", False), 

317 ("change_nullable_branch", True), 

318 ("change_default_literal", True), 

319 ("restrict_additional_items", True), 

320 ("restrict_unevaluated_properties", True), 

321 ], 

322) 

323def test_same_major_mcp_snapshot_changes(monkeypatch, change, breaking): 

324 old = json.loads(BASELINE.read_text(encoding="utf-8")) 

325 new = deepcopy(old) 

326 old_properties = old["get_bus_schedule"]["input"]["properties"] 

327 new_properties = new["get_bus_schedule"]["input"]["properties"] 

328 if change == "remove_deprecated": 

329 old_properties["deprecated"] = {"type": "string"} 

330 elif change == "change_deprecated": 

331 old_properties["deprecated"] = {"type": "string"} 

332 new_properties["deprecated"] = {"type": "integer"} 

333 elif change == "add_optional_type": 

334 new_properties["type"] = {"type": "string"} 

335 elif change == "extend_stop_enum": 

336 old_properties["stop"]["anyOf"][0]["enum"].pop() 

337 elif change == "change_nullable_branch": 

338 new_properties["stop"]["anyOf"][1]["type"] = "integer" 

339 elif change == "change_default_literal": 

340 old_properties["stop"]["default"] = {"input": {"deprecated": "old"}} 

341 new_properties["stop"]["default"] = {"input": {"deprecated": "new"}} 

342 else: 

343 keyword = ( 

344 "additionalItems" if change == "restrict_additional_items" else "unevaluatedProperties" 

345 ) 

346 old_properties["stop"][keyword] = {} 

347 new_properties["stop"][keyword] = {"type": "string"} 

348 

349 original_read_text = Path.read_text 

350 

351 def read_text(path, *args, **kwargs): 

352 if path == Path.cwd() / BASELINE: 

353 return json.dumps(new) 

354 return original_read_text(path, *args, **kwargs) 

355 

356 def git_file(sha, path): 

357 if path == "pyproject.toml": 

358 return '[project]\nversion="3.0.0"\n' 

359 if path.endswith("mcp_contract.json"): 

360 return json.dumps(old) 

361 return original_read_text(Path(path), encoding="utf-8") 

362 

363 monkeypatch.setattr(Path, "read_text", read_text) 

364 monkeypatch.setattr(release, "git_file", git_file) 

365 root = Path.cwd() 

366 if breaking: 

367 with pytest.raises(ValueError, match="major"): 

368 release.compare_base(root, "a" * 40, "3.0.1") 

369 else: 

370 assert not release.compare_base(root, "a" * 40, "3.0.1") 

371 

372 

373async def test_mcp_structured_baseline(): 

374 assert await tool_contract() == json.loads(BASELINE.read_text(encoding="utf-8")) 

375 

376 

377def test_mcp_field_named_title_is_not_stripped(): 

378 schema = {"title": "Description", "properties": {"title": {"type": "string"}}} 

379 original = deepcopy(schema) 

380 assert structural(schema) == {"properties": {"title": {"type": "string"}}} 

381 assert schema == original