perf: descarga DAV concurrente + caché de contactos/calendario
Las colecciones Xandikos son grandes (1064 contactos, 98 eventos). Descargar los .vcf/.ics secuencialmente tardaba ~2 min para los contactos (timeout). Se añade _fetch_resources con un ThreadPoolExecutor acotado (16 workers): primera carga de /api/contacts baja a ~9s, segunda (cacheada) a ~10ms. La descarga sigue delegada a dav_get_resource del registry (stdlib, thread-safe); solo se paraleliza la orquestación. Incluye caché en memoria de contactos y calendario (invalidada por /api/refresh), DavUnavailable para degradación clara sin red, y campos aliaseados en español (nombre/alias/telefonos/correos/osint) para el frontend. Verificado contra el vault real (1199 nodos) y Xandikos real (1064 contactos, 98 eventos). 19 tests verdes.
This commit is contained in:
+173
-3
@@ -236,7 +236,10 @@ def test_vevent_to_json_and_range():
|
||||
|
||||
|
||||
def test_dav_endpoints_degrade_without_network(client, monkeypatch):
|
||||
"""Sin Xandikos accesible los endpoints DAV devuelven error claro, no crash."""
|
||||
"""Sin Xandikos accesible los endpoints DAV devuelven 503 claro, no crash.
|
||||
|
||||
Y los endpoints del vault siguen funcionando offline (no se ven afectados).
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
srv, "dav_list_resources", lambda *a, **k: {"status": "error", "error": "sin red"}
|
||||
)
|
||||
@@ -244,9 +247,176 @@ def test_dav_endpoints_degrade_without_network(client, monkeypatch):
|
||||
client.app.state.vault._xandikos_password = "x"
|
||||
|
||||
r1 = client.get("/api/contacts")
|
||||
assert r1.status_code == 502
|
||||
assert r1.status_code == 503
|
||||
assert r1.json()["status"] == "error"
|
||||
|
||||
r2 = client.get("/api/calendar")
|
||||
assert r2.status_code == 502
|
||||
assert r2.status_code == 503
|
||||
assert r2.json()["status"] == "error"
|
||||
|
||||
# El fallo DAV NO contamina los endpoints del vault (offline-OK).
|
||||
assert client.get("/api/graph").status_code == 200
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DAV: campos osint / alias / nota / itemN. + caché + invalidación
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vcard_to_json_alias_nota_osint_y_item_prefix():
|
||||
"""Parsea alias (NICKNAME), nota (NOTE), osint (X-OSINT-*) y prefijo itemN."""
|
||||
vcard = (
|
||||
"BEGIN:VCARD\r\n"
|
||||
"VERSION:3.0\r\n"
|
||||
"UID:maria-001\r\n"
|
||||
"FN:María del Mar Pérez\r\n"
|
||||
"NICKNAME:Marimar\r\n"
|
||||
"item1.TEL;TYPE=CELL:+34 600 111 222\r\n"
|
||||
"item2.EMAIL;TYPE=INTERNET:maria@example.com\r\n"
|
||||
"NOTE:Objetivo principal.\r\n"
|
||||
"X-OSINT-DNI:12345678Z\r\n"
|
||||
"X-OSINT-PAIS:España\r\n"
|
||||
"X-OSINT-SEXO:F\r\n"
|
||||
"END:VCARD\r\n"
|
||||
)
|
||||
out = srv._vcard_to_json(vcard)
|
||||
assert out["uid"] == "maria-001"
|
||||
assert out["nombre"] == "María del Mar Pérez"
|
||||
assert out["alias"] == "Marimar"
|
||||
assert out["nota"] == "Objetivo principal."
|
||||
# El prefijo itemN. se elimina: TEL/EMAIL se reconocen.
|
||||
assert out["telefonos"] == ["+34 600 111 222"]
|
||||
assert out["correos"] == ["maria@example.com"]
|
||||
# Bloque osint derivado de X-OSINT-*.
|
||||
assert out["osint"] == {"dni": "12345678Z", "pais": "España", "sexo": "F"}
|
||||
|
||||
|
||||
def test_vcard_to_json_nombre_desde_N_sin_fn():
|
||||
vcard = (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:juan-002\r\n"
|
||||
"N:García;Juan;;;\r\nTEL:+34 611 222 333\r\nEND:VCARD\r\n"
|
||||
)
|
||||
out = srv._vcard_to_json(vcard)
|
||||
assert out["nombre"] == "Juan García"
|
||||
assert out["osint"] == {}
|
||||
|
||||
|
||||
# Fixture DAV mockeado: dos contactos (uno con osint) y dos eventos.
|
||||
_VCF_BODY = (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:maria-001\r\nFN:María Pérez\r\n"
|
||||
"NICKNAME:Mari\r\nX-OSINT-DNI:12345678Z\r\nX-OSINT-PAIS:España\r\n"
|
||||
"item1.TEL;TYPE=CELL:+34600111222\r\nEND:VCARD\r\n"
|
||||
)
|
||||
_VCF_BODY_2 = (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:juan-002\r\nFN:Juan García\r\n"
|
||||
"EMAIL:juan@example.com\r\nEND:VCARD\r\n"
|
||||
)
|
||||
_ICS_BODY = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:evt-001\r\n"
|
||||
"SUMMARY:Reunión\r\nDTSTART:20260611T090000Z\r\nDTEND:20260611T100000Z\r\n"
|
||||
"LOCATION:Madrid\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
)
|
||||
_ICS_BODY_2 = (
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:evt-002\r\n"
|
||||
"SUMMARY:Vigilancia\r\nDTSTART:20260620T200000Z\r\nEND:VEVENT\r\n"
|
||||
"END:VCALENDAR\r\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_dav(monkeypatch):
|
||||
"""Parchea las funciones del registry DAV con fixtures en memoria (sin red).
|
||||
|
||||
Devuelve un dict ``{"calls": int}`` que cuenta los PROPFIND para verificar
|
||||
el cacheo (segunda lectura no re-llama a Xandikos).
|
||||
"""
|
||||
state = {"calls": 0}
|
||||
contacts_res = [
|
||||
{"href": "/enmanuel/contacts/addressbook/maria-001.vcf", "etag": '"a"'},
|
||||
{"href": "/enmanuel/contacts/addressbook/juan-002.vcf", "etag": '"b"'},
|
||||
]
|
||||
calendar_res = [
|
||||
{"href": "/enmanuel/calendars/calendar/evt-001.ics", "etag": '"c"'},
|
||||
{"href": "/enmanuel/calendars/calendar/evt-002.ics", "etag": '"d"'},
|
||||
]
|
||||
bodies = {
|
||||
"/enmanuel/contacts/addressbook/maria-001.vcf": _VCF_BODY,
|
||||
"/enmanuel/contacts/addressbook/juan-002.vcf": _VCF_BODY_2,
|
||||
"/enmanuel/calendars/calendar/evt-001.ics": _ICS_BODY,
|
||||
"/enmanuel/calendars/calendar/evt-002.ics": _ICS_BODY_2,
|
||||
}
|
||||
|
||||
def _list(base, user, pw, collection, **kw):
|
||||
state["calls"] += 1
|
||||
res = contacts_res if "contacts" in collection else calendar_res
|
||||
return {"status": "ok", "http_status": 207, "resources": res}
|
||||
|
||||
def _get(base, user, pw, href, **kw):
|
||||
return {"status": "ok", "http_status": 200, "text": bodies.get(href, "")}
|
||||
|
||||
monkeypatch.setattr(srv, "dav_list_resources", _list)
|
||||
monkeypatch.setattr(srv, "dav_get_resource", _get)
|
||||
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
|
||||
return state
|
||||
|
||||
|
||||
def test_contacts_endpoint_parsea_y_cachea(client, fake_dav):
|
||||
r = client.get("/api/contacts")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok" and data["count"] == 2
|
||||
by_uid = {c["uid"]: c for c in data["contacts"]}
|
||||
maria = by_uid["maria-001"]
|
||||
assert maria["nombre"] == "María Pérez"
|
||||
assert maria["alias"] == "Mari"
|
||||
assert maria["telefonos"] == ["+34600111222"]
|
||||
assert maria["osint"] == {"dni": "12345678Z", "pais": "España"}
|
||||
# Segunda llamada NO re-hace PROPFIND (sirve de la caché en memoria).
|
||||
calls_after_first = fake_dav["calls"]
|
||||
client.get("/api/contacts")
|
||||
assert fake_dav["calls"] == calls_after_first
|
||||
|
||||
|
||||
def test_contact_by_uid_desde_cache(client, fake_dav):
|
||||
r = client.get("/api/contact/maria-001")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["contact"]["nombre"] == "María Pérez"
|
||||
assert client.get("/api/contact/no-existe").status_code == 404
|
||||
|
||||
|
||||
def test_calendar_endpoint_rango_y_cache(client, fake_dav):
|
||||
# Sin rango: ambos eventos.
|
||||
r = client.get("/api/calendar")
|
||||
assert r.status_code == 200 and r.json()["count"] == 2
|
||||
# Con rango: solo evt-001 (11 junio).
|
||||
r2 = client.get("/api/calendar", params={"from": "2026-06-01", "to": "2026-06-15"})
|
||||
assert [e["uid"] for e in r2.json()["events"]] == ["evt-001"]
|
||||
|
||||
|
||||
def test_refresh_invalida_cache_dav(client, fake_dav):
|
||||
client.get("/api/contacts") # llena caché
|
||||
calls_before = fake_dav["calls"]
|
||||
client.post("/api/refresh") # invalida
|
||||
client.get("/api/contacts") # vuelve a hacer PROPFIND
|
||||
assert fake_dav["calls"] > calls_before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("OSINT_WEB_DAV_SMOKE") != "1",
|
||||
reason="smoke DAV real desactivado (export OSINT_WEB_DAV_SMOKE=1 para correrlo)",
|
||||
)
|
||||
def test_smoke_dav_real(vault):
|
||||
"""Smoke contra el Xandikos real: ≥1 contacto y ≥1 evento. Requiere red + pass."""
|
||||
app = srv.create_app(vault)
|
||||
real_client = TestClient(app)
|
||||
rc = real_client.get("/api/contacts")
|
||||
assert rc.status_code == 200
|
||||
assert rc.json()["status"] == "ok" and rc.json()["count"] >= 1
|
||||
re_ = real_client.get("/api/calendar")
|
||||
assert re_.status_code == 200 and re_.json()["status"] == "ok"
|
||||
|
||||
Reference in New Issue
Block a user