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