merge: push agenda sin OSINT + sync inverso por etag (DAVx5→DuckDB)
This commit is contained in:
@@ -15,6 +15,8 @@ uses_functions:
|
||||
- dav_list_calendars_py_infra
|
||||
- dav_list_addressbooks_py_infra
|
||||
- dav_collection_ctag_py_infra
|
||||
- dav_list_resources_py_infra
|
||||
- dav_get_resource_py_infra
|
||||
- carddav_put_vcard_py_infra
|
||||
- caldav_put_event_py_infra
|
||||
- dav_delete_resource_py_infra
|
||||
|
||||
@@ -28,6 +28,7 @@ endpoints de datos responden SIEMPRE 200 con status ok|error en el body):
|
||||
POST /api/query/named ejecuta una query del catálogo {name, max_rows}
|
||||
POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas
|
||||
POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas
|
||||
POST /api/sync/dav-pull sync inverso incremental por etag (ediciones del móvil)
|
||||
POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota
|
||||
"""
|
||||
|
||||
@@ -359,6 +360,12 @@ def create_app(cfg: Config) -> FastAPI:
|
||||
def push_dav() -> dict:
|
||||
return _guard(lambda: writes.push_all_dav(cfg))
|
||||
|
||||
@app.post("/api/sync/dav-pull")
|
||||
def sync_dav_pull() -> dict:
|
||||
# Sync inverso: trae a la DB las ediciones del móvil/DAVx5,
|
||||
# last-write-wins por etag (incremental, distinto de ingest_dav).
|
||||
return _guard(lambda: writes.pull_dav(cfg))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ dav_list_addressbooks = _load_registry_fn(
|
||||
"infra", "dav_list_addressbooks", "dav_list_addressbooks"
|
||||
)
|
||||
dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag")
|
||||
# Grupo dav: listado incremental (PROPFIND Depth:1 -> [{href, etag}]) y GET de
|
||||
# un recurso suelto. Base del sync inverso por etag (/api/sync/dav-pull).
|
||||
dav_list_resources = _load_registry_fn("infra", "dav_list_resources", "dav_list_resources")
|
||||
dav_get_resource = _load_registry_fn("infra", "dav_get_resource", "dav_get_resource")
|
||||
|
||||
# Grupo dav: escritura (push DB -> Xandikos) y creación de colecciones.
|
||||
carddav_put_vcard = _load_registry_fn("infra", "carddav_put_vcard", "carddav_put_vcard")
|
||||
@@ -137,6 +141,8 @@ __all__ = [
|
||||
"dav_get_collection",
|
||||
"dav_list_calendars",
|
||||
"dav_list_addressbooks",
|
||||
"dav_list_resources",
|
||||
"dav_get_resource",
|
||||
"carddav_put_vcard",
|
||||
"caldav_put_event",
|
||||
"dav_delete_resource",
|
||||
|
||||
+291
-15
@@ -28,6 +28,8 @@ from .registry_bridge import (
|
||||
carddav_put_vcard,
|
||||
create_obsidian_note,
|
||||
dav_delete_resource,
|
||||
dav_get_resource,
|
||||
dav_list_resources,
|
||||
dav_make_addressbook,
|
||||
dav_make_calendar,
|
||||
duckdb_execute,
|
||||
@@ -288,6 +290,110 @@ def _default_collection(cfg: Config) -> str:
|
||||
return cfg.dav_contacts_collection
|
||||
|
||||
|
||||
def _person_agenda_extras(db_path: str, note_path) -> tuple:
|
||||
"""Devuelve (direcciones, aliases) de la persons enlazada por note_path.
|
||||
|
||||
El móvil solo recibe datos de agenda: si el contacto está enlazado a una
|
||||
ficha de persons, sus direcciones y aliases se incluyen en el vCard (como
|
||||
ADR y NICKNAME). Los campos OSINT (dni/sexo/fecha_nacimiento/pais/contexto)
|
||||
NUNCA salen de la DB: no se leen aquí. Devuelve ([], []) si no hay enlace.
|
||||
"""
|
||||
if not note_path:
|
||||
return [], []
|
||||
res = duckdb_query_readonly(
|
||||
db_path,
|
||||
"SELECT direcciones, aliases FROM persons WHERE note_path = ?",
|
||||
[note_path],
|
||||
1,
|
||||
)
|
||||
if res.get("status") != "ok" or not res.get("rows"):
|
||||
return [], []
|
||||
row = res["rows"][0]
|
||||
return (
|
||||
_decode_json_field(row.get("direcciones")),
|
||||
_decode_json_field(row.get("aliases")),
|
||||
)
|
||||
|
||||
|
||||
def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str:
|
||||
"""Compone el vCard de AGENDA de un contacto, SIN ningún campo OSINT.
|
||||
|
||||
Incluye FN, TEL×N, EMAIL×N y, si el contacto está enlazado a una ficha de
|
||||
persons (por note_path), las direcciones (ADR×N) y aliases (NICKNAME) de esa
|
||||
persona. NUNCA pasa el dict ``osint`` a build_vcard, así que el vCard jamás
|
||||
lleva líneas X-OSINT-* (DNI/sexo/fecha-nac/país/contexto quedan solo en
|
||||
DuckDB + Obsidian).
|
||||
|
||||
Args:
|
||||
cfg: configuración del service (db_path para resolver la persona).
|
||||
uid: UID del contacto.
|
||||
fields: dict con fn/nombre, tels/telefonos, emails/correos,
|
||||
direcciones/adrs y, opcionalmente, note_path (enlace a persons).
|
||||
|
||||
Returns:
|
||||
Texto vCard 3.0 listo para PUT a Xandikos.
|
||||
"""
|
||||
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
|
||||
emails = _as_list(fields.get("emails") or fields.get("correos"))
|
||||
fn = fields.get("fn") or fields.get("nombre")
|
||||
|
||||
# Direcciones y aliases del contacto explícitos en fields...
|
||||
adrs = _as_list(fields.get("direcciones") or fields.get("adrs"))
|
||||
aliases = _as_list(fields.get("aliases"))
|
||||
# ...más los de la persons enlazada (si la hay), deduplicando.
|
||||
note_path = fields.get("note_path")
|
||||
person_adrs, person_aliases = _person_agenda_extras(cfg.db_path, note_path)
|
||||
adrs = _dedup_keep_order(adrs + person_adrs)
|
||||
aliases = _dedup_keep_order(aliases + person_aliases)
|
||||
|
||||
contact = {
|
||||
"uid": uid,
|
||||
"fn": fn,
|
||||
"tels": tels,
|
||||
"emails": emails,
|
||||
"adrs": adrs,
|
||||
"aliases": aliases,
|
||||
}
|
||||
# CLAVE DE PRIVACIDAD: no se pasa 'osint' -> no se emite ninguna X-OSINT-*.
|
||||
return build_vcard(contact)
|
||||
|
||||
|
||||
def _dedup_keep_order(items: list) -> list:
|
||||
"""Deduplica una lista de strings preservando orden (case-insensitive)."""
|
||||
seen, out = set(), []
|
||||
for it in items:
|
||||
s = str(it).strip()
|
||||
key = s.lower()
|
||||
if s and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def _resource_href_tail(uid: str) -> str:
|
||||
"""Nombre del recurso .vcf que carddav_put_vcard deriva del UID."""
|
||||
return _safe_resource(uid) + ".vcf"
|
||||
|
||||
|
||||
def _read_etag_after_push(cfg: Config, pwd: str, collection: str, uid: str):
|
||||
"""Lee el etag NUEVO del recurso .vcf de un contacto tras su PUT.
|
||||
|
||||
Lista la colección (PROPFIND Depth:1) y busca el recurso cuyo href termina
|
||||
en el nombre .vcf del uid. Devuelve el etag o None si no se encuentra/falla.
|
||||
Capturar el etag del push propio evita que el sync inverso lo confunda con
|
||||
una edición hecha desde el móvil.
|
||||
"""
|
||||
listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection)
|
||||
if listing.get("status") != "ok":
|
||||
return None
|
||||
tail = _resource_href_tail(uid)
|
||||
for res in listing.get("resources", []):
|
||||
href = res.get("href") or ""
|
||||
if href.rstrip("/").rsplit("/", 1)[-1] == tail:
|
||||
return res.get("etag")
|
||||
return None
|
||||
|
||||
|
||||
def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
"""Crea/actualiza un contacto en la DB y lo empuja a Xandikos (PUT vCard).
|
||||
|
||||
@@ -302,15 +408,18 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
fn = fields.get("fn") or fields.get("nombre")
|
||||
collection = fields.get("collection") or _default_collection(cfg)
|
||||
|
||||
vcard = build_vcard(
|
||||
{
|
||||
"uid": uid,
|
||||
"fn": fn,
|
||||
"tels": tels,
|
||||
"emails": emails,
|
||||
"adrs": _as_list(fields.get("direcciones") or fields.get("adrs")),
|
||||
}
|
||||
)
|
||||
# Si el contacto ya existe y está enlazado a una ficha, hereda su note_path
|
||||
# para que el vCard de agenda incluya las direcciones/aliases de la persona.
|
||||
note_path = fields.get("note_path")
|
||||
if note_path is None:
|
||||
existing = duckdb_query_readonly(
|
||||
cfg.db_path, "SELECT note_path FROM contacts WHERE uid = ?", [uid], 1
|
||||
)
|
||||
if existing.get("status") == "ok" and existing.get("rows"):
|
||||
note_path = existing["rows"][0].get("note_path")
|
||||
|
||||
# vCard de AGENDA: nunca lleva X-OSINT-* (privacidad del móvil).
|
||||
vcard = _compose_agenda_vcard(cfg, uid, {**fields, "note_path": note_path})
|
||||
|
||||
row = {
|
||||
"uid": uid,
|
||||
@@ -320,7 +429,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
"tels": _json(tels),
|
||||
"emails": _json(emails),
|
||||
"raw": vcard,
|
||||
"note_path": None,
|
||||
"note_path": note_path,
|
||||
"updated_at": _now(),
|
||||
}
|
||||
res = duckdb_upsert(
|
||||
@@ -328,7 +437,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
"contacts",
|
||||
[row],
|
||||
key_cols=["uid"],
|
||||
update_cols=["collection", "fn", "tels", "emails", "raw", "updated_at"],
|
||||
update_cols=["collection", "fn", "tels", "emails", "raw", "note_path", "updated_at"],
|
||||
)
|
||||
if res.get("status") != "ok":
|
||||
return {"status": "error", "error": res.get("error")}
|
||||
@@ -336,17 +445,29 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
# Push DB -> Xandikos fuera de cualquier transacción de la DB.
|
||||
pwd, err = _resolve_password(cfg)
|
||||
pushed = None
|
||||
etag = None
|
||||
if err is None:
|
||||
push = carddav_put_vcard(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
|
||||
)
|
||||
pushed = push.get("status") == "ok"
|
||||
if pushed:
|
||||
# Captura el etag NUEVO del recurso para que el sync inverso no
|
||||
# confunda este push propio con una edición del móvil.
|
||||
etag = _read_etag_after_push(cfg, pwd, collection, uid)
|
||||
if etag:
|
||||
duckdb_execute(
|
||||
cfg.db_path,
|
||||
"UPDATE contacts SET etag = ? WHERE uid = ?",
|
||||
[etag, uid],
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"inserted": res.get("inserted", 0),
|
||||
"updated": res.get("updated", 0),
|
||||
"pushed": pushed,
|
||||
"etag": etag,
|
||||
"push_error": err,
|
||||
}
|
||||
|
||||
@@ -650,7 +771,7 @@ def push_all_dav(cfg: Config) -> dict:
|
||||
|
||||
contacts = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT uid, collection, fn, tels, emails FROM contacts",
|
||||
"SELECT uid, collection, fn, tels, emails, note_path FROM contacts",
|
||||
[],
|
||||
1000000,
|
||||
)
|
||||
@@ -659,19 +780,31 @@ def push_all_dav(cfg: Config) -> dict:
|
||||
for row in contacts["rows"]:
|
||||
uid = row["uid"]
|
||||
collection = row.get("collection") or _default_collection(cfg)
|
||||
vcard = build_vcard(
|
||||
# vCard de AGENDA: compone con la persona enlazada (direcciones +
|
||||
# aliases) pero SIN ningún campo OSINT (privacidad del móvil).
|
||||
vcard = _compose_agenda_vcard(
|
||||
cfg,
|
||||
uid,
|
||||
{
|
||||
"uid": uid,
|
||||
"fn": row.get("fn"),
|
||||
"tels": _decode_json_field(row.get("tels")),
|
||||
"emails": _decode_json_field(row.get("emails")),
|
||||
}
|
||||
"note_path": row.get("note_path"),
|
||||
},
|
||||
)
|
||||
push = carddav_put_vcard(
|
||||
cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard
|
||||
)
|
||||
if push.get("status") == "ok":
|
||||
c_ok += 1
|
||||
# Captura el etag nuevo del push (sync inverso fiable).
|
||||
etag = _read_etag_after_push(cfg, pwd, collection, uid)
|
||||
if etag:
|
||||
duckdb_execute(
|
||||
cfg.db_path,
|
||||
"UPDATE contacts SET etag = ? WHERE uid = ?",
|
||||
[etag, uid],
|
||||
)
|
||||
else:
|
||||
c_fail += 1
|
||||
|
||||
@@ -700,3 +833,146 @@ def push_all_dav(cfg: Config) -> dict:
|
||||
"events_pushed": e_ok,
|
||||
"events_failed": e_fail,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync inverso Xandikos -> DB (pull incremental por etag)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pull_dav(cfg: Config) -> dict:
|
||||
"""Trae a la DB las ediciones del móvil/DAVx5, last-write-wins por etag.
|
||||
|
||||
A diferencia de ``ingest_dav`` (que hace DELETE + INSERT ciego de TODAS las
|
||||
colecciones), este pull es INCREMENTAL y respeta la verdad de la DB salvo
|
||||
donde el etag prueba un cambio externo:
|
||||
|
||||
1. Para cada colección registrada en ``addressbooks`` lista los recursos
|
||||
(PROPFIND Depth:1 -> [{href, etag}]).
|
||||
2. Por recurso: si su etag difiere del ``contacts.etag`` guardado (o el uid
|
||||
no existe en la DB) -> GET + parse + upsert con el etag nuevo. Si el
|
||||
etag coincide -> no se toca (la DB ya está al día).
|
||||
3. Los uids que la DB tenía en esa colección y que YA no aparecen en el
|
||||
PROPFIND se borran (el móvil los eliminó).
|
||||
4. Tras el pull se re-enlazan los contactos con sus fichas y se
|
||||
reconstruyen las derivadas (bajo el lock single-writer).
|
||||
|
||||
Devuelve {status:'ok', pulled, updated, deleted, unchanged} o
|
||||
{status:'error', error}.
|
||||
"""
|
||||
# Late imports: evitan ciclo writes<->ingest a nivel de módulo y reusan la
|
||||
# lógica de colecciones + enlace + derivadas ya existente (registry-first).
|
||||
from . import davparse
|
||||
from .db import write_conn
|
||||
from .derived import rebuild_derived
|
||||
from .ingest import _addressbook_collections, _link_contacts
|
||||
|
||||
pwd, err = _resolve_password(cfg)
|
||||
if err is not None:
|
||||
return {"status": "error", "error": err}
|
||||
|
||||
collections = _addressbook_collections(cfg)
|
||||
|
||||
pulled = updated = deleted = unchanged = 0
|
||||
for collection in collections:
|
||||
listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection)
|
||||
if listing.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"PROPFIND {collection}: {listing.get('error')} "
|
||||
f"(http {listing.get('http_status')})",
|
||||
}
|
||||
|
||||
# Estado actual de la DB para esta colección: uid -> etag.
|
||||
db_state = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT uid, etag FROM contacts WHERE collection = ?",
|
||||
[collection],
|
||||
1000000,
|
||||
)
|
||||
db_etags: dict = {}
|
||||
if db_state.get("status") == "ok":
|
||||
db_etags = {r["uid"]: r.get("etag") for r in db_state["rows"]}
|
||||
|
||||
seen_uids: set = set()
|
||||
for res in listing.get("resources", []):
|
||||
href = res.get("href") or ""
|
||||
remote_etag = res.get("etag")
|
||||
# GET solo cuando el etag cambió o el recurso es nuevo en la DB.
|
||||
# uid provisional desde el nombre del recurso para el cruce rápido;
|
||||
# el uid real se toma del vCard tras el GET.
|
||||
res_name = href.rstrip("/").rsplit("/", 1)[-1]
|
||||
guess_uid = os.path.splitext(res_name)[0]
|
||||
|
||||
# Si ya conocemos este uid (por nombre) y el etag coincide -> skip.
|
||||
if guess_uid in db_etags and db_etags[guess_uid] == remote_etag and remote_etag:
|
||||
seen_uids.add(guess_uid)
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
got = dav_get_resource(cfg.dav_base, cfg.dav_user, pwd, href)
|
||||
if got.get("status") != "ok":
|
||||
# Un recurso ilegible no aborta el pull entero.
|
||||
continue
|
||||
parsed = davparse.parse_vcard(got.get("text", ""))
|
||||
uid = parsed["uid"] or guess_uid
|
||||
seen_uids.add(uid)
|
||||
|
||||
existed = uid in db_etags
|
||||
# Confirmación con el uid real: si el etag ya casa, no es cambio.
|
||||
if existed and db_etags[uid] == remote_etag and remote_etag:
|
||||
unchanged += 1
|
||||
continue
|
||||
|
||||
row = {
|
||||
"uid": uid,
|
||||
"collection": collection,
|
||||
"etag": remote_etag,
|
||||
"fn": parsed["fn"] or None,
|
||||
"tels": _json(parsed["tels"]),
|
||||
"emails": _json(parsed["emails"]),
|
||||
"raw": got.get("text", ""),
|
||||
# note_path se re-enlaza después; preserva el existente si lo había.
|
||||
"note_path": None,
|
||||
"updated_at": _now(),
|
||||
}
|
||||
up = duckdb_upsert(
|
||||
cfg.db_path,
|
||||
"contacts",
|
||||
[row],
|
||||
key_cols=["uid"],
|
||||
update_cols=["collection", "etag", "fn", "tels", "emails", "raw", "updated_at"],
|
||||
)
|
||||
if up.get("status") != "ok":
|
||||
return {"status": "error", "error": up.get("error")}
|
||||
pulled += 1
|
||||
if existed:
|
||||
updated += 1
|
||||
|
||||
# Borra los uids que la DB tenía en esta colección y ya no están remotos.
|
||||
for uid in db_etags:
|
||||
if uid not in seen_uids:
|
||||
rm = duckdb_execute(
|
||||
cfg.db_path, "DELETE FROM contacts WHERE uid = ?", [uid]
|
||||
)
|
||||
if rm.get("status") == "ok":
|
||||
deleted += rm.get("rowcount", 0)
|
||||
|
||||
# Re-enlace de contactos + reconstrucción de derivadas, bajo el lock.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
_link_contacts(conn)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
rebuild_derived(conn)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"pulled": pulled,
|
||||
"updated": updated,
|
||||
"deleted": deleted,
|
||||
"unchanged": unchanged,
|
||||
}
|
||||
|
||||
@@ -541,3 +541,162 @@ def test_api_person_render_preserva_prosa(client, cfg):
|
||||
after = open(note_file, encoding="utf-8").read()
|
||||
assert "Ficha de prueba." in after # prosa preservada
|
||||
assert "+34 622 333 444" in after # frontmatter actualizado
|
||||
|
||||
|
||||
# --- F4: push de agenda SIN OSINT + sync inverso por etag ------------------
|
||||
|
||||
|
||||
def test_compose_agenda_vcard_sin_osint_con_direcciones(client, cfg, monkeypatch):
|
||||
"""upsert_contact compone un vCard de agenda con ADR de la persona y SIN X-OSINT.
|
||||
|
||||
Crea una persona con DNI/sexo/dirección y un contacto enlazado por teléfono;
|
||||
captura el vCard que se manda a Xandikos y verifica que (a) NO lleva ninguna
|
||||
línea X-OSINT-*, (b) SÍ lleva la dirección de la persona como ADR.
|
||||
"""
|
||||
from server import writes
|
||||
|
||||
client.post("/api/ingest/vault")
|
||||
|
||||
# Persona con campos OSINT (dni/sexo/fecha_nac) + direccion + alias.
|
||||
client.post(
|
||||
"/api/person",
|
||||
json={
|
||||
"slug": "marca-osint",
|
||||
"nombre": "Marca Osint",
|
||||
"dni": "99999999R",
|
||||
"sexo": "mujer",
|
||||
"fecha_nacimiento": "1985-01-01",
|
||||
"pais": "españa",
|
||||
"contexto": "investigacion",
|
||||
"telefonos": ["+34 655 100 200"],
|
||||
"direcciones": ["Calle Falsa 123, Madrid"],
|
||||
"aliases": ["La Marca"],
|
||||
},
|
||||
)
|
||||
# El contacto se enlaza a la ficha por teléfono al re-ingestar el vault.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"uid-marca",
|
||||
"/enmanuel/contacts/addressbook/",
|
||||
None,
|
||||
"Marca Osint",
|
||||
'["+34 655 100 200"]',
|
||||
"[]",
|
||||
"BEGIN:VCARD...",
|
||||
None,
|
||||
datetime.now(tz=timezone.utc),
|
||||
],
|
||||
)
|
||||
client.post("/api/ingest/vault") # enlaza uid-marca -> personas/marca-osint.md
|
||||
|
||||
# Capturamos el vCard que upsert_contact empuja a Xandikos.
|
||||
pushed_vcards: list = []
|
||||
|
||||
def fake_put(base, user, pwd, collection, uid, vcard, **kw):
|
||||
pushed_vcards.append(vcard)
|
||||
return {"status": "ok", "http_status": 201, "url": "x"}
|
||||
|
||||
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
|
||||
monkeypatch.setattr(writes, "carddav_put_vcard", fake_put)
|
||||
monkeypatch.setattr(
|
||||
writes,
|
||||
"dav_list_resources",
|
||||
lambda *a, **k: {
|
||||
"status": "ok",
|
||||
"http_status": 207,
|
||||
"resources": [{"href": "/enmanuel/contacts/addressbook/uid-marca.vcf", "etag": '"newtag"'}],
|
||||
},
|
||||
)
|
||||
|
||||
r = client.put(
|
||||
"/api/contact/uid-marca",
|
||||
json={"uid": "uid-marca", "fn": "Marca Osint", "tels": ["+34 655 100 200"]},
|
||||
).json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["pushed"] is True
|
||||
assert r["etag"] == '"newtag"' # etag del recurso capturado tras el push
|
||||
|
||||
assert pushed_vcards, "no se empujó ningún vCard"
|
||||
vcard = pushed_vcards[-1]
|
||||
# (a) Privacidad: ninguna línea X-OSINT-* (DNI/sexo/fecha-nac/pais/contexto).
|
||||
assert "X-OSINT-" not in vcard
|
||||
assert "99999999R" not in vcard # el DNI no se filtra al móvil
|
||||
# (b) Direcciones y alias de la persona enlazada SÍ viajan (agenda).
|
||||
assert "ADR;TYPE=HOME:;;Calle Falsa 123\\, Madrid;;;;" in vcard
|
||||
assert "NICKNAME:La Marca" in vcard
|
||||
|
||||
# El etag nuevo quedó persistido en la DB (sync inverso fiable).
|
||||
q = client.post(
|
||||
"/api/query", json={"sql": "SELECT etag, raw FROM contacts WHERE uid = 'uid-marca'"}
|
||||
).json()
|
||||
assert q["rows"][0]["etag"] == '"newtag"'
|
||||
assert "X-OSINT-" not in q["rows"][0]["raw"] # el raw guardado tampoco lleva OSINT
|
||||
|
||||
|
||||
def test_pull_dav_incremental_por_etag(client, cfg, monkeypatch):
|
||||
"""pull_dav actualiza solo el etag cambiado, no toca el igual y borra el ausente."""
|
||||
from server import writes
|
||||
|
||||
client.post("/api/ingest/vault")
|
||||
|
||||
coll = "/enmanuel/contacts/addressbook/"
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
# Tres contactos en la DB con etags conocidos.
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
for uid, etag, fn in [
|
||||
("c-same", '"e-same"', "Sin Cambios"),
|
||||
("c-changed", '"e-old"', "Antes"),
|
||||
("c-gone", '"e-gone"', "Se Borra"),
|
||||
]:
|
||||
conn.execute(
|
||||
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[uid, coll, etag, fn, "[]", "[]", "BEGIN:VCARD...", None, now],
|
||||
)
|
||||
|
||||
# Remoto: c-same igual, c-changed con etag nuevo, c-gone ausente.
|
||||
def fake_list(base, user, pwd, collection, **kw):
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": 207,
|
||||
"resources": [
|
||||
{"href": coll + "c-same.vcf", "etag": '"e-same"'},
|
||||
{"href": coll + "c-changed.vcf", "etag": '"e-new"'},
|
||||
],
|
||||
}
|
||||
|
||||
def fake_get(base, user, pwd, href, **kw):
|
||||
# Solo debería pedirse el recurso cambiado (c-same tiene etag igual).
|
||||
assert "c-changed" in href, f"GET inesperado de {href}"
|
||||
return {
|
||||
"status": "ok",
|
||||
"http_status": 200,
|
||||
"text": (
|
||||
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c-changed\r\n"
|
||||
"FN:Despues\r\nTEL;TYPE=CELL:+34 600 000 000\r\nEND:VCARD\r\n"
|
||||
),
|
||||
"url": href,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"})
|
||||
monkeypatch.setattr(writes, "dav_list_resources", fake_list)
|
||||
monkeypatch.setattr(writes, "dav_get_resource", fake_get)
|
||||
|
||||
r = client.post("/api/sync/dav-pull").json()
|
||||
assert r["status"] == "ok"
|
||||
assert r["pulled"] == 1 # solo c-changed se descargó
|
||||
assert r["updated"] == 1 # y era una actualización (ya existía)
|
||||
assert r["unchanged"] == 1 # c-same no se tocó
|
||||
assert r["deleted"] == 1 # c-gone se borró (ausente en el PROPFIND)
|
||||
|
||||
rows = client.post(
|
||||
"/api/query",
|
||||
json={"sql": "SELECT uid, etag, fn FROM contacts ORDER BY uid"},
|
||||
).json()["rows"]
|
||||
by_uid = {row["uid"]: row for row in rows}
|
||||
assert set(by_uid) == {"c-changed", "c-same"} # c-gone desapareció
|
||||
assert by_uid["c-same"]["fn"] == "Sin Cambios" # intacto
|
||||
assert by_uid["c-same"]["etag"] == '"e-same"'
|
||||
assert by_uid["c-changed"]["fn"] == "Despues" # FN del vCard nuevo
|
||||
assert by_uid["c-changed"]["etag"] == '"e-new"' # etag remoto persistido
|
||||
|
||||
Reference in New Issue
Block a user