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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user