456ca3872f
Test backoffice (smb-crm) / test (push) Successful in 1m46s
Enables multiple barbers/staff bookable at the same location and time -- previously "resource" conflated "location" and "the thing that can't double-book itself" into one row, so a Filiale could only ever have exactly one bookable slot at once. - New `locations` table; `resources.location_id` with a generic, idempotent backfill migration (any resource without a location gets one auto-created matching its name -- not a one-off for any single client, protects any future resource stuck in the old flat shape too) - `resources`/`resource_hours`/services keep everything they already had (hours, min-notice, max-advance, buffer, the no-overlap constraint) scoped to resource_id, not location_id -- two barbers at one location must stay independently bookable at the same time - booking_db.py: new locations CRUD mirroring the existing resources/services pattern; create_resource now requires a location_id, guarded the same way every other tenant check here is (get_location existence check, no real FK -- matches this schema's existing no-FK convention throughout) - app.py: new POST /api/locations provisioning route; POST /api/resources now requires location_id - owner_settings.py + settings.html: new self-service "add a Filiale" / "add a barber" UI -- there was previously no way to create a resource at all outside the CRM/n8n provisioning API - public_booking.py + book.html: new Filiale picker (reuses the existing wireOptionGroup button-group pattern), filtering the Mitarbeiter picker to the selected location -- a single-location client sees no extra click, same as before Filialen existed - owner_booking.py + agenda.html: the Filiale show/hide toggle and hide-cancelled toggle (shipped earlier this session) now key off location_id instead of resource_id, so hiding a Filiale hides every barber's bookings at it; manual-booking dropdown grouped by Filiale - n8n/onboarding.json: default provisioning now creates a "Hauptfiliale" location before its resource (inert until re-imported into the live n8n instance) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
76 lines
2.2 KiB
Python
76 lines
2.2 KiB
Python
"""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 locations, resources, services, bookings, users, "
|
|
"password_reset_tokens RESTART IDENTITY CASCADE")
|
|
conn.commit()
|
|
yield
|