Owner notification webhook: Telegram re-point (#23)
Test backoffice (smb-crm) / test (push) Has been cancelled

Flask fires a fire-and-forget internal webhook (owner_notify.py, mirroring
mailer.py's background-thread queue) on booking create/cancel/reschedule,
carrying the same {booking, business_name, notify_channel} shape the old
EA-driven "Build booking row" node produced, plus an event field so the
Telegram message can say what actually happened. n8n/booking-sync.json gets
a new webhook + IF node feeding the existing Telegram node directly, so it
no longer needs EA's API to build the notification payload; channel routing
(only telegram sends for now) lives in that IF node.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 12:09:39 +02:00
parent 24d9aca812
commit a27ee59125
7 changed files with 336 additions and 6 deletions
+89
View File
@@ -102,6 +102,37 @@ def test_create_booking_via_public_endpoint_sends_confirmation_email(client, mon
assert "Haircut" in sent[0]["html"]
def test_create_booking_notifies_owner(client, monkeypatch):
"""#23's acceptance criterion: creating a booking via the public API
triggers the owner notification webhook, carrying the client's
notify_channel and business_name alongside the booking."""
notified = []
monkeypatch.setattr(
"owner_notify.notify",
lambda client, booking, event: notified.append((client, booking, event)))
resource, service = _setup_resource_and_service(
auto_confirm=True, min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slots = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slots[0],
"customer_name": "Nia", "customer_contact": "nia@example.com"})
assert resp.status_code == 201
booking_id = resp.get_json()["booking_id"]
assert len(notified) == 1
notified_client, notified_booking, notified_event = notified[0]
assert notified_client["client_id"] == CLIENT_A
assert notified_booking["booking_id"] == booking_id
assert notified_booking["customer_name"] == "Nia"
assert notified_event == "created"
def test_create_booking_auto_confirm_false_yields_pending(client):
resource, service = _setup_resource_and_service(
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
@@ -180,6 +211,64 @@ def test_cancel_with_valid_token_cancels_booking(client):
assert resp3.status_code == 400
def test_cancel_notifies_owner(client, monkeypatch):
notified = []
monkeypatch.setattr(
"owner_notify.notify",
lambda client, booking, event: notified.append((client, booking, event)))
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
created = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot,
"customer_name": "Omar", "customer_contact": "o@example.com"}).get_json()
notified.clear() # drop the create-time notification, isolate the cancel one
resp = client.post("/api/booking/cancel", json={"token": created["token"]})
assert resp.status_code == 200
assert len(notified) == 1
notified_client, notified_booking, notified_event = notified[0]
assert notified_client["client_id"] == CLIENT_A
assert notified_booking["booking_id"] == created["booking_id"]
assert notified_booking["status"] == "cancelled"
assert notified_event == "cancelled"
def test_reschedule_notifies_owner(client, monkeypatch):
notified = []
monkeypatch.setattr(
"owner_notify.notify",
lambda client, booking, event: notified.append((client, booking, event)))
resource, service = _setup_resource_and_service(
min_notice_minutes=0, max_advance_days=365)
day = _next_monday(date.today())
slot_list = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
created = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot_list[0],
"customer_name": "Priya", "customer_contact": "p@example.com"}).get_json()
notified.clear() # drop the create-time notification, isolate the reschedule one
resp = client.post("/api/booking/reschedule", json={
"token": created["token"], "start_time": slot_list[2]})
assert resp.status_code == 200
assert len(notified) == 1
notified_client, notified_booking, notified_event = notified[0]
assert notified_client["client_id"] == CLIENT_A
assert notified_booking["start_time"].isoformat() == slot_list[2]
assert notified_event == "rescheduled"
def test_cancel_with_expired_token_is_rejected(client):
import jwt as pyjwt
from booking_api import TOKEN_SECRET
+88
View File
@@ -0,0 +1,88 @@
"""Unit tests for owner_notify.py's webhook-call shape (#23). Calls
_send_now directly rather than going through the background-thread queue,
so assertions are synchronous -- the queue itself is just plumbing.
"""
from datetime import datetime, timezone
import pytest
import owner_notify
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def _booking():
return {
"booking_id": "BK-1",
"client_id": "C-1",
"customer_name": "Alice",
"customer_contact": "alice@example.com",
"service": "Haircut",
"start_time": datetime(2026, 8, 10, 9, 0, tzinfo=timezone.utc),
"end_time": datetime(2026, 8, 10, 10, 0, tzinfo=timezone.utc),
"source": "public",
"status": "confirmed",
}
def test_send_now_skips_when_webhook_url_not_configured(monkeypatch):
monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "")
calls = []
monkeypatch.setattr(owner_notify.urllib.request, "urlopen",
lambda *a, **kw: calls.append((a, kw)) or _FakeResponse())
owner_notify._send_now({"booking": _booking(), "business_name": "Happy Nails",
"notify_channel": "telegram"})
assert calls == []
def test_send_now_posts_json_when_configured(monkeypatch):
monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "https://n8n.example.com/webhook/owner-notify")
captured = []
def fake_urlopen(req, timeout=None):
captured.append(req)
return _FakeResponse()
monkeypatch.setattr(owner_notify.urllib.request, "urlopen", fake_urlopen)
payload = {"booking": owner_notify._serialize_booking(_booking()),
"business_name": "Happy Nails", "notify_channel": "telegram"}
owner_notify._send_now(payload)
assert len(captured) == 1
req = captured[0]
assert req.full_url == "https://n8n.example.com/webhook/owner-notify"
assert req.get_header("Content-type") == "application/json"
import json
body = json.loads(req.data)
assert body["booking"]["booking_id"] == "BK-1"
assert body["booking"]["start_time"] == "2026-08-10T09:00:00+00:00"
assert body["business_name"] == "Happy Nails"
assert body["notify_channel"] == "telegram"
def test_notify_enqueues_regardless_of_channel(monkeypatch):
"""Channel routing (only "telegram" sends anything real) lives on the
n8n side of this webhook, not in notify() -- an unset/other channel is
still queued, not silently dropped here (#23)."""
captured = []
monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p))
client = {"business_name": "Happy Nails", "notify_channel": "email"}
owner_notify.notify(client, _booking(), "created")
assert len(captured) == 1
assert captured[0]["notify_channel"] == "email"
assert captured[0]["business_name"] == "Happy Nails"
assert captured[0]["event"] == "created"
def test_notify_defaults_business_name_and_channel_when_client_missing(monkeypatch):
captured = []
monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p))
owner_notify.notify(None, _booking(), "cancelled")
assert captured[0]["business_name"] == ""
assert captured[0]["notify_channel"] is None
assert captured[0]["event"] == "cancelled"