Booking schema + tenancy-safe data-access module (#15)
Adds the plumbing every other booking ticket builds on: resources, services, users, and password_reset_tokens tables, plus the clients columns (slug, timezone, auto_confirm, ics_token) and the bookings resource_id/EXCLUDE-constraint double-booking protection described in #14. booking_db.py is the only place raw SQL runs against these tables -- every function takes client_id and injects the tenant filter itself, and create/update_booking additionally verify the resource_id belongs to that client before writing, closing a guessed-ID cross-tenant hole. Tests spin up a real throwaway Postgres 16 container (matching prod) and exercise the EXCLUDE constraint, tenancy isolation, and password-reset single-use semantics end to end, per #14's "real Postgres, no mocking" testing decision. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Spins up a throwaway Postgres 16 container (matching production) and
|
||||
applies backoffice/db/init.sql against it, per the module's testing decision
|
||||
(#14): real Postgres, no mocking, so the EXCLUDE constraint and tenancy
|
||||
filters are exercised for real, not asserted by inspection.
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
_CONTAINER = f"smb-booking-test-db-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _free_port():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_ready(url, timeout=30):
|
||||
deadline = time.time() + timeout
|
||||
last_err = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with psycopg.connect(url, connect_timeout=2):
|
||||
return
|
||||
except psycopg.OperationalError as e:
|
||||
last_err = e
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"test db never became ready: {last_err}")
|
||||
|
||||
|
||||
def _start_db():
|
||||
port = _free_port()
|
||||
subprocess.run(
|
||||
["docker", "run", "-d", "--rm", "--name", _CONTAINER,
|
||||
"-e", "POSTGRES_DB=smbcrm_test",
|
||||
"-e", "POSTGRES_USER=smbcrm",
|
||||
"-e", "POSTGRES_PASSWORD=test",
|
||||
"-p", f"127.0.0.1:{port}:5432",
|
||||
"postgres:16-alpine"],
|
||||
check=True, capture_output=True)
|
||||
atexit.register(
|
||||
lambda: subprocess.run(["docker", "stop", _CONTAINER], capture_output=True))
|
||||
url = f"postgresql://smbcrm:test@127.0.0.1:{port}/smbcrm_test"
|
||||
_wait_ready(url)
|
||||
schema_path = os.path.join(os.path.dirname(__file__), "..", "..", "db", "init.sql")
|
||||
with open(schema_path) as f:
|
||||
schema = f.read()
|
||||
with psycopg.connect(url) as conn, conn.cursor() as cur:
|
||||
cur.execute(schema)
|
||||
conn.commit()
|
||||
return url
|
||||
|
||||
|
||||
DATABASE_URL = _start_db()
|
||||
os.environ["DATABASE_URL"] = DATABASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tables():
|
||||
with psycopg.connect(DATABASE_URL) as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"TRUNCATE resources, services, bookings, users, "
|
||||
"password_reset_tokens RESTART IDENTITY CASCADE")
|
||||
conn.commit()
|
||||
yield
|
||||
@@ -0,0 +1,202 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
import booking_db as bdb
|
||||
|
||||
CLIENT_A = "C-TEST-A"
|
||||
CLIENT_B = "C-TEST-B"
|
||||
|
||||
|
||||
def _dt(hour, minute=0):
|
||||
return datetime(2026, 8, 3, hour, minute, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ---- resources / services ----
|
||||
|
||||
def test_create_and_get_resource():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
assert r["client_id"] == CLIENT_A
|
||||
assert r["name"] == "Chair 1"
|
||||
assert r["active"] is True
|
||||
assert bdb.get_resource(CLIENT_A, r["resource_id"])["resource_id"] == r["resource_id"]
|
||||
|
||||
|
||||
def test_get_resource_is_tenant_scoped():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
assert bdb.get_resource(CLIENT_B, r["resource_id"]) is None
|
||||
|
||||
|
||||
def test_create_and_get_service():
|
||||
s = bdb.create_service(CLIENT_A, "Haircut", 30, price=25)
|
||||
assert s["duration_minutes"] == 30
|
||||
assert bdb.get_service(CLIENT_A, s["service_id"])["name"] == "Haircut"
|
||||
|
||||
|
||||
def test_get_service_is_tenant_scoped():
|
||||
s = bdb.create_service(CLIENT_A, "Haircut", 30, price=25)
|
||||
assert bdb.get_service(CLIENT_B, s["service_id"]) is None
|
||||
|
||||
|
||||
# ---- bookings: double-booking protection ----
|
||||
|
||||
def test_create_booking_succeeds():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "alice@example.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
assert b["status"] == "confirmed"
|
||||
assert b["resource_id"] == r["resource_id"]
|
||||
|
||||
|
||||
def test_overlapping_booking_same_resource_raises_conflict():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
with pytest.raises(bdb.BookingConflict):
|
||||
bdb.create_booking(CLIENT_A, r["resource_id"], "Bob", "b@x.com",
|
||||
"Haircut", _dt(10, 30), _dt(11, 30))
|
||||
|
||||
|
||||
def test_adjacent_non_overlapping_bookings_both_succeed():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
b2 = bdb.create_booking(CLIENT_A, r["resource_id"], "Bob", "b@x.com",
|
||||
"Haircut", _dt(11), _dt(12))
|
||||
assert b2["start_time"] == _dt(11)
|
||||
|
||||
|
||||
def test_create_booking_rejects_resource_from_another_client():
|
||||
other = bdb.create_resource(CLIENT_B, "Chair 1")
|
||||
with pytest.raises(bdb.UnknownResource):
|
||||
bdb.create_booking(CLIENT_A, other["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
|
||||
|
||||
def test_update_booking_rejects_moving_to_another_clients_resource():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
other = bdb.create_resource(CLIENT_B, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
with pytest.raises(bdb.UnknownResource):
|
||||
bdb.update_booking(CLIENT_A, b["booking_id"], resource_id=other["resource_id"])
|
||||
|
||||
|
||||
def test_create_pending_booking():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11), status="pending")
|
||||
assert bdb.get_booking(CLIENT_A, b["booking_id"])["status"] == "pending"
|
||||
|
||||
|
||||
def test_overlap_on_different_resource_succeeds():
|
||||
r1 = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
r2 = bdb.create_resource(CLIENT_A, "Chair 2")
|
||||
bdb.create_booking(CLIENT_A, r1["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
b2 = bdb.create_booking(CLIENT_A, r2["resource_id"], "Bob", "b@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
assert b2["resource_id"] == r2["resource_id"]
|
||||
|
||||
|
||||
def test_reschedule_into_conflict_raises_and_leaves_original_untouched():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
b2 = bdb.create_booking(CLIENT_A, r["resource_id"], "Bob", "b@x.com",
|
||||
"Haircut", _dt(12), _dt(13))
|
||||
with pytest.raises(bdb.BookingConflict):
|
||||
bdb.update_booking(CLIENT_A, b2["booking_id"], start_time=_dt(10, 30),
|
||||
end_time=_dt(11, 30))
|
||||
unchanged = bdb.get_booking(CLIENT_A, b2["booking_id"])
|
||||
assert unchanged["start_time"] == _dt(12)
|
||||
|
||||
|
||||
def test_reschedule_to_free_slot_succeeds():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
updated = bdb.update_booking(CLIENT_A, b["booking_id"], start_time=_dt(14),
|
||||
end_time=_dt(15))
|
||||
assert updated["start_time"] == _dt(14)
|
||||
|
||||
|
||||
def test_update_booking_rejects_non_updatable_field():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
with pytest.raises(ValueError):
|
||||
bdb.update_booking(CLIENT_A, b["booking_id"], created_at=_dt(9))
|
||||
|
||||
|
||||
# ---- bookings: tenancy isolation ----
|
||||
|
||||
def test_get_booking_is_tenant_scoped_even_with_correct_id():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
assert bdb.get_booking(CLIENT_B, b["booking_id"]) is None
|
||||
|
||||
|
||||
def test_update_booking_cannot_touch_other_clients_booking():
|
||||
r = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
b = bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
result = bdb.update_booking(CLIENT_B, b["booking_id"], status="cancelled")
|
||||
assert result is None
|
||||
assert bdb.get_booking(CLIENT_A, b["booking_id"])["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_list_bookings_only_returns_own_client():
|
||||
ra = bdb.create_resource(CLIENT_A, "Chair 1")
|
||||
rb = bdb.create_resource(CLIENT_B, "Chair 1")
|
||||
bdb.create_booking(CLIENT_A, ra["resource_id"], "Alice", "a@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
bdb.create_booking(CLIENT_B, rb["resource_id"], "Zoe", "z@x.com",
|
||||
"Haircut", _dt(10), _dt(11))
|
||||
rows = bdb.list_bookings(CLIENT_A)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["customer_name"] == "Alice"
|
||||
|
||||
|
||||
# ---- users / owner login ----
|
||||
|
||||
def test_create_user_and_verify_password():
|
||||
u = bdb.create_user(CLIENT_A, "owner@example.com", "correct horse")
|
||||
assert bdb.verify_password(u, "correct horse")
|
||||
assert not bdb.verify_password(u, "wrong password")
|
||||
|
||||
|
||||
def test_get_user_by_email_is_tenant_scoped():
|
||||
bdb.create_user(CLIENT_A, "owner@example.com", "pw12345")
|
||||
assert bdb.get_user_by_email(CLIENT_B, "owner@example.com") is None
|
||||
assert bdb.get_user_by_email(CLIENT_A, "owner@example.com") is not None
|
||||
|
||||
|
||||
# ---- password reset: single-use semantics ----
|
||||
|
||||
def test_consume_password_reset_token_sets_new_password():
|
||||
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
|
||||
tok = bdb.create_password_reset_token(u["user_id"])
|
||||
user_id = bdb.consume_password_reset_token(tok["token"], "new-password")
|
||||
assert user_id == u["user_id"]
|
||||
refreshed = bdb.get_user_by_email(CLIENT_A, "owner@example.com")
|
||||
assert bdb.verify_password(refreshed, "new-password")
|
||||
assert not bdb.verify_password(refreshed, "old-password")
|
||||
|
||||
|
||||
def test_consume_password_reset_token_is_single_use():
|
||||
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
|
||||
tok = bdb.create_password_reset_token(u["user_id"])
|
||||
assert bdb.consume_password_reset_token(tok["token"], "new-password") == u["user_id"]
|
||||
assert bdb.consume_password_reset_token(tok["token"], "another-password") is None
|
||||
|
||||
|
||||
def test_consume_expired_password_reset_token_fails():
|
||||
u = bdb.create_user(CLIENT_A, "owner@example.com", "old-password")
|
||||
tok = bdb.create_password_reset_token(u["user_id"], ttl_minutes=-1)
|
||||
assert bdb.consume_password_reset_token(tok["token"], "new-password") is None
|
||||
|
||||
|
||||
def test_consume_unknown_token_fails():
|
||||
assert bdb.consume_password_reset_token("not-a-real-token", "new-password") is None
|
||||
Reference in New Issue
Block a user