feat(contacts): multi-valor (varios tel/email/direccion) + libretas + backend osint_db (flag)

- ContactIn + frontmatter + vCard multi-valor: emite N TEL, N EMAIL, N ADR; _vcard_to_json
  parsea ADR -> direcciones[] (y sigue leyendo X-OSINT-DIRECCION legacy). Los singulares
  telefono/email/direccion se mantienen por compat (= primer elemento de cada lista).
- Libretas de contactos (addressbooks): endpoints GET/POST /api/addressbooks; en
  ContactsView un selector de libreta + boton 'Nueva libreta' (replica del patron de crear
  calendario) + filtro por libreta en la lista.
- Frontend ContactsView: TagsInput para telefonos/emails/direcciones, cargando TODOS los
  valores al editar (antes solo el primero).
- Feature flag OSINT_DB_BACKEND (dev/feature_flags.json, default off): con ON, osint_web
  lee/escribe contra el service osint_db (DuckDB = fuente de verdad) via
  server/osintdb_client.py; con OFF, el comportamiento historico (vault .md + vCard
  Xandikos) queda intacto byte a byte.

Verificado: 52 tests backend (40 + 12 nuevos), tsc --noEmit limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:47:38 +02:00
parent 71e4d95e64
commit 9cbea2d036
6 changed files with 1248 additions and 90 deletions
+206
View File
@@ -609,6 +609,212 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Contactos multi-valor: varias TEL/EMAIL/ADR + compat singular/lista
# ---------------------------------------------------------------------------
def test_contactin_reconcilia_singular_y_lista():
"""ContactIn reconcilia singular↔lista: cliente viejo (singular) y nuevo (lista)."""
# Cliente viejo: solo el campo singular → se siembra la lista.
viejo = srv.ContactIn(nombre="X", telefono="111", email="a@x.com", direccion="C1")
assert viejo.telefonos == ["111"]
assert viejo.emails == ["a@x.com"]
assert viejo.direcciones == ["C1"]
# El singular se conserva = primer elemento.
assert viejo.telefono == "111"
# Cliente nuevo: listas → el singular se rellena con lista[0].
nuevo = srv.ContactIn(
nombre="X",
telefonos=["111", "222"],
emails=["a@x.com", "b@x.com"],
direcciones=["C1", "C2"],
)
assert nuevo.telefonos == ["111", "222"]
assert nuevo.telefono == "111"
assert nuevo.email == "a@x.com"
assert nuevo.direccion == "C1"
def test_build_vcard_multivalor_emite_n_lineas():
"""_build_vcard emite una TEL/EMAIL/ADR por elemento de cada lista."""
fm = {
"tipo": "persona",
"nombre": "Multi Persona",
"telefonos": ["111", "222"],
"emails": ["a@x.com", "b@x.com"],
"direcciones": ["Calle 1", "Calle 2"],
"dni": "X",
}
vc = srv._build_vcard(fm, "multi-persona")
assert vc.count("TEL;TYPE=CELL:") == 2
assert "TEL;TYPE=CELL:111" in vc and "TEL;TYPE=CELL:222" in vc
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
assert vc.count("ADR;TYPE=HOME:") == 2
assert "ADR;TYPE=HOME:;;Calle 1;;;;" in vc
assert "X-OSINT-DNI:X" in vc
def test_vcard_to_json_lee_adr_multivalor():
"""_vcard_to_json reconstruye la lista de direcciones desde las líneas ADR."""
vcard = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:adr-1\r\nFN:Con Direcciones\r\n"
"ADR;TYPE=HOME:;;Calle Uno 1;;;;\r\n"
"ADR;TYPE=HOME:;;Calle Dos 2;Madrid;;28001;España\r\n"
"END:VCARD\r\n"
)
out = srv._vcard_to_json(vcard)
assert out["direcciones"][0] == "Calle Uno 1"
# El 2º ADR concatena street + locality/region/postal/country legibles.
assert "Calle Dos 2" in out["direcciones"][1]
assert "Madrid" in out["direcciones"][1]
def test_vcard_to_json_legacy_x_osint_direccion():
"""Compat: una dirección antigua en X-OSINT-DIRECCION sube a direcciones[]."""
vcard = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nUID:legacy-1\r\nFN:Legacy\r\n"
"X-OSINT-DIRECCION:Calle Antigua 7\r\nEND:VCARD\r\n"
)
out = srv._vcard_to_json(vcard)
assert "Calle Antigua 7" in out["direcciones"]
# Se mantiene también en osint.direccion por si un lector viejo lo consulta.
assert out["osint"]["direccion"] == "Calle Antigua 7"
def test_crud_multivalor_round_trip(crud_client, vault):
"""Golden multi-valor: crear con 2 teléfonos/emails/direcciones y verlos todos."""
calls = crud_client._crud_calls
body = {
"tipo": "persona",
"nombre": "Poli Valor",
"telefonos": ["+34600000001", "+34600000002"],
"emails": ["uno@x.com", "dos@x.com"],
"direcciones": ["Calle A 1", "Calle B 2"],
}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
md = os.path.join(vault, "personas", slug + ".md")
content = open(md, encoding="utf-8").read()
# El frontmatter escribe las listas multi-valor + el singular compat.
assert "+34600000001" in content and "+34600000002" in content
assert "uno@x.com" in content and "dos@x.com" in content
# El vCard emitió las dos líneas TEL/EMAIL/ADR.
vc = calls["put"][-1]["vcard"]
assert vc.count("TEL;TYPE=CELL:") == 2
assert vc.count("EMAIL;TYPE=INTERNET:") == 2
assert vc.count("ADR;TYPE=HOME:") == 2
crud_client.delete("/api/contact/%s" % slug)
def test_crud_singular_compat_sigue_funcionando(crud_client, vault):
"""Edge: un cliente viejo que envía solo el singular sigue funcionando."""
calls = crud_client._crud_calls
body = {"tipo": "persona", "nombre": "Solo Singular", "telefono": "+34611111111"}
r = crud_client.post("/api/contact", json=body)
assert r.status_code == 201, r.text
slug = r.json()["slug"]
vc = calls["put"][-1]["vcard"]
assert "TEL;TYPE=CELL:+34611111111" in vc
assert vc.count("TEL;TYPE=CELL:") == 1
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Libretas (addressbooks) + feature flag OSINT_DB_BACKEND
# ---------------------------------------------------------------------------
def test_feature_flag_off_por_defecto(monkeypatch, tmp_path):
"""El flag OSINT_DB_BACKEND está OFF por defecto (archivo ausente → False)."""
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
assert srv._osint_db_backend_enabled() is False
def test_feature_flag_lee_archivo(monkeypatch, tmp_path):
"""_osint_db_backend_enabled refleja el archivo dev/feature_flags.json."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
assert srv._osint_db_backend_enabled() is True
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":false}}}', encoding="utf-8"
)
assert srv._osint_db_backend_enabled() is False
def test_addressbooks_off_devuelve_libreta_por_defecto(crud_client):
"""Con el flag OFF, /api/addressbooks devuelve solo la libreta por defecto."""
r = crud_client.get("/api/addressbooks")
assert r.status_code == 200, r.text
data = r.json()
assert data["status"] == "ok"
assert data["count"] == 1
assert data["addressbooks"][0]["slug"] == srv.DEFAULT_ADDRESSBOOK_SLUG
assert data["default"] == srv.DEFAULT_ADDRESSBOOK_SLUG
def test_create_addressbook_off_devuelve_501(crud_client, monkeypatch, tmp_path):
"""Error: crear libreta con el flag OFF → 501 claro (requiere OSINT_DB_BACKEND)."""
monkeypatch.setattr(srv, "_FLAGS_FILE", str(tmp_path / "no-existe.json"))
r = crud_client.post("/api/addressbooks", json={"slug": "trabajo", "name": "Trabajo"})
assert r.status_code == 501
assert "OSINT_DB_BACKEND" in r.json()["detail"]
def test_contacts_flag_on_usa_osint_db(crud_client, monkeypatch, tmp_path):
"""Con el flag ON, /api/contacts lee del osint_db (mockeado), no de Xandikos."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
monkeypatch.setattr(
srv.osintdb_client,
"list_contacts",
lambda: [
{
"uid": "u1",
"collection": "addressbook",
"fn": "Desde DuckDB",
"tels": '["111", "222"]',
"emails": '["a@x.com"]',
"note_path": None,
}
],
)
r = crud_client.get("/api/contacts")
assert r.status_code == 200, r.text
contacts = r.json()["contacts"]
assert len(contacts) == 1
c = contacts[0]
assert c["nombre"] == "Desde DuckDB"
# Los JSON array de tels/emails se parsean a lista de strings.
assert c["telefonos"] == ["111", "222"]
assert c["correos"] == ["a@x.com"]
def test_contacts_flag_on_osint_db_caido_503(crud_client, monkeypatch, tmp_path):
"""Error: con el flag ON y el osint_db caído, /api/contacts degrada a 503."""
flags = tmp_path / "feature_flags.json"
flags.write_text(
'{"flags":{"OSINT_DB_BACKEND":{"enabled":true}}}', encoding="utf-8"
)
monkeypatch.setattr(srv, "_FLAGS_FILE", str(flags))
def _down():
raise srv.osintdb_client.OsintDbUnavailable("no arrancado")
monkeypatch.setattr(srv.osintdb_client, "list_contacts", _down)
r = crud_client.get("/api/contacts")
assert r.status_code == 503
assert r.json()["status"] == "error"
# ---------------------------------------------------------------------------
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
# ---------------------------------------------------------------------------