diff --git a/.gitattributes b/.gitattributes index f8975c9..dd5054a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,8 @@ # checksums and making every refresh from upstream show a whole-file diff. src/oold/validation/meta/*/** -text tests/data/oold/** -text + +# tests/data/resolver_cache/ holds a Resolver disk-cache entry, named by the sha256 of a fixed +# URL. It carries no upstream checksum, but the same autocrlf rewrite would still turn it into +# a CRLF file, which is not what the "written with LF" test fixture convention expects. +tests/data/resolver_cache/** -text diff --git a/tests/data/resolver_cache/66a1009a2cf528dd525ec0c0150488fe.json b/tests/data/resolver_cache/66a1009a2cf528dd525ec0c0150488fe.json new file mode 100644 index 0000000..a17f01d --- /dev/null +++ b/tests/data/resolver_cache/66a1009a2cf528dd525ec0c0150488fe.json @@ -0,0 +1,5 @@ +{ + "$id": "https://example.org/oold-warm-cache-fixture.schema.json", + "title": "Warm", + "type": "object" +} diff --git a/tests/test_validation/conftest.py b/tests/test_validation/conftest.py index 1bb4ec7..c8fe257 100644 --- a/tests/test_validation/conftest.py +++ b/tests/test_validation/conftest.py @@ -13,6 +13,7 @@ COMPLIANCE = DATA / "compliance" REMOTE_CONTEXT = DATA / "remote_context" X_OOLD_CONTEXT = DATA / "x_oold_context" +RESOLVER_CACHE = DATA.parent / "resolver_cache" @pytest.fixture @@ -43,6 +44,16 @@ def x_oold_context_dir() -> Path: return X_OOLD_CONTEXT +@pytest.fixture +def resolver_cache_dir() -> Path: + """A committed `Resolver` disk-cache entry, named by the sha256 digest of a fixed URL. + + Proves a warmed cache satisfies an offline fetch without a network mock standing in for the + retrieval layer itself. + """ + return RESOLVER_CACHE + + @pytest.fixture def isolated_cache(tmp_path, monkeypatch) -> Path: """Point the document and meta caches at a temporary directory. diff --git a/tests/test_validation/test_pipeline.py b/tests/test_validation/test_pipeline.py index 91dd81d..037c25f 100644 --- a/tests/test_validation/test_pipeline.py +++ b/tests/test_validation/test_pipeline.py @@ -2,6 +2,11 @@ from __future__ import annotations +import functools +import http.server +import json +import threading + import pytest from oold.validation import Options, run_compliance, validate_directory, validate_instance, validate_schema @@ -67,6 +72,85 @@ def test_a_context_chain_leaving_the_directory_resolves(remote_context_dir): assert "context.remote" in _ids(report, OK) +def test_a_genuinely_remote_context_is_fetched_over_http(tmp_path): + """`remote_context_dir` above only ever leaves the *directory*; nothing in the corpus makes + an `@context` chain leave the filesystem, so `Resolver` never actually opens a socket. That + matters because socket retrieval is exactly the layer #119 proposes moving onto + `referencing`, and no fixture touching it means a regression there would pass CI. + + A loopback HTTP server, bound to an OS-assigned port and shut down in a `finally`, is the + genuinely remote sibling: the schema and its remote context are templated into `tmp_path` + (a committed fixture cannot carry a port that varies per run), and the handler records every + request it serves, so the assertion below is that the context was actually fetched over the + wire, not resolved from disk by accident. + """ + served = tmp_path / "served" + served.mkdir() + requested: list[str] = [] + + class RecordingHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self) -> None: + requested.append(self.path) + super().do_GET() + + def log_message(self, format: str, *args: object) -> None: + pass # keep the test's own output free of per-request access logging + + handler = functools.partial(RecordingHandler, directory=str(served)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + base_url = f"http://127.0.0.1:{server.server_port}/" + (served / "Thing.schema.json").write_text( + json.dumps({ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "Thing.schema.json", + "x-oold-instance-rdf-type": ["schema:Thing"], + "@context": {"schema": "http://schema.org/", "name": "schema:name"}, + "title": "Thing", + "type": "object", + "properties": {"name": {"type": "string"}}, + }), + encoding="utf-8", + ) + + case_dir = tmp_path / "case" + case_dir.mkdir() + (case_dir / "RemoteLeaf.schema.json").write_text( + json.dumps({ + "$schema": "https://oo-ld.org/latest/meta/oold-meta-schema.json", + "$id": "RemoteLeaf.schema.json", + "title": "RemoteLeaf", + "x-oold-instance-rdf-type": ["schema:Thing"], + "@context": [ + f"{base_url}Thing.schema.json", + {"schema": "http://schema.org/", "nickname": "schema:alternateName"}, + ], + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}, "nickname": {"type": "string"}}, + }), + encoding="utf-8", + ) + + report = validate_schema( + case_dir / "RemoteLeaf.schema.json", + Options(meta=("latest",), offline=False, cache_dir=tmp_path / "cache"), + ) + finally: + server.shutdown() + server.server_close() + thread.join() + + assert report.passed, [f"{c.id}: {c.message}" for c in report.failures()] + assert "context.remote" in _ids(report, OK) + assert any(path.endswith("Thing.schema.json") for path in requested), ( + f"the context was never actually requested over the socket: {requested!r}" + ) + + def test_a_property_mapped_only_through_x_oold_context_is_not_reported(x_oold_context_dir): """x-oold-context (OOLD-EXT-4966) is a legitimate way to map a term; one mapped only there must not be reported as unmapped just because plain JSON-LD cannot see that mapping.""" diff --git a/tests/test_validation/test_resolve.py b/tests/test_validation/test_resolve.py index bdf04ce..4b4c480 100644 --- a/tests/test_validation/test_resolve.py +++ b/tests/test_validation/test_resolve.py @@ -241,6 +241,22 @@ def test_properties_nesting_is_depth_neutral(): assert bound_schema(nested, max_depth=2) == nested +def test_a_warm_cache_entry_resolves_offline_and_a_missing_one_is_refused(resolver_cache_dir): + """`tests/data/resolver_cache/` commits one entry ahead of time, named by the sha256 digest + `Resolver._cache_file` itself derives from the URL. That is what lets this prove the offline + guarantee the vendored meta-schemas depend on - a previously warmed cache satisfies a fetch - + without a network mock standing in for the retrieval layer #119 is about to move onto + `referencing`. + """ + resolver = Resolver(cache_dir=resolver_cache_dir, offline=True) + + document = resolver.fetch("https://example.org/oold-warm-cache-fixture.schema.json") + assert document["title"] == "Warm" + + with pytest.raises(SchemaResolutionError, match="offline"): + resolver.fetch("https://example.org/oold-warm-cache-fixture-missing.schema.json") + + def test_disk_cache_is_reused(tmp_path, monkeypatch): cache = tmp_path / "cache" calls = []