mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-15 13:36:35 +03:00
27 tests across five classes: help output, argument errors, dry-run plans, mocked-client logic (merge-stub following with .json suffix repair, work-author fallback for authors:null editions, editions paging, ratings join, covers-host URLs, empty-results-as-success), plus two live probes gated behind OPENLIBRARY_LIVE_TESTS=1 that skip cleanly otherwise. Passes strict-markers pytest, unittest discovery, and the proxy-trap zero-egress rerun. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
372 lines
15 KiB
Python
372 lines
15 KiB
Python
"""Offline tests for the bundled openlibrary CLI (scripts/openlibrary).
|
|
|
|
Four test classes per skill-builder contract:
|
|
1. --help output
|
|
2. argument-error paths
|
|
3. --dry-run behavior
|
|
4. mocked-client logic (requests mocked at the client-call site)
|
|
|
|
Plus one env-guarded live class: Open Library is a keyless public API, so a
|
|
small bounded set of live GETs runs ONLY when OPENLIBRARY_LIVE_TESTS=1; they
|
|
skip cleanly otherwise (proxy-trap reruns pass with them skipped).
|
|
"""
|
|
|
|
import contextlib
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import requests
|
|
|
|
SCRIPT = pathlib.Path(__file__).resolve().parent / "openlibrary"
|
|
LOADER = importlib.machinery.SourceFileLoader("openlibrary_cli", str(SCRIPT))
|
|
SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
|
|
ol_cli = importlib.util.module_from_spec(SPEC)
|
|
LOADER.exec_module(ol_cli)
|
|
|
|
EDITION_KEY = "/books/" + "OL34854896M"
|
|
WORK_KEY = "/works/" + "OL1168083W"
|
|
AUTHOR_KEY = "/authors/" + "OL118077A"
|
|
|
|
|
|
def run_cli(*argv):
|
|
"""Invoke main() with argv[0] prepended; returns (exit_code, stdout, stderr)."""
|
|
out, err = io.StringIO(), io.StringIO()
|
|
code = 0
|
|
with mock.patch.object(ol_cli.sys, "argv", ["openlibrary", *argv]):
|
|
with mock.patch.object(ol_cli.sys, "stdout", out), \
|
|
mock.patch.object(ol_cli.sys, "stderr", err), \
|
|
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
try:
|
|
ol_cli.main()
|
|
except SystemExit as exc:
|
|
code = exc.code if isinstance(exc.code, int) else 0
|
|
return code, out.getvalue(), err.getvalue()
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code=200, payload=None, text="", headers=None,
|
|
url="https://openlibrary.org/x"):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.text = text or (json.dumps(payload) if payload is not None else "")
|
|
self.headers = headers or {}
|
|
self.url = url
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
|
|
# === Class 1: help output ===
|
|
|
|
|
|
class HelpOutputTests(unittest.TestCase):
|
|
def test_help_lists_all_subcommands(self):
|
|
code, out, _ = run_cli("--help")
|
|
self.assertEqual(code, 0)
|
|
for noun in ("search", "search-authors", "author", "work",
|
|
"isbn", "editions", "ratings"):
|
|
self.assertIn(noun, out)
|
|
|
|
def test_help_mentions_keyless_setup(self):
|
|
_, out, _ = run_cli("--help")
|
|
self.assertIn("No API key", out)
|
|
|
|
def test_subcommand_help_mentions_flags(self):
|
|
_, out, _ = run_cli("search", "--help")
|
|
for flag in ("--query", "--limit", "--offset", "--sort", "--lang"):
|
|
self.assertIn(flag, out)
|
|
|
|
def test_editions_help_documents_pagination(self):
|
|
_, out, _ = run_cli("editions", "--help")
|
|
self.assertIn("--limit", out)
|
|
self.assertIn("--offset", out)
|
|
|
|
|
|
# === Class 2: argument errors ===
|
|
|
|
|
|
class ArgumentErrorTests(unittest.TestCase):
|
|
def test_search_requires_query(self):
|
|
code, _, err = run_cli("search")
|
|
self.assertEqual(code, 2)
|
|
self.assertIn("--query", err)
|
|
|
|
def test_no_command_prints_help_and_exits(self):
|
|
code, out, _ = run_cli()
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("usage:", out)
|
|
|
|
def test_unknown_subcommand_fails(self):
|
|
code, _, err = run_cli("frobnicate")
|
|
self.assertEqual(code, 2)
|
|
self.assertIn("invalid choice", err)
|
|
|
|
def test_sort_rejects_unknown_values_client_side(self):
|
|
# A bogus sort value makes Open Library return a plain-text HTTP 500,
|
|
# so the CLI validates sort choices before ever sending.
|
|
code, _, err = run_cli("search", "--query", "dune", "--sort", "bogus")
|
|
self.assertEqual(code, 2)
|
|
self.assertIn("--sort", err)
|
|
|
|
|
|
# === Class 3: dry-run behavior ===
|
|
|
|
|
|
class DryRunTests(unittest.TestCase):
|
|
def test_dry_run_isbn_reports_302_resolution_plan(self):
|
|
code, out, _ = run_cli("--dry-run", "--json", "isbn", "9780451524935")
|
|
self.assertEqual(code, 0)
|
|
plan = json.loads(out)
|
|
self.assertTrue(plan["dry_run"])
|
|
self.assertEqual(plan["command"], "isbn")
|
|
self.assertIn("/isbn/9780451524935.json", plan["url"])
|
|
self.assertIn("302", plan["note"])
|
|
|
|
def test_dry_run_editions_emits_query_params(self):
|
|
code, out, _ = run_cli("--json", "--dry-run", "editions",
|
|
WORK_KEY, "--limit", "5")
|
|
self.assertEqual(code, 0)
|
|
plan = json.loads(out)
|
|
self.assertEqual(plan["key"], "OL1168083W")
|
|
self.assertEqual(plan["params"]["limit"], 5)
|
|
|
|
def test_dry_run_ratings_plans_both_endpoints(self):
|
|
code, out, _ = run_cli("--dry-run", "--json", "ratings", "OL45804W")
|
|
self.assertEqual(code, 0)
|
|
plan = json.loads(out)
|
|
joined = " ".join(plan["urls"])
|
|
self.assertIn("/ratings.json", joined)
|
|
self.assertIn("/bookshelves.json", joined)
|
|
|
|
def test_dry_run_never_touches_network(self):
|
|
with mock.patch.object(requests, "get") as req:
|
|
code, _, _ = run_cli("--dry-run", "work", WORK_KEY)
|
|
self.assertEqual(code, 0)
|
|
req.assert_not_called()
|
|
|
|
|
|
# === Class 4: mocked client logic ===
|
|
|
|
|
|
EDITION_RECORD = {
|
|
"type": {"key": "/type/edition"},
|
|
"key": EDITION_KEY,
|
|
"title": "Nineteen Eighty-Four",
|
|
"authors": None, # real records ship authors:null sometimes
|
|
"works": [{"key": WORK_KEY}],
|
|
"covers": [12054527, -1],
|
|
"number_of_pages": 328,
|
|
"publish_date": "1993?",
|
|
"publishers": ["Signet Classics"],
|
|
"description": {"type": "/type/text", "value": "A dystopian classic."},
|
|
}
|
|
|
|
WORK_RECORD = {
|
|
"type": {"key": "/type/work"},
|
|
"key": WORK_KEY,
|
|
"title": "Nineteen Eighty-Four",
|
|
"authors": [{"author": {"key": AUTHOR_KEY},
|
|
"type": {"key": "/type/author_role"}}],
|
|
"subjects": ["Totalitarianism"],
|
|
"description": "A dystopian classic.",
|
|
"covers": [-1],
|
|
}
|
|
|
|
|
|
class MockedClientTests(unittest.TestCase):
|
|
"""Mock requests.get at the client-call site; zero network in this class."""
|
|
|
|
def setUp(self):
|
|
ol_cli.QUIET = False
|
|
ol_cli.GLOBAL_FLAGS.update(json=True, dry_run=False, quiet=False)
|
|
|
|
def test_isbn_surfaces_resolved_edition_and_work_keys(self):
|
|
# The edition record carries works[] but authors:null, so the CLI
|
|
# follows the work link once to recover author keys.
|
|
edition = FakeResponse(
|
|
200, EDITION_RECORD,
|
|
url="https://openlibrary.org/books/" + "OL34854896M.json")
|
|
work = FakeResponse(200, WORK_RECORD)
|
|
with mock.patch.object(requests, "get", side_effect=[edition, work]) as req:
|
|
code, out, _ = run_cli("--json", "isbn", "9780451524935")
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(req.call_count, 2)
|
|
data = json.loads(out)
|
|
self.assertEqual(data["edition_key"], "OL34854896M")
|
|
self.assertEqual(data["work_keys"], ["OL1168083W"])
|
|
# Cover URLs point at the SEPARATE covers host, skipping -1 placeholders.
|
|
self.assertEqual(data["cover_url"], (
|
|
"https://covers.openlibrary.org/b/id/"
|
|
+ "12054527-M.jpg"))
|
|
|
|
def test_isbn_falls_back_to_work_authors_when_edition_has_none(self):
|
|
edition = FakeResponse(200, EDITION_RECORD)
|
|
work = FakeResponse(200, WORK_RECORD)
|
|
with mock.patch.object(requests, "get", side_effect=[edition, work]) as req:
|
|
code, out, _ = run_cli("--json", "isbn", "9780451524935")
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(req.call_count, 2)
|
|
self.assertTrue(req.call_args_list[1].args[0].endswith(WORK_KEY + ".json"))
|
|
self.assertEqual(json.loads(out)["authors"], "OL118077A")
|
|
|
|
def test_merge_redirect_stub_in_http_200_is_followed_with_json_suffix(self):
|
|
# Merged-away keys answer 200 with {type:/type/redirect, location},
|
|
# NOT a 3xx — the client must detect the stub and refetch. Stub
|
|
# locations are bare keys without .json; extension-less URLs redirect
|
|
# to HTML pages, so the client must append the suffix itself.
|
|
stub = FakeResponse(200, {"type": {"key": "/type/redirect"},
|
|
"location": WORK_KEY})
|
|
work = FakeResponse(200, WORK_RECORD)
|
|
with mock.patch.object(requests, "get", side_effect=[stub, work]) as req:
|
|
code, out, _ = run_cli("--json", "work", "OL24776360W")
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(
|
|
req.call_args_list[1].args[0],
|
|
"https://openlibrary.org" + WORK_KEY + ".json")
|
|
self.assertEqual(json.loads(out)["title"], "Nineteen Eighty-Four")
|
|
|
|
def test_text_wrapper_dict_is_unwrapped(self):
|
|
self.assertEqual(ol_cli.unwrap_text({"type": "/type/text", "value": "hi"}), "hi")
|
|
self.assertEqual(ol_cli.unwrap_text("plain"), "plain")
|
|
self.assertEqual(ol_cli.unwrap_text(None), "")
|
|
|
|
def test_normalize_olid_accepts_bare_and_path_forms(self):
|
|
self.assertEqual(ol_cli.normalize_olid("/works/" + "OL123W"), "OL123W")
|
|
self.assertEqual(ol_cli.normalize_olid("OL23919A"), "OL23919A")
|
|
self.assertEqual(ol_cli.normalize_olid(""), "")
|
|
|
|
def test_cover_url_rejects_negative_placeholder_ids(self):
|
|
self.assertIsNone(ol_cli.cover_url("b/id", -1))
|
|
self.assertIsNone(ol_cli.cover_url("a/id", None))
|
|
self.assertTrue(ol_cli.cover_url("b/id", 12054527).startswith(
|
|
"https://covers.openlibrary.org/b/id/"))
|
|
|
|
def test_search_sends_query_and_sort_params(self):
|
|
payload = {"numFound": 1, "docs": [
|
|
{"key": "/works/" + "OL1W", "title": "Dune",
|
|
"author_name": ["Frank Herbert"], "first_publish_year": 1965,
|
|
"edition_count": 90, "cover_edition_key": "OL1M",
|
|
"has_fulltext": True}]}
|
|
with mock.patch.object(requests, "get",
|
|
return_value=FakeResponse(200, payload)) as req:
|
|
code, out, _ = run_cli("--json", "search", "--query", "dune",
|
|
"--limit", "2", "--sort", "editions")
|
|
self.assertEqual(code, 0)
|
|
req.assert_called_once()
|
|
sent = req.call_args.kwargs["params"]
|
|
self.assertEqual(sent["q"], "dune")
|
|
self.assertEqual(sent["sort"], "editions")
|
|
data = json.loads(out)
|
|
self.assertEqual(data["total"], 1)
|
|
self.assertEqual(data["results"][0]["key"], "/works/" + "OL1W")
|
|
|
|
def test_empty_search_results_are_success_not_error(self):
|
|
# Malformed queries parse loosely and return 200-empty; the CLI must
|
|
# report zero results without failing.
|
|
payload = {"numFound": 0, "docs": []}
|
|
with mock.patch.object(requests, "get",
|
|
return_value=FakeResponse(200, payload)):
|
|
code, out, _ = run_cli("--json", "search", "--query", 'title:"unclosed')
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out)["total"], 0)
|
|
|
|
def test_editions_parses_entries_and_computes_next_offset(self):
|
|
payload = {"size": 6,
|
|
"links": {"self": "/works/x/editions.json?limit=3",
|
|
"next": "/works/x/editions.json?limit=3&offset=3"},
|
|
"entries": [
|
|
{"key": "/books/" + "OL1M", "title": "Ed. One",
|
|
"publishers": ["Ace"], "publish_date": "1965",
|
|
"isbn_13": ["9780000000002"]},
|
|
{"key": "/books/" + "OL2M", "title": "Ed. Two",
|
|
"publishers": [], "publish_date": "1980", "isbn_13": []},
|
|
{"key": "/books/" + "OL3M", "title": "Ed. Three",
|
|
"publishers": ["NEL"], "publish_date": "1974",
|
|
"isbn_13": ["9781111111113"]},
|
|
]}
|
|
with mock.patch.object(requests, "get",
|
|
return_value=FakeResponse(200, payload)) as req:
|
|
code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3")
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(req.call_args.kwargs["params"]["offset"], 0)
|
|
data = json.loads(out)
|
|
self.assertEqual(data["size"], 6)
|
|
self.assertEqual(len(data["editions"]), 3)
|
|
self.assertEqual(data["next_offset"], 3)
|
|
|
|
def test_ratings_joins_ratings_and_bookshelves(self):
|
|
ratings = FakeResponse(200, {"summary": {"average": 3.97, "count": 119},
|
|
"counts": {"5": 56, "4": 29}})
|
|
shelves = FakeResponse(200, {"counts": {"want_to_read": 1191,
|
|
"currently_reading": 97}})
|
|
with mock.patch.object(requests, "get", side_effect=[ratings, shelves]) as req:
|
|
code, out, _ = run_cli("--json", "ratings", "OL45804W")
|
|
self.assertEqual(code, 0)
|
|
self.assertTrue(req.call_args_list[0].args[0].endswith("/ratings.json"))
|
|
self.assertTrue(req.call_args_list[1].args[0].endswith("/bookshelves.json"))
|
|
data = json.loads(out)
|
|
self.assertAlmostEqual(data["average"], 3.97)
|
|
self.assertEqual(data["bookshelves"]["want_to_read"], 1191)
|
|
|
|
def test_404_reports_not_found_without_traceback(self):
|
|
with mock.patch.object(requests, "get",
|
|
return_value=FakeResponse(404, None)):
|
|
code, out, _ = run_cli("work", "OL999999999W")
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("not found", out.lower())
|
|
|
|
def test_server_error_names_status_and_dies(self):
|
|
# run_cli captures SystemExit; a 500 must exit 1 with the status named.
|
|
with mock.patch.object(requests, "get",
|
|
return_value=FakeResponse(500, text="Internal Server Error")):
|
|
code, _, err = run_cli("work", "OL1W")
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("500", err)
|
|
|
|
def test_user_agent_carries_mailto_when_email_configured(self):
|
|
original = ol_cli.ENV_EMAIL
|
|
try:
|
|
ol_cli.ENV_EMAIL = "reader@example.org"
|
|
resp = FakeResponse(200, WORK_RECORD)
|
|
with mock.patch.object(requests, "get", return_value=resp) as req:
|
|
run_cli("work", WORK_KEY)
|
|
ua = req.call_args.kwargs["headers"]["User-Agent"]
|
|
self.assertIn("(mailto:reader@example.org)", ua)
|
|
finally:
|
|
ol_cli.ENV_EMAIL = original
|
|
|
|
|
|
# === Class 5: env-guarded live probes (keyless public API) ===
|
|
# Run only with OPENLIBRARY_LIVE_TESTS=1; skipped otherwise so the proxy-trap
|
|
# rerun proves zero egress for everything above.
|
|
|
|
|
|
@unittest.skipUnless(os.getenv("OPENLIBRARY_LIVE_TESTS") == "1",
|
|
"live probes disabled (set OPENLIBRARY_LIVE_TESTS=1)")
|
|
class LiveGuardedTests(unittest.TestCase):
|
|
def test_live_isbn_resolves_through_302(self):
|
|
code, out, _ = run_cli("--json", "isbn", "9780451524935")
|
|
self.assertEqual(code, 0)
|
|
data = json.loads(out)
|
|
self.assertRegex(data["edition_key"], r"^OL\d+M$")
|
|
self.assertRegex(data["work_keys"][0], r"^OL\d+W$")
|
|
self.assertTrue(data["title"])
|
|
|
|
def test_live_editions_listing_returns_entries(self):
|
|
code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3")
|
|
self.assertEqual(code, 0)
|
|
data = json.loads(out)
|
|
self.assertGreaterEqual(data["size"], 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|