feat(contacts): CRUD de contactos (vault .md fuente de verdad + reflejo vCard)

Añade alta, edición y borrado de contactos (personas y organizaciones) a la
app osint_web. La fuente de verdad es la ficha .md del vault Obsidian
(CONVENTIONS.md §3b/§6); Xandikos es el retransmisor al móvil.

Backend (server/main.py):
- POST /api/contact: genera slug, escribe la ficha .md con el frontmatter
  canónico + PUT del vCard a Xandikos. 409 si el slug ya existe.
- PUT /api/contact/{slug}: merge del frontmatter (preserva campos heredados)
  + re-PUT del vCard. 404 si no existe.
- DELETE /api/contact/{slug}: borra la ficha .md + DELETE del vCard. 404 si
  no existe.
Cada escritura invalida la caché DAV para que el cambio se vea ya en la app.
Registry-first: orquesta create/update/delete_obsidian_note del grupo obsidian
y carddav_put_vcard/dav_delete_resource del grupo dav (sin reimplementar
parseo ni HTTP). Mapea los campos OSINT a propiedades X-OSINT-* del vCard.

Frontend (ContactsView.tsx + api.ts + format.ts):
- Botón "Nuevo contacto" → modal con formulario Mantine (TextInput,
  TagsInput aliases, Select contexto, Textarea notas).
- Detalle: botones "Editar" (formulario precargado) y "Borrar" (con
  confirmación). Tras guardar refresca la lista.
- Helper slugify (replica slugify_obsidian_name) para resolver la ficha.

