Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/test_bill_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

import pytest

from bill_index.bill_index import (
from tools.shared.bill_index import (
BillIdentifier,
BillIndex,
_decode_value,
Expand Down
8 changes: 4 additions & 4 deletions tests/test_fetch_bill_archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
import pytest
import respx

from fetch_bill_archives import archive_temp_path, download_archive_zip
from tests.utils import assert_files
from tools.shared.http import download_zip as download_archive_zip

ARCHIVE_URL = "https://www.govinfo.gov/bulkdata/BILLSTATUS/999/hr/BILLSTATUS-999-hr.zip"

Expand Down Expand Up @@ -53,8 +54,7 @@ def test_truncated_body_without_content_length_is_not_committed(self, tmp_path):
with pytest.raises(httpx.HTTPError):
download_archive_zip(client, ARCHIVE_URL, dest)

assert not dest.exists()
assert not archive_temp_path(dest).exists()
assert_files(tmp_path, ["999-hr.zip.error"])

@respx.mock
def test_healthy_body_without_content_length_is_committed(self, tmp_path):
Expand All @@ -67,7 +67,7 @@ def test_healthy_body_without_content_length_is_committed(self, tmp_path):
download_archive_zip(client, ARCHIVE_URL, dest)

assert dest.read_bytes() == full
assert not archive_temp_path(dest).exists()
assert_files(tmp_path, ["999-hr.zip"])
with zipfile.ZipFile(dest) as zf:
assert zf.namelist() == ["BILLSTATUS-999hr1.xml"]

Expand Down
589 changes: 174 additions & 415 deletions tests/test_fetch_bill_archives_extract.py

Large diffs are not rendered by default.

51 changes: 17 additions & 34 deletions tests/test_fetch_bills.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
import argparse
import json
import time
import zipfile

import httpx
import pytest
import respx

import fetch_bills as fb
import fetch_govinfo as gi
from fetch_bills import (
api_get,
build_parser,
cmd_download,
cmd_download_all,
cmd_fetch_index,
congress_for_year,
download_all_versions,
download_version_xml,
Expand All @@ -27,10 +30,15 @@
save_version,
version_path,
)
from tests.utils import EMPTY_ZIP_BYTES, assert_files, mock_http_requests

TEST_API_KEY = "test-key"


def fetch_index(args: list[str]) -> int:
return cmd_fetch_index(client=None, args=build_parser().parse_args(["fetch-index"] + args), api_key=None)


def _govinfo_billstatus(congress: int, btype: str, number: int, *codes: str) -> bytes:
"""A govinfo BILLSTATUS body whose textVersions carry content/pkg URLs.

Expand Down Expand Up @@ -973,7 +981,6 @@ def test_valid_content_still_saves(self, tmp_path):

def _write_search_corpus(dirpath):
"""A minimal local BILLSTATUS ZIP with one approps + one non-approps bill."""
import zipfile

def doc(number, title, code):
return (
Expand Down Expand Up @@ -1037,7 +1044,6 @@ def test_facet_absent_returns_non_appropriations_bills(self, tmp_path, capsys):
assert "118-hr-5" in out

def test_congress_and_type_filters_narrow_the_index(self, tmp_path, capsys):
import zipfile

def doc(congress, btype, number, title):
return (
Expand Down Expand Up @@ -1158,34 +1164,12 @@ def test_requires_congress(self):
with pytest.raises(SystemExit):
build_parser().parse_args(["fetch-index"])

def test_wires_scoped_single_type_download(self, tmp_path, monkeypatch):
import fetch_bills

calls = {}

def fake_download(from_congress, to_congress, *, bill_types, destination):
calls.update(
from_congress=from_congress,
to_congress=to_congress,
bill_types=bill_types,
destination=destination,
)
(destination / "118-hr.zip").write_bytes(b"") # simulate the landed archive
return [destination / "118-hr.zip"]

monkeypatch.setattr(fetch_bills, "download_archives", fake_download)
args = build_parser().parse_args(
["fetch-index", "--congress", "118", "--type", "hr", "--billstatus-dir", str(tmp_path)]
)
from fetch_bills import cmd_fetch_index

rc = cmd_fetch_index(None, args, None)
assert rc == 0 # every requested archive present after the run
assert calls["from_congress"] == 118
assert calls["to_congress"] == 118 # single congress: the lightweight slice
assert calls["bill_types"] == ["hr"]
# Resolved against cwd so it points where `search` reads (not script-relative).
assert calls["destination"] == tmp_path.resolve()
@respx.mock
def test_single_congress_and_bill_type_download(self, tmp_path):
mock_http_requests(content=EMPTY_ZIP_BYTES)
rc = fetch_index(["--congress", "118", "--type", "hr", "--billstatus-dir", str(tmp_path)])
assert rc == 0
assert_files(tmp_path, {"BILLSTATUS-118-hr.zip"})
Comment on lines +1167 to +1172

@RolfHendriks RolfHendriks Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new model for unit tests:

Perform mocking. Ideally in a line or two using utilities

Invoke a function. In this case, a CLI command.

Check user-facing results. In this case, files created.

Aside from greatly simplifying the test, there is a behavioral change that required the rewrite: archive files now begin with BILLSTATUS to disambiguate them from BILLTEXT archives.

It is debatable whether we should verify zip files downloaded in any level of detail. They are an intermediate step, end their structure is not important. The important details to check are the extracted archive contents, the bill index file contents, cache behavior, and error handling.


def test_type_omitted_fetches_all_types_for_the_congress(self, tmp_path, monkeypatch):
import fetch_bills
Expand Down Expand Up @@ -1244,18 +1228,17 @@ def test_main_propagates_fetch_index_exit_code(self, tmp_path, monkeypatch):
fetch_bills.main()
assert exc.value.code == 1

@respx.mock
def test_main_routes_and_succeeds(self, tmp_path, monkeypatch):
# Happy-path routing: main() dispatches `fetch-index` to the download and exits 0
# when the archive lands.
import fetch_bills

monkeypatch.setattr(fetch_bills, "download_archives", _fake_download_landing("118-hr.zip"))
mock_http_requests(content=EMPTY_ZIP_BYTES)
monkeypatch.setattr(
"sys.argv",
["fetch_bills", "fetch-index", "--congress", "118", "--type", "hr", "--billstatus-dir", str(tmp_path)],
)
with pytest.raises(SystemExit) as exc:
fetch_bills.main()
fb.main()
assert exc.value.code == 0


Expand Down
2 changes: 1 addition & 1 deletion tests/test_surface_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def test_the_boundary_scan_actually_looked_at_something():
)

forbidden = _forbidden_names()
for expected in ("fetch_bills", "bill_index", "shared", "web"):
for expected in ("fetch_bills", "shared", "web"):
assert expected in forbidden, f"forbidden roster missed {expected!r} -- derivation is broken, not the code"


Expand Down
66 changes: 66 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Shared test helpers."""

from __future__ import annotations

import io
import re
import zipfile
from pathlib import Path

import respx

# Validation


def assert_files(folder: Path, files: set[str] | list[str]) -> None:
"""Assert the folder contains exactly the given filenames."""
__tracebackhide__ = True
actual = {path.name for path in folder.iterdir()}
expected = set(files)
if actual != expected:
extra = actual - expected
missing = expected - actual
raise AssertionError(
"\n".join(
filter(
None,
[
f"Unexpected file contents in folder {folder}:",
f"expected: {expected}",
f"actual: {actual}",
f"extra: {extra}" if extra else None,
f"missing: {missing}" if missing else None,
],
)
)
)
Comment on lines +15 to +36

@RolfHendriks RolfHendriks Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new utility is key for testing - it checks the full contents of a folder and outputs intuitive error text if expectations mismatch reality. Fetch_bill_archives unit tests make liberal use of this utility now.

Feel free to add a recursive option if needed, but I found it most intuitive to check one folder at a time instead.



# HTTP Mocking
def mock_http_requests(
url: re.Pattern[str] = re.compile(".*"),
status_code: int = 200,
content: bytes = b"",
**kwargs,
) -> None:
"""Mock matching GET requests with one response."""
return respx.get(url).respond(status_code, content=content, **kwargs)


# Zip File Mocking
def archive_bytes(members: dict[str, bytes] = {}) -> Path:
"""Write a well-formed ZIP named ``{name}.zip`` into source."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for member, body in members.items():
zf.writestr(member, body)
return buf.getvalue()


EMPTY_ZIP_BYTES = archive_bytes()


def write_archive(source: Path, name: str, members: dict[str, bytes] | None = None) -> Path:
"""Write a well-formed ZIP named ``{name}.zip`` into source."""
path = source / name
path.write_bytes(archive_bytes(name, members))
5 changes: 0 additions & 5 deletions tools/bill_index/__init__.py

This file was deleted.

Loading