Rate-limit public bookings per contact; add direct owner contact endpoint
Test backoffice (smb-crm) / test (push) Successful in 1m43s
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:
@@ -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"]
|
||||
Reference in New Issue
Block a user