feat: push de agenda sin OSINT (compone persona enlazada) + sync inverso por etag

Privacidad (decisión del usuario: al móvil solo datos de agenda):
- _compose_agenda_vcard compone el vCard desde el contacto (fn/tels/emails) + las
  direcciones (ADR) y aliases (NICKNAME) de la persona enlazada por note_path, SIN pasar
  nunca el dict osint a build_vcard → el vCard jamás lleva X-OSINT-* (DNI/sexo/fecha-nac
  quedan solo en DuckDB+Obsidian). Usado en upsert_contact y en el push masivo push_all_dav
  (que antes leía solo contacts y perdía las direcciones).

Sync inverso DAVx5→DuckDB (last-write-wins por etag):
- Tras cada push se captura el etag nuevo del recurso (dav_list_resources) y se persiste en
  contacts.etag, para no confundir el push propio con una edición del móvil.
- POST /api/sync/dav-pull: pull incremental — compara etags, descarga SOLO los recursos
  cambiados/nuevos (dav_get_resource + parse_vcard + upsert), borra los que el móvil quitó,
  re-enlaza. Distinto del ingest_dav (DELETE+INSERT ciego): respeta la verdad de la DB salvo
  donde el etag prueba un cambio externo.

20 tests verdes (18 + 2 nuevos: vCard sin OSINT con direcciones; pull incremental por etag).
This commit is contained in:
2026-06-13 10:53:23 +02:00
parent d672f4f73e
commit b620cc38c2
5 changed files with 465 additions and 15 deletions
+159
View File
@@ -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