Files
fn_registry/python/functions/infra/dav_get_collection_test.py
T
egutierrez 73f41a3474 feat(dav): dav_get_collection + dav_collection_ctag — bulk DAV en 1 request + ctag cache
dav_get_collection trae TODOS los recursos de una coleccion CardDAV/CalDAV en
UNA peticion REPORT (addressbook-query / calendar-query) con el contenido vCard
/ VCALENDAR inline, evitando el patron N+1 (PROPFIND + un GET por recurso). Para
1064 contactos baja de ~9s a ~1s. dav_collection_ctag lee el ctag de la
coleccion (PROPFIND Depth:0 barato) para validar caches sin descargar cuando
nada cambio. Ambas: solo stdlib, basic auth, verify_tls, error-safe, tests que
mockean el multistatus. Grupo dav, verificadas contra Xandikos real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:07:39 +02:00

204 lines
6.7 KiB
Python

"""Tests para dav_get_collection.
Smoke deterministas: monkeypatchean urllib.request.urlopen para capturar el
Request (method REPORT, Depth, auth, cuerpo del query) y devolver un XML
multistatus simulado con contenido inline. Cubren ambos perfiles (vcard / ical),
el des-escapado de entidades XML, los sinonimos de content_type, el content_type
invalido y el path de error HTTP.
"""
import base64
import sys
import infra.dav_get_collection # noqa: F401
mod = sys.modules["infra.dav_get_collection"]
dav_get_collection = mod.dav_get_collection
# Multistatus de un addressbook-query: 2 vCards inline. El segundo contiene una
# entidad XML (&lt;) que debe des-escaparse a '<' en el campo data.
_VCARD_XML = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:carddav">'
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/ada.vcf</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-ada"</ns0:getetag>'
"<ns1:address-data>BEGIN:VCARD\nFN:Ada\nUID:ada\nEND:VCARD\n</ns1:address-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"<ns0:response><ns0:href>/enmanuel/contacts/addressbook/alan.vcf</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-alan"</ns0:getetag>'
"<ns1:address-data>BEGIN:VCARD\nFN:Alan &lt;turing&gt;\nUID:alan\nEND:VCARD\n</ns1:address-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"</ns0:multistatus>"
)
# Multistatus de un calendar-query: 1 VEVENT inline.
_ICAL_XML = (
'<?xml version="1.0"?>'
'<ns0:multistatus xmlns:ns0="DAV:" xmlns:ns1="urn:ietf:params:xml:ns:caldav">'
"<ns0:response><ns0:href>/enmanuel/calendars/calendar/e1.ics</ns0:href>"
"<ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>"
'<ns0:getetag>"etag-e1"</ns0:getetag>'
"<ns1:calendar-data>BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Cita\nUID:e1\nEND:VEVENT\nEND:VCALENDAR\n</ns1:calendar-data>"
"</ns0:prop></ns0:propstat></ns0:response>"
"</ns0:multistatus>"
)
class _FakeResp:
def __init__(self, payload: str):
self._payload = payload
self.status = 207
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._payload.encode("utf-8")
def _capture(monkeypatch, payload: str):
captured = {}
def fake_urlopen(req, timeout=None, context=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
captured["body"] = req.data.decode("utf-8") if req.data else ""
return _FakeResp(payload)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
return captured
def test_vcard_construye_report_addressbook_query(monkeypatch):
cap = _capture(monkeypatch, _VCARD_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type="vcard",
)
assert cap["method"] == "REPORT"
assert cap["headers"]["depth"] == "1"
assert "addressbook-query" in cap["body"]
assert "address-data" in cap["body"]
assert "urn:ietf:params:xml:ns:carddav" in cap["body"]
def test_ical_construye_report_calendar_query_con_filtro(monkeypatch):
cap = _capture(monkeypatch, _ICAL_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/calendars/calendar/",
content_type="ical",
)
assert cap["method"] == "REPORT"
assert "calendar-query" in cap["body"]
assert 'comp-filter name="VCALENDAR"' in cap["body"]
assert "urn:ietf:params:xml:ns:caldav" in cap["body"]
def test_basic_auth_header_correcto(monkeypatch):
cap = _capture(monkeypatch, _VCARD_XML)
dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
expected = "Basic " + base64.b64encode(b"enmanuel:secret-pw").decode("ascii")
assert cap["headers"]["authorization"] == expected
def test_parsea_resources_con_data_inline(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
assert res["status"] == "ok"
assert len(res["resources"]) == 2
by_href = {r["href"]: r for r in res["resources"]}
ada = by_href["/enmanuel/contacts/addressbook/ada.vcf"]
assert ada["etag"] == '"etag-ada"'
assert "BEGIN:VCARD" in ada["data"]
assert "FN:Ada" in ada["data"]
def test_desescapa_entidades_xml_del_data(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
alan = next(r for r in res["resources"] if r["href"].endswith("alan.vcf"))
# &lt;turing&gt; debe quedar des-escapado a <turing>.
assert "FN:Alan <turing>" in alan["data"]
def test_ical_parsea_calendar_data(monkeypatch):
_capture(monkeypatch, _ICAL_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/calendars/calendar/",
content_type="ical",
)
assert res["status"] == "ok"
assert len(res["resources"]) == 1
assert "BEGIN:VEVENT" in res["resources"][0]["data"]
def test_acepta_sinonimos_de_content_type(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
for alias in ("contacts", "carddav", "addressbook"):
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type=alias,
)
assert res["status"] == "ok"
def test_content_type_invalido_devuelve_error(monkeypatch):
_capture(monkeypatch, _VCARD_XML)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
content_type="json",
)
assert res["status"] == "error"
assert res["http_status"] is None
def test_httperror_devuelve_status_error(monkeypatch):
def fake_urlopen(req, timeout=None, context=None):
raise mod.urllib.error.HTTPError(req.full_url, 401, "Unauthorized", {}, None)
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
res = dav_get_collection(
"https://dav.example.com",
"enmanuel",
"secret-pw",
"/enmanuel/contacts/addressbook/",
)
assert res["status"] == "error"
assert res["http_status"] == 401