Files
mivanchenko 456ca3872f
Test backoffice (smb-crm) / test (push) Successful in 1m46s
Add locations (Filialen) as a grouping layer above resources
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>
2026-09-12 03:02:56 +02:00

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