Tests: 6 nuevos casos (ciclo crear→editar→borrar con .md real + reflejo vCard
mockeado, organización, 404s, tipo inválido, preserva campos heredados). Suite
27 passed. Ciclo e2e real verificado contra Xandikos + vault (vCard creado,
editado y borrado; slug zz-test-crud limpiado). pnpm build verde (React 19 +
Mantine v9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:18:55 +02:00
parent 44a696c12e
commit 43889bfc07
6 changed files with 1079 additions and 114 deletions
+162
View File
@@ -442,6 +442,168 @@ def test_disk_cache_recarga_si_cambia_ctag(vault, fake_dav):
assert fake_dav["reports"] > reports_after_first # re-descargó
# ---------------------------------------------------------------------------
# CRUD de contactos: ficha .md del vault (verdad) + reflejo del vCard
# ---------------------------------------------------------------------------
@pytest.fixture()
def crud_client(vault, monkeypatch):
"""Cliente con el PUT/DELETE de Xandikos mockeado (CRUD sin red).
Verifica el comportamiento sobre el vault real (la ficha .md) sin tocar
Xandikos. ``calls`` registra los PUT/DELETE que el server intentó, para
asertar que el reflejo en Xandikos se dispara.
"""
calls = {"put": [], "delete": []}
def _put(base, user, pw, coll, uid, vcard_text, **kw):
calls["put"].append({"uid": uid, "vcard": vcard_text})
return {"status": "ok", "http_status": 201, "url": coll + uid + ".vcf"}
def _delete(base, user, pw, resource_path, **kw):
calls["delete"].append(resource_path)
return {"status": "ok", "http_status": 204, "url": resource_path}
monkeypatch.setattr(srv, "carddav_put_vcard", _put)
monkeypatch.setattr(srv, "dav_delete_resource", _delete)
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
app = srv.create_app(vault)
client = TestClient(app)
client._crud_calls = calls # type: ignore[attr-defined]
return client
def _persona_md_path(vault_dir: str, slug: str) -> str:
return os.path.join(vault_dir, "personas", slug + ".md")
def test_crud_contact_full_cycle(crud_client, vault):
"""Golden: crear → editar → borrar un contacto (ficha .md + reflejo vCard)."""
calls = crud_client._crud_calls
# -- CREATE --
body = {
"tipo": "persona",
"nombre": "Zoé Test Crud",
"aliases": ["Zozo"],
"telefono": "+34600999888",
"email": "zoe@example.com",
"dni": "99999999Z",
"direccion": "Calle Falsa 123",
"pais": "españa",
"contexto": "prueba",
"notas": "Contacto de prueba CRUD.",
}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
assert slug == "zoe-test-crud"
assert r.json()["uid"] == slug
# La ficha .md se escribió de verdad en el vault.
md = _persona_md_path(vault, slug)
assert os.path.isfile(md)
content = open(md, encoding="utf-8").read()
assert "tipo: persona" in content
assert "Zoé Test Crud" in content
assert "99999999Z" in content
assert "Contacto de prueba CRUD." in content
# Reflejo: se hizo PUT del vCard con UID=slug y los X-OSINT-*.
assert calls["put"], "debió hacer PUT del vCard"
vc = calls["put"][-1]["vcard"]
assert "UID:zoe-test-crud" in vc
assert "FN:Zoé Test Crud" in vc
assert "X-OSINT-DNI:99999999Z" in vc
assert "TEL;TYPE=CELL:+34600999888" in vc
# 409 al recrear el mismo slug.
assert crud_client.post("/api/contact", json=body).status_code == 409
# -- READ (vía /api/node, la ficha aparece en el grafo) --
nr = crud_client.get("/api/node/%s" % slug)
assert nr.status_code == 200
assert nr.json()["frontmatter"]["dni"] == "99999999Z"
# -- UPDATE --
body2 = dict(body)
body2["telefono"] = "+34611000111"
body2["notas"] = "Editado."
ur = crud_client.put("/api/contact/%s" % slug, json=body2)
assert ur.status_code == 200, ur.text
content2 = open(md, encoding="utf-8").read()
assert "+34611000111" in content2
assert "Editado." in content2
# Re-PUT del vCard con el teléfono nuevo.
assert "TEL;TYPE=CELL:+34611000111" in calls["put"][-1]["vcard"]
# -- DELETE --
dr = crud_client.delete("/api/contact/%s" % slug)
assert dr.status_code == 200, dr.text
assert dr.json()["deleted"] is True
assert not os.path.isfile(md), "la ficha .md debe desaparecer"
# Reflejo: DELETE del recurso <slug>.vcf en Xandikos.
assert any(slug + ".vcf" in p for p in calls["delete"])
def test_crud_organizacion(crud_client, vault):
"""Edge: una organización usa organizaciones/ y emite ORG en el vCard."""
calls = crud_client._crud_calls
r = crud_client.post(
"/api/contact",
json={"tipo": "organizacion", "nombre": "Acme Test Org", "pais": "españa"},
)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
org_md = os.path.join(vault, "organizaciones", slug + ".md")
assert os.path.isfile(org_md)
assert "tipo: organizacion" in open(org_md, encoding="utf-8").read()
assert "ORG:Acme Test Org" in calls["put"][-1]["vcard"]
# limpieza
crud_client.delete("/api/contact/%s" % slug)
assert not os.path.isfile(org_md)
def test_crud_update_missing_404(crud_client):
"""Error: editar un contacto inexistente devuelve 404, no crash."""
r = crud_client.put(
"/api/contact/no-existe", json={"tipo": "persona", "nombre": "X"}
)
assert r.status_code == 404
def test_crud_delete_missing_404(crud_client):
"""Error: borrar un contacto inexistente devuelve 404."""
assert crud_client.delete("/api/contact/no-existe").status_code == 404
def test_crud_create_invalid_tipo_400(crud_client):
"""Error: un tipo no soportado devuelve 400."""
r = crud_client.post("/api/contact", json={"tipo": "robot", "nombre": "R2D2"})
assert r.status_code == 400
def test_crud_update_preserves_inherited_fields(crud_client, vault):
"""Edge: editar preserva campos heredados no editables (sexo, etc.)."""
# Crea y luego inyecta un campo heredado a mano (simula ficha previa).
crud_client.post(
"/api/contact", json={"tipo": "persona", "nombre": "Inés Hered"}
)
slug = "ines-hered"
md = _persona_md_path(vault, slug)
# Añade sexo + horoscopo al frontmatter como si fueran heredados.
srv.update_obsidian_note(md, set_frontmatter={"sexo": "mujer", "horoscopo": "aries"})
# Edita vía API: no toca sexo/horoscopo.
crud_client.put(
"/api/contact/%s" % slug,
json={"tipo": "persona", "nombre": "Inés Hered", "email": "i@x.com"},
)
content = open(md, encoding="utf-8").read()
assert "sexo: mujer" in content
assert "horoscopo: aries" in content
assert "i@x.com" in content
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
# ---------------------------------------------------------------------------