testing/v2-booking-conflict-scenarios.md

V2 Booking — Conflict / Overlap / Adjacency Test Scenarios

Mục tiêu: đặc tả đầy đủ mọi tình huống "khớp giờ / đè giờ / sát giờ" mà luồng booking V2 (stepper) phải xử lý đúng, gắn với (1) bộ automated e2e test làm regression net và (2) một kịch bản dev seed trên tenant studio-nordic để test thủ công trên UI.

Liên quan:


1. Quy tắc overlap đang test (source of truth)

Conflict được tính trong BookingService.checkConflict (booking-api/src/core/booking/booking.service.ts). Khoảng thời gian được coi là nửa mở [start, end):

conflict  ⇔  E.start < N.end  AND  E.end > N.start

trong đó E = booking đã tồn tại, N = booking mới. Suy ra:

Quan hệ thời gian Ví dụ (E = 10:00–11:00) Kết quả
Trùng khít N 10:00–11:00 ❌ CONFLICT
Đè một phần đầu N 09:30–10:30 ❌ CONFLICT
Đè một phần đuôi N 10:30–11:30 ❌ CONFLICT
N chứa E N 09:30–11:30 ❌ CONFLICT
E chứa N N 10:15–10:45 ❌ CONFLICT
Sát trước (N.end == E.start) N 09:00–10:00 ✅ OK
Sát sau (N.start == E.end) N 11:00–12:00 ✅ OK
Có khoảng hở N 09:00–09:30 ✅ OK
        09:00   10:00   11:00   12:00
          |       |       |       |
  E:              [███████]                 (10:00–11:00)
  sát trước [─────]                          ✅ 09:00–10:00  (chạm 10:00)
  sát sau           [─────]                  ✅ 11:00–12:00  (chạm 11:00)  ← biên của bug V2
  đè đầu       [────────]                    ❌
  đè đuôi             [────────]             ❌

Điểm mấu chốt — "sát giờ" (adjacency) KHÔNG phải conflict. Đây chính là biên mà bug chain V2 từng làm sai: một leg của chain bắt đầu đúng thời điểm một booking khác vừa kết thúc bị từ chối nhầm. Xem mục 6.

Các điều kiện phụ enforced cùng overlap

Điều kiện Hành vi
Per-resource Conflict chỉ tính trên cùng một resource. Hai staff khác nhau có thể trùng giờ tuyệt đối.
Item-level checkConflict quét cả booking.resourceId (parent) lẫn items.some.resourceId. Booking multi-service gán staff ở item vẫn chặn đúng.
Status loại trừ Booking CANCELLED / NO_SHOW không chặn slot. PENDING / CONFIRMED / IN_PROGRESS thì (giữ chỗ).
Unassigned Booking không có resource (parent null + item null) không bao giờ conflict — không có resource để đụng.
allowDoubleBooking=true Bỏ qua hoàn toàn overlap check (business hours + schedule vẫn enforce).
Chain (multi-leg) fillChainedStartTimes suy ra startTime từng leg tuần tự; mỗi leg được check overlap theo cửa sổ riêng của nó, KHÔNG theo cả envelope của booking.

2. Dữ liệu Studio Nordic (cơ sở của kịch bản)

slug = studio-nordic · timezone Europe/Oslo (mùa hè = UTC+2) · industryType = beauty. Settings then chốt cho test: allowDoubleBooking = false (conflict đang bật), bookingMode = allow_unassigned, autoConfirm = true.

Giờ mở cửa salon

Thứ Giờ
Mon, Tue, Thu, Fri 09:00–17:00
Wed 09:00–19:00
Sat 10:00–16:00
Sun Đóng cửa

Nhân viên (staff) — lịch làm + kỹ năng chính

Staff Lịch làm Kỹ năng (trích)
Anna Nguyen Mon–Fri 09:00–17:00 Haircut, Color, Updo, Extensions
Linh Tran Mon–Fri 09:00–17:00 Haircut, Color, Treatments (Keratin, Olaplex)
Erik Johansen Mon–Sat 09:00–17:00 Haircut, Updo, Beard/Shave
Sofia Larsen Tue–Sat 10:00–18:00 Color, Treatments, Extensions

Lưu ý kỹ năng: dropdown staff ở V2 strict skill match — chỉ staff có skill cho service đó mới hiện. Vd Beard Trim chỉ Erik làm; Keratin chỉ Linh/Sofia.

Service dùng trong kịch bản (duration)

Service Duration Staff làm được
Cut & Style 60' Anna, Linh, Erik, …
Men's Haircut 30' Anna, Linh, Erik
Full Color 120' Anna, Linh, Sofia
Keratin Treatment 150' Linh, Sofia
Olaplex Treatment 45' Linh, Sofia
Beard Trim 20' Erik

3. Automated test suite (regression net)

File: booking-api/test/public-booking-conflict.e2e-spec.ts — chạy thật qua endpoint POST /api/public/tenants/:slug/bookings + GET …/availability, tạo tenant riêng (e2e-conflict-<ts>) và tự dọn. 22 case, tất cả pass.

cd booking-api
yarn test:e2e --testPathPatterns public-booking-conflict

Suite tự disable throttler (route createBooking giới hạn 5 req/60s) bằng cách override ThrottlerStorage — vì conflict logic độc lập với rate-limit. Mọi booking nằm trong 07:00Z–15:00Z (= 09:00–17:00 Oslo) để business-hours không nhiễu assertion. Mỗi case bắt đầu từ slate sạch (afterEach xoá booking).

Ma trận case (E = resource r1 bận 10:00–11:00 Oslo, trừ nhóm chain):

Nhóm Case Expected
Overlap → 409 trùng khít · same-start ngắn hơn · đè đầu · đè đuôi · N chứa E · N bắt đầu trong E 409 BOOKING_CONFLICT
No overlap → 201 sát trước (09:00–10:00) · sát sau (11:00–12:00) · hở trước · hở sau 201 CONFIRMED
Per-resource cùng giờ khác resource · unassigned đè E 201
Status E CANCELLED không chặn · E NO_SHOW không chặn · E PENDING chặn 201 / 201 / 409
Item-level E gán staff chỉ ở item level → vẫn chặn 409
allowDoubleBooking bật true → đè khít vẫn nhận 201
Chain (multi-leg) r1 bận 07:00–08:30 (đè envelope nhưng KHÔNG đè leg3 thật) → nhận · r1 bận 10:00–11:00 (đè leg3 thật) → chặn · r2 bận 09:00–10:00 (đè leg2) → chặn · persist đúng startTime từng leg + endTime = tổng chain 201 / 409 / 409 / 201
Availability ↔ create slot đè bị ẩn nhưng slot sát giờ vẫn được offer + create nhận slot đó nhất quán

4. Kịch bản dev seed (test thủ công trên V2 UI)

Seed deterministic vào studio-nordic cho hôm nay (giờ Oslo):

cd booking-api
yarn seed:conflict

Script: xoá toàn bộ booking hôm nay của studio-nordic → tạo lại 8 booking dưới đây. Vì tạo ≥8 booking, lần prestart:dev (yarn seed:daily) sau sẽ skip → kịch bản sống qua các lần restart dev cho tới khi seed lại.

8 booking được tạo (giờ Oslo, hôm nay)

# Staff Service Giờ Status Dùng để test
1 Anna Cut & Style 60' 10:00–11:00 CONFIRMED adjacency (09:00 sát trước / 11:00 sát sau)
2 Linh Full Color 120' 09:00–11:00 CONFIRMED nửa đầu "sandwich"
3 Linh Keratin 150' 12:00–14:30 CONFIRMED nửa sau → khe đúng 60' (11:00–12:00)
4 Erik Men's Haircut 30' 10:00–10:30 CANCELLED cancelled không chặn slot
5 Erik Beard Trim 20' 13:00–13:20 CONFIRMED block thường
6 Sofia Full Color + Olaplex 11:00–13:45 CONFIRMED chain multi-service chiếm chỗ
7 (unassigned) Cut & Style 60' 14:00–15:00 PENDING unassigned không chặn staff
8 Erik Cut & Style 60' 15:00–16:00 PENDING PENDING (held) chặn slot

⚠️ Lưu ý quan trọng khi test trên UI

Slot grid ẩn các giờ đã trôi qua trong ngày hôm nay (không thể đặt quá khứ). Vì vậy các probe buổi sáng chỉ quan sát được nếu test vào buổi sáng. Để kiểm tra một probe đã qua giờ, có thể seed lại vào sáng hôm sau, hoặc dựa vào automated suite (đã chứng minh logic độc lập thời điểm).

