mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-05-17 19:35:13 +02:00
Fix 5 cascading bugs in the Library API wrapper that prevented the get_documents → update_document round-trip from working: - Tolerate missing title field in document metadata (use .get()) - Use attribute access on Triple objects instead of subscript - Serialize datetime to int seconds for JSON compatibility - Handle empty server response on successful update - Send both id and document-id keys in update request Added library API tests
This commit is contained in:
parent
913f610db5
commit
aea4c2df8e
2 changed files with 312 additions and 11 deletions
296
tests/unit/test_api/test_library_api.py
Normal file
296
tests/unit/test_api/test_library_api.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
Tests for the Library API wrapper round-trip behavior.
|
||||
Covers the get_documents → update_document path and edge cases
|
||||
from issue #893.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from trustgraph.api.library import Library, to_value, from_value
|
||||
from trustgraph.api.types import DocumentMetadata, Triple
|
||||
from trustgraph.knowledge import Uri, Literal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_library(response=None):
|
||||
api = MagicMock()
|
||||
api.workspace = "default"
|
||||
api.request.return_value = response or {}
|
||||
lib = Library(api)
|
||||
return lib, api
|
||||
|
||||
|
||||
def _wire_triple(s_iri, p_iri, o_val):
|
||||
return {
|
||||
"s": {"t": "i", "i": s_iri},
|
||||
"p": {"t": "i", "i": p_iri},
|
||||
"o": {"t": "l", "v": o_val},
|
||||
}
|
||||
|
||||
|
||||
def _doc_wire(id="doc-1", time=1700000000, title="Test Doc",
|
||||
kind="text/plain", comments="", tags=None,
|
||||
metadata=None, parent_id="", document_type="source",
|
||||
include_title=True):
|
||||
doc = {
|
||||
"id": id,
|
||||
"time": time,
|
||||
"kind": kind,
|
||||
"comments": comments,
|
||||
"metadata": metadata or [],
|
||||
"tags": tags or [],
|
||||
"parent-id": parent_id,
|
||||
"document-type": document_type,
|
||||
}
|
||||
if include_title:
|
||||
doc["title"] = title
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 1: get_documents tolerates missing title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetDocumentsMissingTitle:
|
||||
|
||||
def test_missing_title_defaults_to_empty(self):
|
||||
doc = _doc_wire(include_title=False)
|
||||
lib, api = _make_library({"document-metadatas": [doc]})
|
||||
|
||||
result = lib.get_documents()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].title == ""
|
||||
|
||||
def test_present_title_preserved(self):
|
||||
doc = _doc_wire(title="My Title")
|
||||
lib, api = _make_library({"document-metadatas": [doc]})
|
||||
|
||||
result = lib.get_documents()
|
||||
|
||||
assert result[0].title == "My Title"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2: update_document handles Triple objects (attribute access)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateDocumentTripleAccess:
|
||||
|
||||
def test_triple_objects_serialized_correctly(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(1700000000),
|
||||
kind="text/plain",
|
||||
title="Test",
|
||||
comments="",
|
||||
metadata=[
|
||||
Triple(
|
||||
s=Uri("http://example.org/entity/alice"),
|
||||
p=Uri("http://example.org/rel/knows"),
|
||||
o=Literal("Bob"),
|
||||
),
|
||||
],
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
call_args = api.request.call_args[0][1]
|
||||
triples = call_args["document-metadata"]["metadata"]
|
||||
|
||||
assert len(triples) == 1
|
||||
assert triples[0]["s"]["i"] == "http://example.org/entity/alice"
|
||||
assert triples[0]["p"]["i"] == "http://example.org/rel/knows"
|
||||
assert triples[0]["o"]["v"] == "Bob"
|
||||
|
||||
def test_empty_metadata_list(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(1700000000),
|
||||
kind="text/plain",
|
||||
title="Test",
|
||||
comments="",
|
||||
metadata=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
call_args = api.request.call_args[0][1]
|
||||
assert call_args["document-metadata"]["metadata"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3: update_document serializes datetime to int seconds
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateDocumentTimeSerialization:
|
||||
|
||||
def test_datetime_serialized_to_int(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
ts = 1700000000
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(ts),
|
||||
kind="text/plain",
|
||||
title="Test",
|
||||
comments="",
|
||||
metadata=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
call_args = api.request.call_args[0][1]
|
||||
wire_time = call_args["document-metadata"]["time"]
|
||||
|
||||
assert isinstance(wire_time, int)
|
||||
assert wire_time == ts
|
||||
|
||||
def test_int_time_passed_through(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=1700000000,
|
||||
kind="text/plain",
|
||||
title="Test",
|
||||
comments="",
|
||||
metadata=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
call_args = api.request.call_args[0][1]
|
||||
assert call_args["document-metadata"]["time"] == 1700000000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 4: update_document handles empty server response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateDocumentEmptyResponse:
|
||||
|
||||
def test_empty_response_returns_input_metadata(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(1700000000),
|
||||
kind="text/plain",
|
||||
title="Updated Title",
|
||||
comments="notes",
|
||||
metadata=[],
|
||||
tags=["a"],
|
||||
)
|
||||
|
||||
result = lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
assert result is metadata
|
||||
|
||||
def test_full_response_parsed(self):
|
||||
response_doc = _doc_wire(
|
||||
id="doc-1", title="Server Title", tags=["b"],
|
||||
)
|
||||
lib, api = _make_library({"document-metadata": response_doc})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(1700000000),
|
||||
kind="text/plain",
|
||||
title="Client Title",
|
||||
comments="",
|
||||
metadata=[],
|
||||
tags=["a"],
|
||||
)
|
||||
|
||||
result = lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
assert result.title == "Server Title"
|
||||
assert result.tags == ["b"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 5: update_document sends both id and document-id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateDocumentIdKeys:
|
||||
|
||||
def test_both_id_keys_sent(self):
|
||||
lib, api = _make_library({})
|
||||
|
||||
metadata = DocumentMetadata(
|
||||
id="doc-1",
|
||||
time=datetime.datetime.fromtimestamp(1700000000),
|
||||
kind="text/plain",
|
||||
title="Test",
|
||||
comments="",
|
||||
metadata=[],
|
||||
tags=[],
|
||||
)
|
||||
|
||||
lib.update_document(id="doc-1", metadata=metadata)
|
||||
|
||||
call_args = api.request.call_args[0][1]
|
||||
doc_meta = call_args["document-metadata"]
|
||||
|
||||
assert doc_meta["id"] == "doc-1"
|
||||
assert doc_meta["document-id"] == "doc-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-trip: get_documents → update_document
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetUpdateRoundTrip:
|
||||
|
||||
def test_full_round_trip(self):
|
||||
wire_doc = _doc_wire(
|
||||
id="doc-42",
|
||||
title="Original",
|
||||
tags=["v1"],
|
||||
metadata=[_wire_triple(
|
||||
"http://example.org/e/1",
|
||||
"http://example.org/r/type",
|
||||
"report",
|
||||
)],
|
||||
)
|
||||
|
||||
lib, api = _make_library({"document-metadatas": [wire_doc]})
|
||||
|
||||
docs = lib.get_documents()
|
||||
assert len(docs) == 1
|
||||
|
||||
doc = docs[0]
|
||||
doc.title = "Updated"
|
||||
doc.tags.append("v2")
|
||||
|
||||
# Server returns empty on update
|
||||
api.request.return_value = {}
|
||||
result = lib.update_document(id=doc.id, metadata=doc)
|
||||
|
||||
# Should not raise, should return the input metadata
|
||||
assert result.title == "Updated"
|
||||
assert "v2" in result.tags
|
||||
|
||||
# Verify the wire format sent
|
||||
call_args = api.request.call_args[0][1]
|
||||
doc_meta = call_args["document-metadata"]
|
||||
|
||||
assert doc_meta["id"] == "doc-42"
|
||||
assert doc_meta["title"] == "Updated"
|
||||
assert isinstance(doc_meta["time"], int)
|
||||
assert len(doc_meta["metadata"]) == 1
|
||||
assert doc_meta["metadata"][0]["o"]["v"] == "report"
|
||||
|
|
@ -365,7 +365,7 @@ class Library:
|
|||
id = v["id"],
|
||||
time = datetime.datetime.fromtimestamp(v["time"]),
|
||||
kind = v["kind"],
|
||||
title = v["title"],
|
||||
title = v.get("title", ""),
|
||||
comments = v.get("comments", ""),
|
||||
metadata = [
|
||||
Triple(
|
||||
|
|
@ -482,14 +482,15 @@ class Library:
|
|||
"workspace": self.api.workspace,
|
||||
"document-metadata": {
|
||||
"document-id": id,
|
||||
"time": metadata.time,
|
||||
"id": id,
|
||||
"time": int(metadata.time.timestamp()) if hasattr(metadata.time, 'timestamp') else metadata.time,
|
||||
"title": metadata.title,
|
||||
"comments": metadata.comments,
|
||||
"metadata": [
|
||||
{
|
||||
"s": from_value(t["s"]),
|
||||
"p": from_value(t["p"]),
|
||||
"o": from_value(t["o"]),
|
||||
"s": from_value(t.s),
|
||||
"p": from_value(t.p),
|
||||
"o": from_value(t.o),
|
||||
}
|
||||
for t in metadata.metadata
|
||||
],
|
||||
|
|
@ -498,14 +499,17 @@ class Library:
|
|||
}
|
||||
|
||||
object = self.request(input)
|
||||
doc = object["document-metadata"]
|
||||
doc = object.get("document-metadata") if isinstance(object, dict) else None
|
||||
|
||||
if not doc:
|
||||
return metadata
|
||||
|
||||
try:
|
||||
DocumentMetadata(
|
||||
return DocumentMetadata(
|
||||
id = doc["id"],
|
||||
time = datetime.datetime.fromtimestamp(doc["time"]),
|
||||
kind = doc["kind"],
|
||||
title = doc["title"],
|
||||
title = doc.get("title", ""),
|
||||
comments = doc.get("comments", ""),
|
||||
metadata = [
|
||||
Triple(
|
||||
|
|
@ -513,10 +517,11 @@ class Library:
|
|||
p = to_value(w["p"]),
|
||||
o = to_value(w["o"])
|
||||
)
|
||||
for w in doc["metadata"]
|
||||
for w in doc.get("metadata", [])
|
||||
],
|
||||
workspace = doc.get("workspace", ""),
|
||||
tags = doc["tags"]
|
||||
tags = doc.get("tags", []),
|
||||
parent_id = doc.get("parent-id", ""),
|
||||
document_type = doc.get("document-type", "source"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse document update response", exc_info=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue