All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s
Vier Funktionen: - Anlass: ein Feld auf der Startseite gilt für alle folgenden Scans und wird im Browser gemerkt. Ordnet den Bestand verlässlicher als der Ortsname aus den Koordinaten. Durchsuchbar, nachträglich änderbar. - Dubletten: die Detailansicht zeigt Karten, die dieselbe Person meinen könnten - gleiche E-Mail, gleiche Telefonnummer (verglichen werden die letzten acht Ziffern, damit +49 511 123456 und 0511/123456 aufeinander passen) oder gleicher Nachname bei gleicher Firma. - Rückseite: nachträglich ein zweites Bild zur Karte. Es füllt nur Felder, die die Vorderseite offen gelassen hat. - Schnellwahl bei Erinnerungen: in 3 Tagen / 1 Woche / 2 Wochen / 1 Monat, jeweils 9 Uhr. Das Datumsfeld braucht man damit selten. Teilen als vCard über das System-Teilenblatt, mit Download als Rückfallebene, wo es kein Teilenblatt gibt. Oberfläche: - Startseite führt jetzt: Anlass, große Aufnahmefläche mit Symbol statt Textzeile, leise Alternative, darunter die zuletzt erfassten Karten. Vorher stand dort eine Überschrift mit Zahlen und man musste raten. - Weniger Webseite, mehr App: kein Seitenrahmen auf dem Telefon, Symbole in der Tableiste mit Zählerblasen, kein Tap-Highlight, keine Textmarkierung auf Bedienelementen, Safe-Area oben, Einblendung beim Ansichtswechsel, Mindesthöhe 54 px für Schaltflächen. - Die Marken "exportiert" / "nicht exportiert" sind aus der Liste raus; dafür gibt es den Filter. Neue Spalten occasion und back_image_file kommen per ALTER TABLE in bestehende Datenbanken - CREATE TABLE IF NOT EXISTS rührt eine vorhandene Tabelle nicht an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Durchstich durch die HTTP-Schnittstelle.
|
|
|
|
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
|
|
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
|
|
"""
|
|
import asyncio
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import db, extract, mailer, reminders
|
|
from app.main import app
|
|
from tests.helpers import photo_bytes
|
|
|
|
|
|
def warte_bis_gelesen(client, card_ids, timeout=15.0):
|
|
"""Das Auslesen laeuft im Hintergrund - hier auf das Ende warten."""
|
|
frist = time.monotonic() + timeout
|
|
while time.monotonic() < frist:
|
|
stati = [
|
|
client.get(f"/api/cards/{i}").json()["extract_status"] for i in card_ids
|
|
]
|
|
if all(status != "offen" for status in stati):
|
|
return stati
|
|
time.sleep(0.1)
|
|
raise AssertionError("Karten wurden nicht fertig gelesen")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def scanned(client):
|
|
response = client.post(
|
|
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
warte_bis_gelesen(client, [c["id"] for c in data["cards"]])
|
|
return data
|
|
|
|
|
|
def test_healthz(client):
|
|
assert client.get("/healthz").json() == {"status": "ok"}
|
|
|
|
|
|
def test_scan_legt_je_karte_einen_datensatz_an(scanned):
|
|
assert scanned["count"] == 4
|
|
assert scanned["fallback"] is False
|
|
assert len({card["id"] for card in scanned["cards"]}) == 4
|
|
|
|
|
|
def test_jede_karte_hat_ein_gespeichertes_bild(client, scanned):
|
|
for card in scanned["cards"]:
|
|
image = client.get(card["image_url"])
|
|
assert image.status_code == 200
|
|
assert image.headers["content-type"] == "image/jpeg"
|
|
assert len(image.content) > 1000
|
|
|
|
|
|
def test_liste_zaehlt_und_liefert_die_karten(client, scanned):
|
|
data = client.get("/api/cards").json()
|
|
|
|
assert data["stats"]["total"] >= 4
|
|
assert data["stats"]["offen"] >= 4
|
|
|
|
|
|
def test_bearbeiten_und_suchen(client, scanned):
|
|
card_id = scanned["cards"][0]["id"]
|
|
|
|
patched = client.patch(
|
|
f"/api/cards/{card_id}",
|
|
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
|
|
)
|
|
assert patched.status_code == 200
|
|
assert patched.json()["full_name"] == "Anna Berg"
|
|
|
|
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
|
assert [c["id"] for c in hits] == [card_id]
|
|
|
|
by_note = client.get("/api/cards", params={"q": "Messe"}).json()["cards"]
|
|
assert card_id in [c["id"] for c in by_note]
|
|
|
|
|
|
def test_unbekannte_felder_werden_abgewiesen(client, scanned):
|
|
response = client.patch(
|
|
f"/api/cards/{scanned['cards'][0]['id']}", json={"extract_status": "ok"}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
|
card_id = scanned["cards"][1]["id"]
|
|
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
|
|
|
|
response = client.get(f"/vcf/{card_id}")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("text/vcard")
|
|
assert "Bea-Kurz.vcf" in response.headers["content-disposition"]
|
|
assert "FN:Bea Kurz" in response.text
|
|
assert client.get(f"/api/cards/{card_id}").json()["exported_at"] is not None
|
|
|
|
|
|
def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
|
|
offen = client.get("/api/cards", params={"filter": "offen"}).json()["cards"]
|
|
|
|
assert all(card["exported_at"] is None for card in offen)
|
|
|
|
|
|
def test_erinnerung_anlegen_und_filtern(client, scanned):
|
|
card_id = scanned["cards"][2]["id"]
|
|
due = datetime.now(timezone.utc) + timedelta(days=3)
|
|
|
|
created = client.post(
|
|
f"/api/cards/{card_id}/reminders",
|
|
json={"text": "Angebot nachfassen", "due_at": due.isoformat()},
|
|
)
|
|
assert created.status_code == 200
|
|
|
|
detail = client.get(f"/api/cards/{card_id}").json()
|
|
assert len(detail["reminders"]) == 1
|
|
|
|
mit_erinnerung = client.get("/api/cards", params={"filter": "erinnerung"}).json()
|
|
assert card_id in [c["id"] for c in mit_erinnerung["cards"]]
|
|
assert mit_erinnerung["stats"]["erinnerungen"] >= 1
|
|
|
|
|
|
def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
|
|
response = client.post(
|
|
f"/api/cards/{scanned['cards'][2]['id']}/reminders",
|
|
json={"text": "Ohne Termin", "due_at": "morgen"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
|
card_id = scanned["cards"][3]["id"]
|
|
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
|
|
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
|
client.post(
|
|
f"/api/cards/{card_id}/reminders",
|
|
json={"text": "Rueckruf", "due_at": past.isoformat()},
|
|
)
|
|
|
|
verschickt = []
|
|
monkeypatch.setattr(mailer, "configured", lambda: True)
|
|
monkeypatch.setattr(mailer, "send", lambda subject, body: verschickt.append((subject, body)))
|
|
|
|
assert reminders.process_due() == 1
|
|
assert "Carl Spät" in verschickt[0][0]
|
|
assert "Rueckruf" in verschickt[0][1]
|
|
|
|
# Zweiter Durchlauf darf nicht erneut verschicken.
|
|
assert reminders.process_due() == 0
|
|
|
|
|
|
def test_loeschen_entfernt_karte_und_bild(client):
|
|
created = client.post(
|
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()
|
|
card_id = created["cards"][0]["id"]
|
|
image_file = db.query_one("SELECT image_file FROM cards WHERE id = ?", (card_id,))["image_file"]
|
|
|
|
from app import config
|
|
|
|
assert (config.IMAGE_DIR / image_file).exists()
|
|
assert client.delete(f"/api/cards/{card_id}").status_code == 200
|
|
assert client.get(f"/api/cards/{card_id}").status_code == 404
|
|
assert not (config.IMAGE_DIR / image_file).exists()
|
|
|
|
|
|
def test_kaputter_upload_gibt_400(client):
|
|
response = client.post(
|
|
"/api/scan", files={"file": ("kaputt.jpg", b"kein bild", "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_pwa_dateien_werden_ausgeliefert(client):
|
|
assert client.get("/").status_code == 200
|
|
assert client.get("/sw.js").status_code == 200
|
|
assert client.get("/manifest.webmanifest").status_code == 200
|
|
assert client.get("/static/app.js").status_code == 200
|
|
|
|
|
|
def test_scan_antwortet_bevor_das_auslesen_fertig_ist(client, monkeypatch):
|
|
"""Der Kern der Hintergrundverarbeitung: die Antwort wartet nicht auf das
|
|
Modell. Ohne das haengt ein Zwanzigerstapel eine Minute im Upload."""
|
|
original = extract.extract_all
|
|
|
|
async def langsam(images):
|
|
await asyncio.sleep(1.5)
|
|
return await original(images)
|
|
|
|
monkeypatch.setattr(extract, "extract_all", langsam)
|
|
|
|
start = time.monotonic()
|
|
response = client.post(
|
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
|
)
|
|
dauer = time.monotonic() - start
|
|
card_id = response.json()["cards"][0]["id"]
|
|
|
|
assert dauer < 1.2, f"Antwort brauchte {dauer:.2f}s, hat also gewartet"
|
|
assert client.get(f"/api/cards/{card_id}").json()["extract_status"] == "offen"
|
|
|
|
monkeypatch.undo()
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
|
|
def test_offene_karten_zaehlen_nicht_als_unvollstaendig(client):
|
|
"""Waehrend des Lesens sind Felder naturgemaess leer - das ist kein Mangel."""
|
|
created = client.post(
|
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()
|
|
card_id = created["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [card_id]) # sonst ueberschreibt der Hintergrundlauf
|
|
db.execute("UPDATE cards SET extract_status = 'offen' WHERE id = ?", (card_id,))
|
|
|
|
unvollstaendig = client.get(
|
|
"/api/cards", params={"filter": "unvollstaendig"}
|
|
).json()["cards"]
|
|
|
|
assert card_id not in [c["id"] for c in unvollstaendig]
|
|
assert client.get("/api/cards").json()["stats"]["laeuft"] >= 1
|
|
|
|
db.execute("UPDATE cards SET extract_status = 'ok' WHERE id = ?", (card_id,))
|
|
|
|
|
|
def test_sammelexport_ist_entfernt(client):
|
|
assert client.get("/vcf", params={"ids": "egal"}).status_code == 404
|
|
|
|
|
|
def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
|
|
"""Die Karte ist sichtbar, bevor sie gelesen ist. Wer in der Zwischenzeit
|
|
etwas eintraegt, darf es nicht wieder verlieren."""
|
|
original = extract.extract_all
|
|
|
|
async def langsam(images):
|
|
await asyncio.sleep(1.0)
|
|
return await original(images)
|
|
|
|
monkeypatch.setattr(extract, "extract_all", langsam)
|
|
card_id = client.post(
|
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
|
|
client.patch(f"/api/cards/{card_id}", json={"full_name": "Von Hand", "notes": "Messe"})
|
|
|
|
monkeypatch.undo()
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
card = client.get(f"/api/cards/{card_id}").json()
|
|
assert card["full_name"] == "Von Hand"
|
|
assert card["notes"] == "Messe"
|
|
|
|
|
|
def test_anlass_wird_gespeichert_und_ist_durchsuchbar(client):
|
|
created = client.post(
|
|
"/api/scan",
|
|
files={"file": ("tisch.jpg", photo_bytes(2), "image/jpeg")},
|
|
data={"occasion": "Hannover Messe 2026"},
|
|
).json()
|
|
ids = [c["id"] for c in created["cards"]]
|
|
warte_bis_gelesen(client, ids)
|
|
|
|
assert all(c["occasion"] == "Hannover Messe 2026" for c in created["cards"])
|
|
|
|
gefunden = client.get("/api/cards", params={"q": "hannover messe"}).json()["cards"]
|
|
assert set(ids) <= {c["id"] for c in gefunden}
|
|
|
|
|
|
def test_anlass_ist_nachtraeglich_aenderbar(client):
|
|
card_id = client.post(
|
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
patched = client.patch(f"/api/cards/{card_id}", json={"occasion": "Kundentermin"})
|
|
|
|
assert patched.json()["occasion"] == "Kundentermin"
|
|
|
|
|
|
def test_dubletten_stehen_in_der_detailansicht(client):
|
|
erste = client.post(
|
|
"/api/scan", files={"file": ("a.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
zweite = client.post(
|
|
"/api/scan", files={"file": ("b.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [erste, zweite])
|
|
for card_id in (erste, zweite):
|
|
client.patch(f"/api/cards/{card_id}", json={"email": "doppelt@firma.de"})
|
|
|
|
detail = client.get(f"/api/cards/{zweite}").json()
|
|
|
|
assert erste in [d["id"] for d in detail["duplicates"]]
|
|
assert detail["duplicates"][0]["reason"] == "gleiche E-Mail"
|
|
|
|
|
|
def test_rueckseite_nachtragen(client):
|
|
card_id = client.post(
|
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
response = client.post(
|
|
f"/api/cards/{card_id}/back",
|
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["back_image_url"].endswith("side=back")
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
bild = client.get(f"/api/cards/{card_id}/image", params={"side": "back"})
|
|
assert bild.status_code == 200
|
|
assert bild.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_ohne_rueckseite_gibt_es_kein_rueckseitenbild(client):
|
|
card_id = client.post(
|
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [card_id])
|
|
|
|
assert client.get(
|
|
f"/api/cards/{card_id}/image", params={"side": "back"}
|
|
).status_code == 404
|
|
|
|
|
|
def test_loeschen_entfernt_auch_die_rueckseite(client):
|
|
from app import config
|
|
|
|
card_id = client.post(
|
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
|
).json()["cards"][0]["id"]
|
|
warte_bis_gelesen(client, [card_id])
|
|
client.post(
|
|
f"/api/cards/{card_id}/back",
|
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
|
)
|
|
warte_bis_gelesen(client, [card_id])
|
|
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
|
|
|
|
client.delete(f"/api/cards/{card_id}")
|
|
|
|
assert not (config.IMAGE_DIR / row["image_file"]).exists()
|
|
assert not (config.IMAGE_DIR / row["back_image_file"]).exists()
|