Rate-limit public bookings per contact; add direct owner contact endpoint
Test backoffice (smb-crm) / test (push) Successful in 1m43s

Public booking API now rejects a 6th active booking from the same
customer_contact within 24h (429), stopping one contact from filling
every slot on every resource, while owner-entered manual bookings stay
unaffected.

Add POST /api/contact: client sites can reach their own owner's inbox
directly (via their existing login email) for general inquiries,
separate from the agency's leads/Telegram pipeline (n8n/lead-intake.json),
which stays reserved for actual prospects contacting the agency itself.
Paris Barber Shop's contact form and Rückruf widget now point here; the
Rückruf floating widget itself has been removed from the site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 04:47:06 +02:00
parent bcb2672d9f
commit c3e520aabb
9 changed files with 299 additions and 108 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Flask test client / real-DB integration tests for the public "contact the
owner" API (contact_api.py) -- the customer-to-owner path, deliberately
separate from the agency's leads/Telegram pipeline. See contact_mail.py.
"""
import pytest
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-CONTACT-A"
@pytest.fixture
def client():
flask_app.config["TESTING"] = True
return flask_app.test_client()
def _insert_booking_client(client_id, business_name="Café Test"):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, business_name) VALUES (%s, %s) "
"ON CONFLICT (client_id) DO NOTHING",
(client_id, business_name))
conn.commit()
def test_contact_request_emails_the_owner(client, monkeypatch):
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(
{"to": to, "subject": subject, "html": html}))
_insert_booking_client(CLIENT_A)
bdb.create_user(CLIENT_A, "inhaber@contact-test.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": CLIENT_A, "name": "Jamie", "contact": "jamie@example.com",
"service_interest": "Herrenschnitt", "message": "Gibt es heute noch einen Termin?"})
assert resp.status_code == 201
assert resp.get_json() == {"sent": True}
assert resp.headers["Access-Control-Allow-Origin"] == "*"
assert len(sent) == 1
assert sent[0]["to"] == "inhaber@contact-test.example"
assert "Jamie" in sent[0]["html"]
assert "jamie@example.com" in sent[0]["html"]
assert "Herrenschnitt" in sent[0]["html"]
def test_contact_request_falls_back_to_service_field(client, monkeypatch):
"""The Rückruf widget sends service_interest; the plain contact form
sends the same concept under the field name "service" -- both work."""
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(html))
client_id = "C-TEST-CONTACT-SVCFALLBACK"
_insert_booking_client(client_id)
bdb.create_user(client_id, "inhaber@svcfallback.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Robin", "contact": "robin@example.com",
"service": "Bartpflege"})
assert resp.status_code == 201
assert "Bartpflege" in sent[0]
def test_honeypot_fakes_success_without_sending(client, monkeypatch):
sent = []
monkeypatch.setattr(
"contact_mail.mailer.send_email",
lambda to, subject, html, from_addr=None: sent.append(to))
client_id = "C-TEST-CONTACT-HONEYPOT"
_insert_booking_client(client_id)
bdb.create_user(client_id, "inhaber@honeypot.example", "irrelevant-pw")
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Bot", "contact": "bot@example.com",
"website": "http://spam.example"})
assert resp.status_code == 201
assert resp.get_json() == {"sent": True}
assert sent == []
def test_missing_required_fields_is_rejected(client):
resp = client.post("/api/contact", json={"client_id": CLIENT_A, "name": "NoContact"})
assert resp.status_code == 400
def test_unknown_client_is_rejected(client):
resp = client.post("/api/contact", json={
"client_id": "C-DOES-NOT-EXIST", "name": "Alex", "contact": "a@example.com"})
assert resp.status_code == 404
def test_client_without_owner_account_is_rejected(client):
"""A client that exists in the CRM but hasn't been provisioned into the
booking schema yet (no owner account) -- nothing to notify, so this is a
404 rather than a silent success the visitor would wrongly trust."""
client_id = "C-TEST-CONTACT-NOOWNER"
_insert_booking_client(client_id)
resp = client.post("/api/contact", json={
"client_id": client_id, "name": "Alex", "contact": "a@example.com"})
assert resp.status_code == 404
def test_preflight_options_returns_cors_headers(client):
resp = client.options("/api/contact")
assert resp.status_code < 300
assert resp.headers["Access-Control-Allow-Origin"] == "*"
assert "POST" in resp.headers["Access-Control-Allow-Methods"]