Các probe thủ công + kết quả mong đợi

Mở /(customer)/b/studio-nordic/book (tenant đang để bookingUiVersion='v2'), chọn service + staff tương ứng, xem slot grid ngày hôm nay.

P1 — Adjacency (Anna · Cut & Style 60'):

  • 09:00 (09:00–10:00, sát trước block 10:00) → được offer.
  • 10:00 → ẩn (đè block).
  • 11:00 (11:00–12:00, sát sau block) → được offer. ← biên bug V2, phải có.

P2 — Khe khít đúng size (Linh · service 60' vs 90'):

  • Linh bận 09:00–11:00 và 12:00–14:30 → khe hở đúng 60' ở 11:00–12:00.
  • Với Cut & Style 60': slot 11:00 được offer (vừa khít, sát cả 2 đầu).
  • Với một service 90': không slot nào trong khe (không vừa) → chỉ còn sau 14:30.

P3 — Cancelled giải phóng slot (Erik · Cut & Style hoặc Men's Haircut):

  • Erik có booking 10:00–10:30 nhưng đã CANCELLED → slot 10:00 vẫn được offer.

P4 — PENDING giữ chỗ + adjacency quanh nó (Erik · Cut & Style 60'):

  • Erik PENDING 15:00–16:00 → 15:00 ẩn (held).
  • 14:00 được offer (14:00–15:00, sát trước hold) nhưng 14:15 ẩn (đè hold).
  • 16:00 được offer (sát sau hold).
  • Đã verify live (Fri 12:04 Oslo): Erik Cut & Style → 13:30 13:45 14:00 16:00.

P5 — Chain multi-service chiếm chỗ (Sofia):

  • Sofia bận 11:00–13:45 (Full Color 120' + Olaplex 45'). Mọi service đè 11:00–13:45 với Sofia → ẩn; slot sau 13:45 → offer.

P6 — Unassigned không chặn staff:

  • Booking #7 (unassigned 14:00–15:00) không làm mất slot 14:00 của bất kỳ staff cụ thể nào (vd Anna vẫn đặt được 14:00 nếu rảnh).

P7 — allowDoubleBooking (tuỳ chọn):

  • Bật allowDoubleBooking=true trên studio-nordic → slot grid hiện đầy đủ mọi giờ (kể cả đang bận); submit đè khít vẫn 201. Nhớ tắt lại sau khi test.

5. Kiểm tra nhanh bằng query (không cần UI)

Slot offer cho 1 staff (giờ Oslo), dùng API:

curl -s "http://localhost:3010/api/public/tenants/studio-nordic/availability?serviceId=<SVC>&date=<YYYY-MM-DD>&resourceId=<STAFF>"

Liệt kê booking hôm nay (giờ Oslo) — lưu ý cột start_timetimestamp lưu UTC, phải convert 2 lần:

SELECT r.name, to_char((b.start_time AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Oslo','HH24:MI'),
       to_char((b.end_time   AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Oslo','HH24:MI'),
       b.status, b.customer_name
FROM bookings b JOIN tenants t ON t.id = b.tenant_id
LEFT JOIN resources r ON r.id = b.resource_id
WHERE t.slug = 'studio-nordic'
  AND (b.start_time AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Oslo')::date
      = (now() AT TIME ZONE 'Europe/Oslo')::date
ORDER BY r.name NULLS LAST, b.start_time;

6. Bug chain V2 (đã fix)

Lịch sử: chain V2 từng trả BOOKING_CONFLICT sai vì mọi leg bị check theo envelope của cả booking thay vì cửa sổ thật từng leg. fillChainedStartTimes (+ calculateEndTime) đã sửa: leg cuối gán cho staff X được check đúng cửa sổ của nó, không phải toàn envelope.

Case regression nằm trong suite (chain › r1 busy 07:00–08:30 … → 201) pass, xác nhận code đã đúng. Triệu chứng còn sót trên dev DB trước đây là do data legacy (booking tạo bằng code song song cũ: item.startTime = NULL, parent endTime = max thay vì tổng) — yarn seed:conflict tạo data sạch nên không còn dính.