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 re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Optional
|
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:
|
def _registry_functions_dir() -> str:
|
||||||
"""Localiza ``python/functions`` del fn_registry sin paths hardcodeados.
|
"""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"}
|
_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:
|
def _attachment_kind(name: str) -> str:
|
||||||
"""Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``."""
|
"""Clasifica un attachment por extensión: ``image`` | ``pdf`` | ``other``."""
|
||||||
ext = os.path.splitext(name)[1].lower()
|
ext = os.path.splitext(name)[1].lower()
|
||||||
@@ -396,6 +411,124 @@ class VaultState:
|
|||||||
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
|
self._xandikos_password = _read_pass_secret(XANDIKOS_PASS_ENTRY)
|
||||||
return self._xandikos_password
|
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
|
# 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:
|
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]``.
|
"""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
|
Comparación lexicográfica sobre el prefijo ``YYYYMMDD`` que comparten todos
|
||||||
comparten todos los formatos iCal (date y date-time). ``dt_from``/``dt_to``
|
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.
|
vacíos desactivan ese extremo del filtro.
|
||||||
"""
|
"""
|
||||||
dtstart = (event.get("dtstart") or "")[:8]
|
dtstart = (event.get("dtstart") or "").replace("-", "")[:8]
|
||||||
if not dtstart:
|
if not dtstart:
|
||||||
return True
|
return True
|
||||||
if dt_from and dtstart < dt_from[:8]:
|
if dt_from and dtstart < dt_from.replace("-", "")[:8]:
|
||||||
return False
|
return False
|
||||||
if dt_to and dtstart > dt_to[:8]:
|
if dt_to and dtstart > dt_to.replace("-", "")[:8]:
|
||||||
return False
|
return False
|
||||||
return True
|
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
|
# Construcción de la app FastAPI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -679,127 +804,93 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/api/contacts")
|
@app.get("/api/contacts")
|
||||||
def api_contacts() -> JSONResponse:
|
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
|
Cada contacto: ``{uid, nombre, alias, nota, org, telefonos[], emails[],
|
||||||
parsea con ``split_vcards`` + parseo ligero. Si Xandikos no responde
|
osint{dni,pais,sexo,...}}`` (+ las formas tipadas ``phones``/``emails``).
|
||||||
(sin red) devuelve un error claro (502), no un crash.
|
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:
|
try:
|
||||||
password = state.xandikos_password()
|
contacts = state.contacts()
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, DavUnavailable) 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":
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=502,
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
|
||||||
)
|
)
|
||||||
|
return JSONResponse(
|
||||||
contacts: list = []
|
content={"status": "ok", "count": len(contacts), "contacts": contacts}
|
||||||
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})
|
|
||||||
|
|
||||||
@app.get("/api/contact/{uid}")
|
@app.get("/api/contact/{uid}")
|
||||||
def api_contact(uid: str) -> JSONResponse:
|
def api_contact(uid: str) -> JSONResponse:
|
||||||
"""Un vCard concreto (por UID) a JSON."""
|
"""Un contacto concreto (por UID) parseado a JSON, desde la caché.
|
||||||
try:
|
|
||||||
password = state.xandikos_password()
|
|
||||||
except RuntimeError as exc:
|
|
||||||
return JSONResponse(status_code=503, content={"status": "error", "error": str(exc)})
|
|
||||||
|
|
||||||
listing = dav_list_resources(
|
Resuelve sobre la lista cacheada de ``/api/contacts`` (mismo parseo
|
||||||
XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, XANDIKOS_CONTACTS_COLLECTION
|
completo, todos los campos). 404 si el UID no existe; 503 si Xandikos no
|
||||||
)
|
responde o falta la password.
|
||||||
if listing.get("status") != "ok":
|
"""
|
||||||
|
try:
|
||||||
|
contacts = state.contacts()
|
||||||
|
except (RuntimeError, DavUnavailable) as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=502,
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
|
||||||
)
|
)
|
||||||
href = _uid_to_href(uid, listing.get("resources", []))
|
match = next((c for c in contacts if c.get("uid") == uid), None)
|
||||||
if not href:
|
if match is None:
|
||||||
raise HTTPException(status_code=404, detail="contacto '%s' no encontrado" % uid)
|
# Tolerancia: aceptar también el segmento final del href (nombre del
|
||||||
got = dav_get_resource(XANDIKOS_BASE_URL, XANDIKOS_USERNAME, password, href)
|
# recurso .vcf) cuando el UID no coincide literalmente.
|
||||||
if got.get("status") != "ok":
|
match = next(
|
||||||
return JSONResponse(
|
(
|
||||||
status_code=502,
|
c
|
||||||
content={"status": "error", "error": "no se pudo descargar el vCard"},
|
for c in contacts
|
||||||
|
if uid in (c.get("href") or "").rsplit("/", 1)[-1]
|
||||||
|
),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
cards = split_vcards(got.get("text", ""))
|
if match is None:
|
||||||
if not cards:
|
raise HTTPException(
|
||||||
raise HTTPException(status_code=404, detail="vCard vacío")
|
status_code=404, detail="contacto '%s' no encontrado" % uid
|
||||||
return JSONResponse(content={"status": "ok", "contact": _vcard_to_json(cards[0])})
|
)
|
||||||
|
return JSONResponse(content={"status": "ok", "contact": match})
|
||||||
|
|
||||||
# -- Xandikos: calendario (CalDAV) --
|
# -- Xandikos: calendario (CalDAV) --
|
||||||
|
|
||||||
@app.get("/api/calendar")
|
@app.get("/api/calendar")
|
||||||
def api_calendar(
|
def api_calendar(
|
||||||
from_: str = Query("", alias="from", description="fecha inicio YYYYMMDD"),
|
from_: str = Query("", alias="from", description="fecha inicio YYYY-MM-DD"),
|
||||||
to: str = Query("", description="fecha fin YYYYMMDD"),
|
to: str = Query("", description="fecha fin YYYY-MM-DD"),
|
||||||
) -> JSONResponse:
|
) -> 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``,
|
Cada evento: ``{uid, summary, dtstart, dtend, location, description}``.
|
||||||
extrae sus VEVENT y los filtra por DTSTART dentro del rango. Sin red ->
|
La descarga + parseo completos se cachean (``POST /api/refresh`` los
|
||||||
error claro (502), no crash.
|
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:
|
try:
|
||||||
password = state.xandikos_password()
|
events = state.calendar(from_, to)
|
||||||
except RuntimeError as exc:
|
except (RuntimeError, DavUnavailable) 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":
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=502,
|
status_code=503, content={"status": "error", "error": str(exc)}
|
||||||
content={"status": "error", "error": "Xandikos no responde: %s" % listing.get("error")},
|
|
||||||
)
|
)
|
||||||
|
return JSONResponse(
|
||||||
events: list = []
|
content={"status": "ok", "count": len(events), "events": events}
|
||||||
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})
|
|
||||||
|
|
||||||
# -- Refresco de cachés --
|
# -- Refresco de cachés --
|
||||||
|
|
||||||
@app.post("/api/refresh")
|
@app.post("/api/refresh")
|
||||||
def api_refresh() -> dict:
|
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
|
Re-escanea el vault (grafo + tablas) y vacía las cachés de contactos y
|
||||||
vault. Devuelve el conteo del grafo recién reconstruido.
|
calendario, que se recargarán perezosamente en el siguiente acceso.
|
||||||
|
Devuelve el conteo del grafo recién reconstruido.
|
||||||
"""
|
"""
|
||||||
summary = state.refresh()
|
summary = state.refresh()
|
||||||
|
state.invalidate_dav()
|
||||||
return {"status": "refreshed", **summary}
|
return {"status": "refreshed", **summary}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+173
-3
@@ -236,7 +236,10 @@ def test_vevent_to_json_and_range():
|
|||||||
|
|
||||||
|
|
||||||
def test_dav_endpoints_degrade_without_network(client, monkeypatch):
|
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(
|
monkeypatch.setattr(
|
||||||
srv, "dav_list_resources", lambda *a, **k: {"status": "error", "error": "sin red"}
|
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"
|
client.app.state.vault._xandikos_password = "x"
|
||||||
|
|
||||||
r1 = client.get("/api/contacts")
|
r1 = client.get("/api/contacts")
|
||||||
assert r1.status_code == 502
|
assert r1.status_code == 503
|
||||||
assert r1.json()["status"] == "error"
|
assert r1.json()["status"] == "error"
|
||||||
|
|
||||||
r2 = client.get("/api/calendar")
|
r2 = client.get("/api/calendar")
|
||||||
assert r2.status_code == 502
|
assert r2.status_code == 503
|
||||||
assert r2.json()["status"] == "error"
|
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