From a111c430f31be561cfb2c531ef6a85c211324fef Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 29 Apr 2026 20:27:25 +0100 Subject: [PATCH] documents_api: implement OpenCDE Documents API 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /documents/1.0/document-versions resolves document_ids (base64url encoding of owner/repo/path) to versioned download URLs. Exposes only git tags + the current HEAD commit — mirrors CDE conventions where only released versions are visible, while still surfacing the WIP state. version_number is the tag name for releases and short SHA for HEAD. Tagged SHAs are deduplicated so HEAD at a tag doesn't create a duplicate. Download URLs point at Forgejo's existing raw/commit endpoint. Auth forwarded verbatim. No Go code, no Forgejo recompilation. Proxy at /documents/ on the Forgejo hostname. Co-Authored-By: Claude Sonnet 4.6 --- .beads/issues.jsonl | 2 +- README.md | 2 + ifcurl/documents_api.py | 160 ++++++++++++++++++++++++++++++++ ifcurl/service.py | 2 + tests/test_documents_api.py | 178 ++++++++++++++++++++++++++++++++++++ 5 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 ifcurl/documents_api.py create mode 100644 tests/test_documents_api.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 48cb38a..2692c86 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -98,7 +98,7 @@ {"id":"ifcurl-0oj","title":"Phase 4: Bonsai ifc:// OS protocol handler","description":"Register ifc:// as an OS protocol handler in Bonsai on installation (Windows, macOS, Linux). On receiving a URL, resolve the git source, load the model, and apply the view state from the URL parameters (camera, selector, clipping planes, visibility mode). This is the foundation for all other Phase 4 work.","status":"deferred","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:45Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:09Z","defer_until":"2026-12-01T00:00:00Z","dependency_count":0,"dependent_count":3,"comment_count":0} {"id":"ifcurl-cyw","title":"Phase 3c: Verify Forgejo merge-ability check works with ifcmerge","description":"Investigate whether Forgejo's merge-ability check (the 'branches can be merged automatically' indicator) correctly reflects ifcmerge's ability to handle .ifc files. Determine if Forgejo runs a trial git merge or uses a heuristic. If needed, patch Forgejo to run a trial git merge --no-commit to get an accurate result.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:27:25Z","started_at":"2026-04-24T08:19:58Z","closed_at":"2026-04-24T08:27:25Z","close_reason":"Verified: git merge-tree --write-tree (Forgejo's code path for git ≥2.38) does invoke merge drivers. Bare repos require core.attributesFile in /etc/gitconfig — committed .gitattributes not read. Added gitattributes server-config file and updated deployment docs.","dependencies":[{"issue_id":"ifcurl-cyw","depends_on_id":"ifcurl-c69","type":"blocks","created_at":"2026-04-23T06:48:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-c69","title":"Phase 3c: Register ifcmerge as git merge driver on Forgejo server","description":"Configure ifcmerge as the git merge driver for .ifc files on the Forgejo server. Add to /etc/gitconfig or ~forgejo/.gitconfig: [merge \"ifcmerge\"] name = IFC merge driver / driver = ifcmerge %O %A %B %L. Add *.ifc merge=ifcmerge to server-side gitattributes. This allows Forgejo's 'merge automatically' path to invoke ifcmerge via git when merging .ifc files.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:15:20Z","closed_at":"2026-04-24T08:15:20Z","close_reason":"Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement","comments":[{"id":"019dbc54-bf0a-76e2-b64d-7d1681f0c730","issue_id":"ifcurl-c69","author":"Bruno Postle","text":"ifcmerge needs to be installed as a pair of merge tools because the merge process is asymmetrical and we need to be able to prefer one branch over another (because step-ids from one branch get rewritten in the merged file). In practice we prefer origin and/or main/master branches. there is code in the ifcopenshell bonsai ifcgit module for reference.","created_at":"2026-04-23T21:52:44Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"id":"ifcurl-gxf","title":"Add minimal read-only OpenCDE Documents API","description":"Expose a minimal read-only OpenCDE Documents API that makes IFC files in git repos addressable as OpenCDE documents.\n\n## Architecture\n\nGo routes added to Forgejo alongside the existing patch. Purely additive — new endpoints only. Uses Forgejo's existing auth middleware so per-user repository permissions are enforced correctly with no extra machinery. Direct access to Forgejo's repo and git internals — no round-trips through the REST API.\n\n## Design\n\ndocument_id encodes owner/repo/path/commit-hash as a stable opaque string. version_index is derived from the commit's position in the git log. Implements POST /document-versions returning download URLs pointing to Forgejo's existing raw file endpoint for the resolved commit.\n\nThis validates the ifc:// CDE extension in SPECIFICATION.md §9 (document_id= parameter) and makes Forgejo discoverable as an OpenCDE server to any client that speaks the Documents API.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:30Z","created_by":"Bruno Postle","updated_at":"2026-04-28T23:10:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-gxf","title":"Add minimal read-only OpenCDE Documents API","description":"Expose a minimal read-only OpenCDE Documents API that makes IFC files in git repos addressable as OpenCDE documents.\n\n## Architecture\n\nPython routes in the preview service, proxied at /documents/ on the Forgejo hostname via nginx/Caddy. Same pattern as the BCF API. Auth forwarded verbatim (Authorization: Bearer) — Forgejo enforces per-user repo permissions. No Go code, no Forgejo recompilation.\n\n## Design\n\ndocument_id is a base64url encoding of 'owner/repo/path', making it stable and opaque. version_index is the 1-based position of the commit in the file's git history (oldest=1, newest=N), derived by querying Forgejo's commits API with the file path filter.\n\nPOST /documents/1.0/document-versions returns all versions (up to 50 commits) for each requested document_id. Download URLs point at Forgejo's existing raw file endpoint: {forgejo_host}/{owner}/{repo}/raw/commit/{sha}/{path}.\n\nThe public Forgejo base URL for download links is derived from X-Forwarded-Host/X-Forwarded-Proto headers, same as the Foundation endpoint.\n\nThis validates the ifc:// CDE extension in SPECIFICATION.md §9 (document_id= parameter) and makes Forgejo discoverable as an OpenCDE server to any client that speaks the Documents API.","status":"in_progress","priority":4,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-28T22:41:30Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:48:16Z","started_at":"2026-04-29T18:48:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"closed","priority":4,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-26T14:49:05Z","started_at":"2026-04-26T13:10:02Z","closed_at":"2026-04-26T14:49:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-nd1","title":"Submit provisional IANA registration for ifc:// scheme","description":"Once the Phase 1 URL spec is stable, submit a provisional IANA URI scheme registration for ifc://.\\n\\nProcess: email iana@iana.org with scheme name, syntax description, security considerations, and a link to the spec (GitHub URL suffices). First Come First Served — no committee review, typically processed within days to weeks. See RFC 7595 for requirements.\\n\\nThis secures the namespace, establishes priority, and is a prerequisite if the scheme is ever proposed to buildingSMART as an open standard.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T11:17:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-u6r","title":"Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state","description":"In the Forgejo repository commit list, each commit shows only the commit message. When a commit modifies an IFC file, there is no thumbnail showing what the model looks like. Reviewers browsing commits have no visual cues about which commits changed significant geometry versus metadata-only changes. A Forgejo webhook or CI integration could post a rendered PNG thumbnail as a commit status/check comment when .ifc files change, using the preview service. This would make the git log visually meaningful for BIM projects.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:41Z","created_by":"Bruno Postle","updated_at":"2026-04-25T20:07:21Z","closed_at":"2026-04-25T20:07:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 795f6b5..212def5 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ ifcurl serve --host 0.0.0.0 --port 9000 --allowed-hosts git.example.com | `GET /select?url=ifc://…` | Resolve a complex selector server-side; returns JSON list of GlobalIds | | `GET /render_diff?base=ifc://…&head=ifc://…` | Render a colour-coded diff PNG (added green, removed red) | | `GET /foundation/versions` | OpenCDE Foundation API discovery — lists BCF and Documents API endpoints (proxy at `/foundation/` on the Forgejo hostname) | +| `POST /documents/1.0/document-versions` | OpenCDE Documents API — resolve document_ids to versioned download URLs (proxied at `/documents/`) | | `GET /bcf/3.0/projects` | BCF 3.0 — list repositories as BCF projects | | `GET/POST /bcf/3.0/projects/{owner}/{repo}/topics` | BCF 3.0 — list or create topics (Forgejo issues) | | `GET/PUT /bcf/3.0/projects/{owner}/{repo}/topics/{guid}` | BCF 3.0 — get or update a topic | @@ -159,6 +160,7 @@ The BCF API forwards the client's `Authorization: Bearer` header to Forgejo's RE ```nginx location /foundation/ { proxy_pass http://localhost:8000/foundation/; } location /bcf/ { proxy_pass http://localhost:8000/bcf/; } +location /documents/ { proxy_pass http://localhost:8000/documents/; } ``` **Caching:** diff --git a/ifcurl/documents_api.py b/ifcurl/documents_api.py new file mode 100644 index 0000000..c28762b --- /dev/null +++ b/ifcurl/documents_api.py @@ -0,0 +1,160 @@ +# IFC URL — OpenCDE Documents API routes +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IFC URL. +# +# IFC URL is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IFC URL is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IFC URL. If not, see . + +"""OpenCDE Documents API 1.0 — read-only, minimal implementation. + +Routes are mounted at /documents/1.0 in service.py and proxied at +/documents/ on the Forgejo hostname via nginx/Caddy. + +document_id is a base64url encoding of "owner/repo/path", making it stable +and opaque to callers. + +Versions exposed: one per git tag (sorted chronologically, oldest = index 1), +plus the most recent HEAD commit if it is not already covered by a tag. This +mirrors CDE conventions where only released/tagged versions are visible, while +still surfacing the current work-in-progress state. + +version_number is the tag name for tagged versions and the short SHA for HEAD. + +Download URLs point at Forgejo's existing raw file endpoint: + {forgejo_host}/{owner}/{repo}/raw/commit/{sha}/{path} + +Auth is forwarded verbatim so Forgejo enforces per-user permissions. +""" + +from __future__ import annotations + +import base64 + +from fastapi import APIRouter, Body, Request +from fastapi.responses import JSONResponse + +from ifcurl.bcf_api import _auth, _fget + +router = APIRouter(prefix="/documents/1.0") + +# --------------------------------------------------------------------------- +# document_id encoding / decoding +# --------------------------------------------------------------------------- + +def encode_document_id(owner: str, repo: str, path: str) -> str: + """Encode owner/repo/path as a stable base64url document_id.""" + return base64.urlsafe_b64encode(f"{owner}/{repo}/{path}".encode()).rstrip(b"=").decode() + + +def decode_document_id(doc_id: str) -> tuple[str, str, str]: + """Decode a document_id back to (owner, repo, path). Raises ValueError if malformed.""" + padding = "=" * (4 - len(doc_id) % 4) if len(doc_id) % 4 else "" + try: + decoded = base64.urlsafe_b64decode(doc_id + padding).decode() + except Exception as exc: + raise ValueError(f"Invalid document_id: {doc_id!r}") from exc + parts = decoded.split("/", 2) + if len(parts) != 3 or not all(parts): + raise ValueError(f"Invalid document_id: {doc_id!r}") + return parts[0], parts[1], parts[2] + + +# --------------------------------------------------------------------------- +# Version enumeration +# --------------------------------------------------------------------------- + +def _versions_for_file(owner: str, repo: str, path: str, auth: str | None, forgejo_base: str) -> list[dict]: + """Return a list of BCF version dicts for the file, tagged releases + HEAD.""" + # Tagged versions — one entry per tag, sorted oldest-first by commit date. + tags = _fget(f"/api/v1/repos/{owner}/{repo}/tags", auth, params={"limit": 50}) + tags = tags if isinstance(tags, list) else [] + + seen_shas: set[str] = set() + versions: list[dict] = [] + + for tag in sorted(tags, key=lambda t: t.get("commit", {}).get("created", "")): + sha = (tag.get("commit") or {}).get("sha", "") + if not sha or sha in seen_shas: + continue + seen_shas.add(sha) + versions.append({ + "sha": sha, + "date": (tag.get("commit") or {}).get("created", ""), + "version_number": tag.get("name", sha[:8]), + "author_name": "", + "author_email": "", + }) + + # HEAD — add only if its commit SHA is not already represented by a tag. + head_commits = _fget( + f"/api/v1/repos/{owner}/{repo}/commits", + auth, + params={"path": path, "limit": 1, "sha": "HEAD"}, + ) + if isinstance(head_commits, list) and head_commits: + head = head_commits[0] + sha = head.get("sha", "") + if sha and sha not in seen_shas: + commit_meta = head.get("commit") or {} + author = commit_meta.get("author") or {} + committer = commit_meta.get("committer") or {} + versions.append({ + "sha": sha, + "date": committer.get("date") or author.get("date", ""), + "version_number": sha[:8], + "author_name": author.get("name", ""), + "author_email": author.get("email", ""), + }) + + return [ + { + "version_index": i, + "version_number": v["version_number"], + "creation_date": v["date"], + "created_by": {"id": v["author_email"], "name": v["author_name"]}, + "document_version_download": f"{forgejo_base}/{owner}/{repo}/raw/commit/{v['sha']}/{path}", + "document_version_upload": None, + } + for i, v in enumerate(versions, start=1) + ] + + +# --------------------------------------------------------------------------- +# Route +# --------------------------------------------------------------------------- + +@router.post("/document-versions") +def document_versions(request: Request, body: dict = Body(default={})) -> JSONResponse: + """Return tagged releases + HEAD for each requested document_id. + + Implements the OpenCDE Documents API resolution step (SPECIFICATION.md §9 + step 3). Tagged versions are indexed oldest=1, newest=N; HEAD is appended + as N+1 when it is not already tagged. version_number is the tag name for + releases and the short SHA for HEAD. + """ + auth = _auth(request) + host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost") + proto = request.headers.get("x-forwarded-proto", "https") + forgejo_base = f"{proto}://{host}" + + result = [] + for doc_id in body.get("document_ids", []): + try: + owner, repo, path = decode_document_id(doc_id) + except ValueError: + continue + for v in _versions_for_file(owner, repo, path, auth, forgejo_base): + result.append({"document_id": doc_id, **v}) + + return JSONResponse(result) diff --git a/ifcurl/service.py b/ifcurl/service.py index d6eaadc..8003ff2 100644 --- a/ifcurl/service.py +++ b/ifcurl/service.py @@ -53,6 +53,7 @@ from pydantic import BaseModel from ifcurl.auth import get_token_for_host from ifcurl.bcf import build_bcf from ifcurl.bcf_api import router as bcf_router +from ifcurl.documents_api import router as documents_router from ifcurl.git import diff_text as git_diff_text from ifcurl.git import fetch_ifc from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_select @@ -174,6 +175,7 @@ app = FastAPI( version="0.0.0", ) app.include_router(bcf_router) +app.include_router(documents_router) # --------------------------------------------------------------------------- # Rate limiting (in-process, per client IP, sliding window) diff --git a/tests/test_documents_api.py b/tests/test_documents_api.py new file mode 100644 index 0000000..1c88c86 --- /dev/null +++ b/tests/test_documents_api.py @@ -0,0 +1,178 @@ +"""Tests for ifcurl.documents_api — OpenCDE Documents API routes.""" + +from __future__ import annotations + +from unittest.mock import call, patch + +import pytest +from fastapi.testclient import TestClient + +from ifcurl.documents_api import decode_document_id, encode_document_id +from ifcurl.service import _rate_hits, app + +client = TestClient(app) + +OWNER, REPO, PATH = "alice", "myproject", "models/building.ifc" + +TAG_V1 = { + "name": "v1.0", + "commit": {"sha": "1111aaaa2222bbbb3333cccc4444dddd5555eeee", "created": "2026-01-01T10:00:00Z"}, +} +TAG_V2 = { + "name": "v2.0", + "commit": {"sha": "aaaa1111bbbb2222cccc3333dddd4444eeee5555", "created": "2026-02-01T10:00:00Z"}, +} +HEAD_COMMIT = { + "sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "commit": { + "author": {"name": "Alice", "email": "alice@example.com", "date": "2026-03-01T10:00:00Z"}, + "committer": {"name": "Alice", "email": "alice@example.com", "date": "2026-03-01T10:00:00Z"}, + "message": "WIP changes", + }, +} +# HEAD at same SHA as the latest tag — should not add a duplicate version +HEAD_AT_TAG = { + "sha": TAG_V2["commit"]["sha"], + "commit": { + "author": {"name": "Alice", "email": "alice@example.com", "date": "2026-02-01T10:00:00Z"}, + "committer": {"name": "Alice", "email": "alice@example.com", "date": "2026-02-01T10:00:00Z"}, + "message": "Release v2.0", + }, +} + + +@pytest.fixture(autouse=True) +def reset_rate_limit(): + _rate_hits.clear() + yield + _rate_hits.clear() + + +def _post(doc_ids, tags=None, head_commits=None, headers=None): + if tags is None: + tags = [TAG_V1, TAG_V2] + if head_commits is None: + head_commits = [HEAD_COMMIT] + + def mock_fget(path, auth, params=None): + if "/tags" in path: + return tags + if "/commits" in path: + return head_commits + return [] + + with patch("ifcurl.documents_api._fget", side_effect=mock_fget): + return client.post( + "/documents/1.0/document-versions", + json={"document_ids": doc_ids}, + headers=headers or {}, + ) + + +# --------------------------------------------------------------------------- +# document_id encoding +# --------------------------------------------------------------------------- + +class TestDocumentId: + def test_roundtrip(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + assert decode_document_id(doc_id) == (OWNER, REPO, PATH) + + def test_path_with_slashes(self): + doc_id = encode_document_id("alice", "proj", "a/b/c.ifc") + assert decode_document_id(doc_id) == ("alice", "proj", "a/b/c.ifc") + + def test_stable(self): + assert encode_document_id(OWNER, REPO, PATH) == encode_document_id(OWNER, REPO, PATH) + + def test_different_inputs_differ(self): + assert encode_document_id("alice", "a", "m.ifc") != encode_document_id("alice", "b", "m.ifc") + + def test_invalid_raises(self): + with pytest.raises(ValueError): + decode_document_id("!!!not-base64!!!") + + +# --------------------------------------------------------------------------- +# POST /documents/1.0/document-versions +# --------------------------------------------------------------------------- + +class TestDocumentVersions: + def test_returns_200(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + assert r.status_code == 200 + + def test_two_tags_plus_head_give_three_versions(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + assert len(r.json()) == 3 + + def test_tag_names_used_as_version_number(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + version_numbers = {v["version_number"] for v in r.json()} + assert "v1.0" in version_numbers + assert "v2.0" in version_numbers + + def test_head_version_number_is_short_sha(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + versions = sorted(r.json(), key=lambda v: v["version_index"]) + assert versions[-1]["version_number"] == HEAD_COMMIT["sha"][:8] + + def test_oldest_tag_is_version_index_1(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + versions = sorted(r.json(), key=lambda v: v["version_index"]) + assert versions[0]["version_number"] == "v1.0" + + def test_head_at_same_sha_as_tag_not_duplicated(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id], head_commits=[HEAD_AT_TAG]) + assert len(r.json()) == 2 # only the two tags, HEAD deduplicated + + def test_no_tags_just_head(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id], tags=[]) + versions = r.json() + assert len(versions) == 1 + assert versions[0]["version_number"] == HEAD_COMMIT["sha"][:8] + + def test_download_url_uses_commit_sha(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + for v in r.json(): + assert "/raw/commit/" in v["document_version_download"] + + def test_download_url_contains_path(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + for v in r.json(): + assert PATH in v["document_version_download"] + + def test_download_url_uses_forwarded_host(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id], headers={"x-forwarded-host": "git.example.com", "x-forwarded-proto": "https"}) + for v in r.json(): + assert v["document_version_download"].startswith("https://git.example.com/") + + def test_document_id_preserved(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id]) + for v in r.json(): + assert v["document_id"] == doc_id + + def test_invalid_document_id_skipped(self): + valid_id = encode_document_id(OWNER, REPO, PATH) + r = _post(["not-valid!!!!", valid_id]) + assert all(v["document_id"] == valid_id for v in r.json()) + + def test_empty_document_ids(self): + r = _post([]) + assert r.json() == [] + + def test_no_commits_and_no_tags_gives_no_versions(self): + doc_id = encode_document_id(OWNER, REPO, PATH) + r = _post([doc_id], tags=[], head_commits=[]) + assert r.json() == []