Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest und als Kontakt bereitstellt. - Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken, entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt das ganze Foto als eine Karte. - Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht. - Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname über Nominatim, Browserstandort nur als Rückfallebene. - Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske. - Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster Termine nach einem Neustart. - vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert. - Anmeldung über ein Passwort, Sitzung als signiertes Cookie. - Deployment per Tag-Push nach den Konventionen in DEPLOY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
183
tests/test_api.py
Normal file
183
tests/test_api.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""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.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db, mailer, reminders
|
||||
from app.main import app
|
||||
from tests.helpers import photo_bytes
|
||||
|
||||
|
||||
@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
|
||||
return response.json()
|
||||
|
||||
|
||||
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_sammelexport_mehrerer_karten(client, scanned):
|
||||
ids = ",".join(card["id"] for card in scanned["cards"][:3])
|
||||
|
||||
response = client.get("/vcf", params={"ids": ids})
|
||||
|
||||
assert response.text.count("BEGIN:VCARD") == 3
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user