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:
+196
-105
@@ -45,8 +45,14 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
# Nº de descargas DAV concurrentes al traer una colección completa (addressbook
|
||||
# con ~1000 vCards). Secuencial son ~0.11s/recurso (~2 min para 1064); con un
|
||||
# pool acotado baja a ~10s. Acotado para no saturar al servidor Xandikos.
|
||||
_DAV_FETCH_WORKERS = 16
|
||||
|
||||
|
||||
def _registry_functions_dir() -> str:
|
||||
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
|
||||
@@ -148,6 +154,15 @@ XANDIKOS_CALENDAR_COLLECTION = "/enmanuel/calendars/calendar/"
|
||||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
||||
|
||||
|
||||
class DavUnavailable(Exception):
|
||||
"""Xandikos no responde (sin red, timeout, auth caída).
|
||||
|
||||
Los endpoints DAV la capturan y devuelven un 503 JSON claro, para que un
|
||||
fallo de la agenda/calendario NUNCA tumbe el server ni afecte a los
|
||||
endpoints del vault, que deben seguir funcionando offline.
|
||||
"""
|
||||
|
||||
|
||||
def _attachment_kind(name: str) -> str:
|
||||
"""Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``."""
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
@@ -396,6 +411,124 @@ class VaultState:
|
||||
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
|
||||
return self._xandikos_password
|
||||
|
||||
def contacts(self) -> list:
|
||||
"""Contactos del addressbook Xandikos, parseados y cacheados en memoria.
|
||||
|
||||
Llena la caché al primer acceso (descarga + parseo de todos los
|
||||
``.vcf``); accesos posteriores la reutilizan hasta ``invalidate_dav()``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si no se puede leer la password de ``pass``.
|
||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||
"""
|
||||
with self._dav_lock:
|
||||
if self._contacts_cache is not None:
|
||||
return self._contacts_cache
|
||||
password = self.xandikos_password()
|
||||
listing = dav_list_resources(
|
||||
XANDIKOS_BASE_URL,
|
||||
XANDIKOS_USERNAME,
|
||||
password,
|
||||
XANDIKOS_CONTACTS_COLLECTION,
|
||||
)
|
||||
if listing.get("status") != "ok":
|
||||
raise DavUnavailable(
|
||||
"Xandikos no responde: %s" % listing.get("error")
|
||||
)
|
||||
contacts: list = []
|
||||
# Descarga concurrente de los .vcf: secuencial son ~0.11s/recurso
|
||||
# (~2 min para 1064 contactos); con el pool acotado baja a ~10s.
|
||||
for res, got in self._fetch_resources(
|
||||
listing.get("resources", []), ".vcf", password
|
||||
):
|
||||
href = res.get("href")
|
||||
for card_text in split_vcards(got.get("text", "")):
|
||||
card = _vcard_to_json(card_text)
|
||||
card["etag"] = res.get("etag")
|
||||
card["href"] = href
|
||||
contacts.append(card)
|
||||
contacts.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
|
||||
self._contacts_cache = contacts
|
||||
return contacts
|
||||
|
||||
def calendar(self, dt_from: str = "", dt_to: str = "") -> list:
|
||||
"""Eventos del calendario Xandikos, cacheados; filtrados por rango.
|
||||
|
||||
La descarga + parseo completos se cachean; el filtro por ``[from, to]``
|
||||
se aplica sobre la caché en cada llamada (barato). Sin ``from``/``to``
|
||||
devuelve todos.
|
||||
|
||||
Raises:
|
||||
RuntimeError: si no se puede leer la password de ``pass``.
|
||||
DavUnavailable: si Xandikos no responde (sin red, timeout, auth).
|
||||
"""
|
||||
with self._dav_lock:
|
||||
if self._calendar_cache is None:
|
||||
password = self.xandikos_password()
|
||||
listing = dav_list_resources(
|
||||
XANDIKOS_BASE_URL,
|
||||
XANDIKOS_USERNAME,
|
||||
password,
|
||||
XANDIKOS_CALENDAR_COLLECTION,
|
||||
)
|
||||
if listing.get("status") != "ok":
|
||||
raise DavUnavailable(
|
||||
"Xandikos no responde: %s" % listing.get("error")
|
||||
)
|
||||
events: list = []
|
||||
for res, got in self._fetch_resources(
|
||||
listing.get("resources", []), ".ics", password
|
||||
):
|
||||
href = res.get("href")
|
||||
for event in _vcalendar_to_events(got.get("text", "")):
|
||||
event["etag"] = res.get("etag")
|
||||
event["href"] = href
|
||||
events.append(event)
|
||||
events.sort(key=lambda e: e.get("dtstart") or "")
|
||||
self._calendar_cache = events
|
||||
all_events = self._calendar_cache
|
||||
if not dt_from and not dt_to:
|
||||
return list(all_events)
|
||||
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
|
||||
|
||||
def _fetch_resources(self, resources: list, suffix: str, password: str) -> list:
|
||||
"""Descarga en paralelo los recursos DAV con la extensión ``suffix``.
|
||||
|
||||
Filtra los recursos por extensión (``.vcf`` / ``.ics``), los descarga con
|
||||
``dav_get_resource`` (función del registry) usando un pool acotado de
|
||||
hilos (``_DAV_FETCH_WORKERS``) y devuelve la lista de pares
|
||||
``(res, got)`` de los que respondieron ``status == "ok"``, preservando el
|
||||
orden del listing. La paralelización es solo de la orquestación: la
|
||||
descarga sigue delegada a la función del registry, que es stdlib y
|
||||
thread-safe (abre su propia conexión por request). Acotar el pool evita
|
||||
saturar al servidor Xandikos.
|
||||
"""
|
||||
targets = [
|
||||
res
|
||||
for res in resources
|
||||
if res.get("href") and res["href"].lower().endswith(suffix)
|
||||
]
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
def _get(res):
|
||||
got = dav_get_resource(
|
||||
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, res["href"]
|
||||
)
|
||||
return res, got
|
||||
|
||||
workers = min(_DAV_FETCH_WORKERS, len(targets))
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
# pool.map preserva el orden de entrada.
|
||||
results = list(pool.map(_get, targets))
|
||||
return [(res, got) for res, got in results if got.get("status") == "ok"]
|
||||
|
||||
def invalidate_dav(self) -> None:
|
||||
"""Vacía las cachés de contactos y calendario (no la password)."""
|
||||
with self._dav_lock:
|
||||
self._contacts_cache = None
|
||||
self._calendar_cache = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers DAV: parseo ligero de vCard / iCalendar a JSON
|
||||
@@ -570,30 +703,22 @@ def _vcalendar_to_events(vcalendar_text: str) -> list:
|
||||
def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
|
||||
"""True si el evento cae (por DTSTART) dentro de ``[dt_from, dt_to]``.
|
||||
|
||||
La comparación es lexicográfica sobre el prefijo de fecha ``YYYYMMDD`` que
|
||||
comparten todos los formatos iCal (date y date-time). ``dt_from``/``dt_to``
|
||||
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` que comparten todos
|
||||
los formatos iCal (date y date-time). Los límites se normalizan quitando los
|
||||
guiones, así acepta tanto el formato documentado del endpoint
|
||||
(``2026-06-11``) como el iCal crudo (``20260611``). ``dt_from``/``dt_to``
|
||||
vacíos desactivan ese extremo del filtro.
|
||||
"""
|
||||
dtstart = (event.get("dtstart") or "")[:8]
|
||||
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
|
||||
if not dtstart:
|
||||
return True
|
||||
if dt_from and dtstart < dt_from[:8]:
|
||||
if dt_from and dtstart < dt_from.replace("-", "")[:8]:
|
||||
return False
|
||||
if dt_to and dtstart > dt_to[:8]:
|
||||
if dt_to and dtstart > dt_to.replace("-", "")[:8]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _uid_to_href(uid: str, resources: list) -> Optional[str]:
|
||||
"""Localiza el href de un recurso DAV cuyo último segmento contiene el uid."""
|
||||
for res in resources:
|
||||
href = res.get("href", "")
|
||||
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||
if uid in tail or tail.startswith(uid):
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construcción de la app FastAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -679,127 +804,93 @@ def create_app(vault_dir: str) -> FastAPI:
|
||||
|
||||
@app.get("/api/contacts")
|
||||
def api_contacts() -> JSONResponse:
|
||||
"""Contactos del addressbook Xandikos, parseados a JSON.
|
||||
"""Contactos del addressbook Xandikos, parseados a JSON (cacheados).
|
||||
|
||||
Lista los recursos de la colección CardDAV, descarga cada ``.vcf`` y lo
|
||||
parsea con ``split_vcards`` + parseo ligero. Si Xandikos no responde
|
||||
(sin red) devuelve un error claro (502), no un crash.
|
||||
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
||||
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
||||
La lista se cachea en memoria al primer acceso (``POST /api/refresh`` la
|
||||
invalida). Si Xandikos no responde o falta la password → 503 con un JSON
|
||||
de error claro, nunca un crash.
|
||||
"""
|
||||
try:
|
||||
password = state.xandikos_password()
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)})
|
||||
|
||||
listing = dav_list_resources(
|
||||
XANDIKOS_BASE_URL,
|
||||
XANDIKOS_USERNAME,
|
||||
password,
|
||||
XANDIKOS_CONTACTS_COLLECTION,
|
||||
)
|
||||
if listing.get("status") != "ok":
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
|
||||
contacts: list = []
|
||||
for res in listing.get("resources", []):
|
||||
href = res.get("href")
|
||||
if not href or not href.lower().endswith(".vcf"):
|
||||
continue
|
||||
got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href)
|
||||
if got.get("status") != "ok":
|
||||
continue
|
||||
for card_text in split_vcards(got.get("text", "")):
|
||||
card = _vcard_to_json(card_text)
|
||||
card["etag"] = res.get("etag")
|
||||
contacts.append(card)
|
||||
|
||||
contacts.sort(key=lambda c: (c.get("fn") or c.get("uid") or "").lower())
|
||||
return JSONResponse(content={"status": "ok", "count": len(contacts), "contacts": contacts})
|
||||
return JSONResponse(
|
||||
content={"status": "ok", "count": len(contacts), "contacts": contacts}
|
||||
)
|
||||
|
||||
@app.get("/api/contact/{uid}")
|
||||
def api_contact(uid: str) -> JSONResponse:
|
||||
"""Un vCard concreto (por UID) a JSON."""
|
||||
try:
|
||||
password = state.xandikos_password()
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)})
|
||||
"""Un contacto concreto (por UID) parseado a JSON, desde la caché.
|
||||
|
||||
listing = dav_list_resources(
|
||||
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CONTACTS_COLLECTION
|
||||
)
|
||||
if listing.get("status") != "ok":
|
||||
Resuelve sobre la lista cacheada de ``/api/contacts`` (mismo parseo
|
||||
completo, todos los campos). 404 si el UID no existe; 503 si Xandikos no
|
||||
responde o falta la password.
|
||||
"""
|
||||
try:
|
||||
contacts = state.contacts()
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
href = _uid_to_href(uid, listing.get("resources", []))
|
||||
if not href:
|
||||
raise HTTPException(status_code=404, detail="contacto '%s' no encontrado" % uid)
|
||||
got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href)
|
||||
if got.get("status") != "ok":
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"status": "error", "error": "no se pudo descargar el vCard"},
|
||||
match = next((c for c in contacts if c.get("uid") == uid), None)
|
||||
if match is None:
|
||||
# Tolerancia: aceptar también el segmento final del href (nombre del
|
||||
# recurso .vcf) cuando el UID no coincide literalmente.
|
||||
match = next(
|
||||
(
|
||||
c
|
||||
for c in contacts
|
||||
if uid in (c.get("href") or "").rsplit("/", 1)[-1]
|
||||
),
|
||||
None,
|
||||
)
|
||||
cards = split_vcards(got.get("text", ""))
|
||||
if not cards:
|
||||
raise HTTPException(status_code=404, detail="vCard vacío")
|
||||
return JSONResponse(content={"status": "ok", "contact": _vcard_to_json(cards[0])})
|
||||
if match is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="contacto '%s' no encontrado" % uid
|
||||
)
|
||||
return JSONResponse(content={"status": "ok", "contact": match})
|
||||
|
||||
# -- Xandikos: calendario (CalDAV) --
|
||||
|
||||
@app.get("/api/calendar")
|
||||
def api_calendar(
|
||||
from_: str = Query("", alias="from", description="fecha inicio YYYYMMDD"),
|
||||
to: str = Query("", description="fecha fin YYYYMMDD"),
|
||||
from_: str = Query("", alias="from", description="fecha inicio YYYY-MM-DD"),
|
||||
to: str = Query("", description="fecha fin YYYY-MM-DD"),
|
||||
) -> JSONResponse:
|
||||
"""Eventos del calendario Xandikos en ``[from, to]``, parseados a JSON.
|
||||
"""Eventos del calendario Xandikos en ``[from, to]`` (cacheados).
|
||||
|
||||
Lista los recursos de la colección CalDAV, descarga cada ``.ics``,
|
||||
extrae sus VEVENT y los filtra por DTSTART dentro del rango. Sin red ->
|
||||
error claro (502), no crash.
|
||||
Cada evento: ``{uid, summary, dtstart, dtend, location, description}``.
|
||||
La descarga + parseo completos se cachean (``POST /api/refresh`` los
|
||||
invalida); el filtro por rango se aplica sobre la caché. Sin ``from``/
|
||||
``to`` devuelve todos. Si Xandikos no responde o falta la password →
|
||||
503 con JSON de error claro, nunca un crash.
|
||||
"""
|
||||
try:
|
||||
password = state.xandikos_password()
|
||||
except RuntimeError as exc:
|
||||
return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)})
|
||||
|
||||
listing = dav_list_resources(
|
||||
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CALENDAR_COLLECTION
|
||||
)
|
||||
if listing.get("status") != "ok":
|
||||
events = state.calendar(from_, to)
|
||||
except (RuntimeError, DavUnavailable) as exc:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
||||
status_code=503, content={"status": "error", "error": str(exc)}
|
||||
)
|
||||
|
||||
events: list = []
|
||||
for res in listing.get("resources", []):
|
||||
href = res.get("href")
|
||||
if not href or not href.lower().endswith(".ics"):
|
||||
continue
|
||||
got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href)
|
||||
if got.get("status") != "ok":
|
||||
continue
|
||||
for event in _vcalendar_to_events(got.get("text", "")):
|
||||
if _event_in_range(event, from_, to):
|
||||
events.append(event)
|
||||
|
||||
events.sort(key=lambda e: e.get("dtstart") or "")
|
||||
return JSONResponse(content={"status": "ok", "count": len(events), "events": events})
|
||||
return JSONResponse(
|
||||
content={"status": "ok", "count": len(events), "events": events}
|
||||
)
|
||||
|
||||
# -- Refresco de cachés --
|
||||
|
||||
@app.post("/api/refresh")
|
||||
def api_refresh() -> dict:
|
||||
"""Invalida y reconstruye la caché del grafo del vault.
|
||||
"""Reconstruye la caché del grafo del vault e invalida las cachés DAV.
|
||||
|
||||
Los datos DAV no se cachean, así que esto solo afecta al grafo/tablas del
|
||||
vault. Devuelve el conteo del grafo recién reconstruido.
|
||||
Re-escanea el vault (grafo + tablas) y vacía las cachés de contactos y
|
||||
calendario, que se recargarán perezosamente en el siguiente acceso.
|
||||
Devuelve el conteo del grafo recién reconstruido.
|
||||
"""
|
||||
summary = state.refresh()
|
||||
state.invalidate_dav()
|
||||
return {"status": "refreshed", **summary}
|
||||
|
||||
return app
|
||||
|
||||
+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