Skip to content

Commit aeedbde

Browse files
JerrettDavisCopilot
andcommitted
test: raise subscription coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 32c5abd commit aeedbde

5 files changed

Lines changed: 793 additions & 0 deletions

tests/test_copilot_quota.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import sys
57
import time
8+
from types import SimpleNamespace
69

710
import pytest
811

12+
import headroom.subscription.copilot_quota as quota_module
913
from headroom.subscription.copilot_quota import (
1014
CopilotQuotaCategory,
1115
CopilotQuotaSnapshot,
16+
CopilotQuotaState,
17+
_CopilotQuotaTracker,
1218
discover_github_token,
19+
get_copilot_quota_tracker,
1320
parse_copilot_quota,
1421
)
1522

@@ -65,6 +72,10 @@ def test_used_percent_clipped_at_zero(self):
6572
cat = CopilotQuotaCategory(name="chat", percent_remaining=110.0)
6673
assert cat.used_percent == pytest.approx(0.0)
6774

75+
def test_used_percent_none_when_entitlement_is_zero(self):
76+
cat = CopilotQuotaCategory(name="chat", entitlement=0, remaining=0)
77+
assert cat.used_percent is None
78+
6879

6980
# ---------------------------------------------------------------------------
7081
# parse_copilot_quota
@@ -329,3 +340,129 @@ def _count_event_wait() -> int:
329340
assert residual <= baseline, (
330341
f"CopilotQuotaTracker left residual Event.wait: baseline={baseline} residual={residual}"
331342
)
343+
344+
345+
def test_copilot_quota_state_to_dict_and_tracker_get_stats() -> None:
346+
state = CopilotQuotaState()
347+
tracker = _CopilotQuotaTracker()
348+
349+
assert state.to_dict()["latest"] is None
350+
assert tracker.get_stats() is None
351+
352+
state.latest = CopilotQuotaSnapshot(login="octocat")
353+
state.last_error = "boom"
354+
state.last_updated = 123.0
355+
tracker._state = state
356+
357+
assert tracker.get_stats()["last_error"] == "boom"
358+
359+
360+
@pytest.mark.asyncio
361+
async def test_tracker_stop_cancels_when_wait_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
362+
tracker = _CopilotQuotaTracker()
363+
tracker._stop_event = asyncio.Event()
364+
tracker._task = asyncio.create_task(asyncio.sleep(60))
365+
366+
async def fake_wait_for(awaitable, timeout): # noqa: ANN001, ANN202
367+
raise asyncio.TimeoutError
368+
369+
monkeypatch.setattr(quota_module.asyncio, "wait_for", fake_wait_for)
370+
371+
await tracker.stop()
372+
await asyncio.sleep(0)
373+
374+
assert tracker._task.cancelled() is True
375+
376+
377+
def _install_fake_aiohttp(
378+
monkeypatch: pytest.MonkeyPatch,
379+
*,
380+
payload: dict | None = None,
381+
status: int = 200,
382+
ok: bool = True,
383+
exc: Exception | None = None,
384+
) -> None:
385+
class FakeTimeout:
386+
def __init__(self, total: float) -> None:
387+
self.total = total
388+
389+
class FakeResponse:
390+
def __init__(self) -> None:
391+
self.status = status
392+
self.ok = ok
393+
394+
async def __aenter__(self):
395+
if exc is not None:
396+
raise exc
397+
return self
398+
399+
async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
400+
return False
401+
402+
async def json(self):
403+
return payload or {}
404+
405+
class FakeSession:
406+
async def __aenter__(self):
407+
return self
408+
409+
async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
410+
return False
411+
412+
def get(self, url: str, headers: dict[str, str], timeout: FakeTimeout):
413+
assert url.endswith("/copilot_internal/user")
414+
assert headers["Authorization"].startswith("Bearer ")
415+
assert timeout.total == 10
416+
return FakeResponse()
417+
418+
monkeypatch.setitem(
419+
sys.modules,
420+
"aiohttp",
421+
SimpleNamespace(ClientTimeout=FakeTimeout, ClientSession=lambda: FakeSession()),
422+
)
423+
424+
425+
@pytest.mark.asyncio
426+
async def test_maybe_poll_covers_response_and_singleton_paths(
427+
monkeypatch: pytest.MonkeyPatch,
428+
) -> None:
429+
tracker = _CopilotQuotaTracker()
430+
monkeypatch.setattr(quota_module, "discover_github_token", lambda: "ghp-test")
431+
432+
_install_fake_aiohttp(monkeypatch, status=401)
433+
await tracker._maybe_poll()
434+
assert tracker._state.last_error == "unauthorized — check GITHUB_TOKEN"
435+
436+
_install_fake_aiohttp(monkeypatch, status=404)
437+
await tracker._maybe_poll()
438+
assert tracker._state.last_error == "endpoint not found (non-Copilot account?)"
439+
440+
_install_fake_aiohttp(monkeypatch, status=500, ok=False)
441+
await tracker._maybe_poll()
442+
assert tracker._state.last_error == "HTTP 500"
443+
444+
_install_fake_aiohttp(monkeypatch, exc=RuntimeError("network down"))
445+
await tracker._maybe_poll()
446+
assert tracker._state.last_error == "network down"
447+
448+
_install_fake_aiohttp(monkeypatch, payload={"quota_snapshots": {}})
449+
monkeypatch.setattr(
450+
quota_module,
451+
"parse_copilot_quota",
452+
lambda data: (_ for _ in ()).throw(ValueError("bad")),
453+
)
454+
await tracker._maybe_poll()
455+
assert tracker._state.last_error == "parse error: bad"
456+
457+
monkeypatch.setattr(quota_module, "parse_copilot_quota", parse_copilot_quota)
458+
_install_fake_aiohttp(monkeypatch, payload=_SAMPLE_RESPONSE)
459+
await tracker._maybe_poll()
460+
assert tracker._state.latest is not None
461+
assert tracker._state.latest.login == "octocat"
462+
assert tracker._state.last_error is None
463+
assert tracker._state.last_updated is not None
464+
465+
quota_module._singleton = None
466+
first = get_copilot_quota_tracker()
467+
second = get_copilot_quota_tracker()
468+
assert first is second

tests/test_release_version.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,20 @@
1010

1111
from headroom.release_version import (
1212
CommitInfo,
13+
ReleaseVersionInfo,
14+
SemVer,
1315
classify_commit_bump,
16+
commit_height_since,
1417
compute_release_version,
1518
determine_bump_level,
1619
find_latest_release_tag,
1720
get_canonical_version,
1821
list_release_commits,
22+
list_release_tags,
23+
main,
1924
normalize_release_tag,
2025
parse_release_tag,
26+
write_github_outputs,
2127
)
2228

2329
ROOT = Path(__file__).resolve().parent.parent
@@ -107,6 +113,46 @@ def test_parse_release_tag_preserves_legacy_height_for_sorting() -> None:
107113
assert tag.legacy_height == 3
108114

109115

116+
def test_semver_helpers_cover_parse_bump_and_str() -> None:
117+
version = SemVer.parse("1.2.3")
118+
119+
assert str(version) == "1.2.3"
120+
assert version.bump("major") == SemVer(2, 0, 0)
121+
assert version.bump("minor") == SemVer(1, 3, 0)
122+
assert version.bump("patch") == SemVer(1, 2, 4)
123+
124+
with pytest.raises(ValueError, match="Invalid semantic version"):
125+
SemVer.parse("v1.2.3")
126+
127+
with pytest.raises(ValueError, match="Unsupported bump level"):
128+
version.bump("build")
129+
130+
131+
def test_release_version_info_as_outputs() -> None:
132+
info = ReleaseVersionInfo(
133+
version="1.2.4",
134+
npm_version="1.2.4",
135+
canonical="1.2.3",
136+
height="7",
137+
bump="patch",
138+
previous_tag="v1.2.3",
139+
)
140+
141+
assert info.as_outputs() == {
142+
"version": "1.2.4",
143+
"npm_version": "1.2.4",
144+
"canonical": "1.2.3",
145+
"height": "7",
146+
"bump": "patch",
147+
"previous_tag": "v1.2.3",
148+
}
149+
150+
151+
def test_parse_release_tag_rejects_invalid_input() -> None:
152+
with pytest.raises(ValueError, match="Invalid release tag"):
153+
parse_release_tag("release-1.2.3")
154+
155+
110156
def test_classify_commit_bump_treats_breaking_change_as_major() -> None:
111157
assert (
112158
classify_commit_bump(
@@ -116,6 +162,24 @@ def test_classify_commit_bump_treats_breaking_change_as_major() -> None:
116162
)
117163

118164

165+
def test_classify_commit_bump_uses_merge_summary_and_breaking_body() -> None:
166+
assert (
167+
classify_commit_bump(
168+
CommitInfo(
169+
subject="Merge pull request #1 from feature/thing",
170+
body="\n\nfeat(api): add window export\n\nmore detail",
171+
)
172+
)
173+
== "minor"
174+
)
175+
assert (
176+
classify_commit_bump(
177+
CommitInfo(subject="Merge branch 'topic'", body="BREAKING CHANGE: drop support"),
178+
)
179+
== "major"
180+
)
181+
182+
119183
def test_determine_bump_level_uses_greatest_commit_level() -> None:
120184
commits = [
121185
CommitInfo(subject="fix: patch one", body=""),
@@ -156,6 +220,89 @@ def test_list_release_commits_parses_empty_body_entries(
156220
]
157221

158222

223+
def test_list_release_tags_filters_blank_lines(monkeypatch: pytest.MonkeyPatch) -> None:
224+
monkeypatch.setattr(
225+
"headroom.release_version.subprocess.run",
226+
lambda *args, **kwargs: Mock(stdout="v0.1.0\n\nv0.2.0\n"),
227+
)
228+
229+
assert list_release_tags(ROOT) == ["v0.1.0", "v0.2.0"]
230+
231+
232+
def test_commit_height_since_handles_empty_and_missing_tag(monkeypatch: pytest.MonkeyPatch) -> None:
233+
assert commit_height_since(ROOT, "") == "0"
234+
235+
monkeypatch.setattr(
236+
"headroom.release_version.subprocess.run",
237+
lambda *args, **kwargs: Mock(stdout="\n"),
238+
)
239+
240+
assert commit_height_since(ROOT, "v0.1.0") == "0"
241+
242+
243+
def test_write_github_outputs_appends_all_values(tmp_path: Path) -> None:
244+
output_path = tmp_path / "github-output.txt"
245+
info = ReleaseVersionInfo(
246+
version="1.0.1",
247+
npm_version="1.0.1",
248+
canonical="1.0.0",
249+
height="2",
250+
bump="patch",
251+
previous_tag="v1.0.0",
252+
)
253+
254+
write_github_outputs(info, str(output_path))
255+
256+
assert output_path.read_text(encoding="utf-8").splitlines() == [
257+
"version=1.0.1",
258+
"npm_version=1.0.1",
259+
"canonical=1.0.0",
260+
"height=2",
261+
"bump=patch",
262+
"previous_tag=v1.0.0",
263+
]
264+
265+
266+
def test_main_prints_outputs_when_github_output_is_unset(
267+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
268+
) -> None:
269+
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
270+
monkeypatch.setenv("LEVEL", "")
271+
monkeypatch.setattr("headroom.release_version.list_release_tags", lambda root: ["v1.2.3"])
272+
monkeypatch.setattr("headroom.release_version.find_latest_release_tag", lambda tags: "v1.2.3")
273+
monkeypatch.setattr(
274+
"headroom.release_version.list_release_commits",
275+
lambda root, previous_tag: [CommitInfo(subject="feat: add thing", body="")],
276+
)
277+
monkeypatch.setattr("headroom.release_version.determine_bump_level", lambda commits: "minor")
278+
monkeypatch.setattr("headroom.release_version.get_canonical_version", lambda root: "1.2.3")
279+
monkeypatch.setattr(
280+
"headroom.release_version.compute_release_version",
281+
lambda canonical_version, level, tags, manual_version="": ReleaseVersionInfo(
282+
version="1.3.0",
283+
npm_version="1.3.0",
284+
canonical="1.2.3",
285+
height="0",
286+
bump="minor",
287+
previous_tag="v1.2.3",
288+
),
289+
)
290+
monkeypatch.setattr(
291+
"headroom.release_version.commit_height_since", lambda root, previous_tag: "4"
292+
)
293+
294+
main()
295+
296+
assert capsys.readouterr().out.splitlines() == [
297+
"version=1.3.0",
298+
"npm_version=1.3.0",
299+
"canonical=1.2.3",
300+
"height=4",
301+
"bump=minor",
302+
"previous_tag=v1.2.3",
303+
]
304+
305+
159306
def test_release_version_script_runs_directly_without_importing_headroom_package(
160307
tmp_path: Path,
161308
) -> None:

0 commit comments

Comments
 (0)