feat: edit request time blockers, today closing time, UI polish, and test fixes
- Add time blocker management for booking edit requests - Add closing_time field to admin today/current-next endpoint - Update UserBookingModal and CurrentAppointment UI components - Fix fmt import in bookings_test.go (was missing) - Fix created_by FK in TestAdminApproveEditRequest_TimeBlockerOverlap - Update test coverage for edit request time blocker overlap - Update gap backlog documentation
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# Test DB Reset Optimization — Deep Analysis & Updated Plan
|
||||
|
||||
## Current State: The Problem
|
||||
|
||||
Every test (288 total) does a **full schema DROP + CREATE + TRUNCATE**:
|
||||
|
||||
```
|
||||
setupTestDB(t)
|
||||
├── testdb.Pool(t) → New pgxpool connection (expensive)
|
||||
├── testdb.Migrate(t, pool) → Full schema DROP + CREATE
|
||||
│ ├── Check typeCount → ALWAYS > 0 (shared crussell_test DB)
|
||||
│ ├── DROP 14 types CASCADE
|
||||
│ ├── DROP 24 tables CASCADE
|
||||
│ ├── DROP 4 sequences
|
||||
│ └── Run init-script.sql → CREATE all tables, types, indexes, stored procs
|
||||
├── testdb.TruncateTables() → TRUNCATE 21 tables CASCADE
|
||||
├── db.DB = pool → Replace global
|
||||
├── jwt.Init() → Re-init JWT
|
||||
└── defer: pool.Close() → Close connection
|
||||
```
|
||||
|
||||
**227 tests** call `setupTestDB` directly. The remaining **61 tests** use custom setup functions (`setupTest`, `setupReserveTestDB`, `setupTimeBlockersTestDB`) that do the exact same thing — Pool + Migrate + Truncate.
|
||||
|
||||
**All 288 tests do full schema destruction and rebuild.**
|
||||
|
||||
---
|
||||
|
||||
## Deep Analysis: Cross-Test Safety Verification
|
||||
|
||||
### ✅ VERIFIED SAFE: No Schema Creation by Tests
|
||||
|
||||
Grep for `CREATE TABLE|DROP TABLE|ALTER TABLE|CREATE TYPE|DROP TYPE|CREATE INDEX|CREATE FUNCTION` in all `_test.go` files: **zero matches**. No test creates, alters, or drops any schema objects. All tests only INSERT/UPDATE/DELETE row data.
|
||||
|
||||
### ✅ VERIFIED SAFE: No Cross-Test Dependencies
|
||||
|
||||
Every test is self-contained. Each creates its own data via fixtures or direct SQL, and either:
|
||||
- Uses `defer fixtures.Delete...` for cleanup, OR
|
||||
- Relies on `TruncateTables` to clean up before the next test
|
||||
|
||||
No test reads data created by a previous test. No test depends on sequential ordering.
|
||||
|
||||
### ✅ VERIFIED SAFE: Empty-State Tests
|
||||
|
||||
5 tests explicitly check for empty results:
|
||||
- `TestPortfolio_ListImages_Empty` — expects 0 images
|
||||
- `TestPortfolio_ListTags_Empty` — expects 0 tags
|
||||
- `TestPortfolio_ListFilters_Empty` — expects 0 filters
|
||||
- `TestNotifications_ListEmpty` — expects 0 notifications
|
||||
- `TestCustomerRelationship_NoBookings` — expects user with no bookings
|
||||
|
||||
All are safe with TRUNCATE — truncation produces the empty state these tests expect.
|
||||
|
||||
### ✅ VERIFIED SAFE: `seedDefaultWorkingHours`
|
||||
|
||||
Used by 38 tests across 4 files. Uses `ON CONFLICT (weekday) DO UPDATE` — fully idempotent. Safe to call multiple times.
|
||||
|
||||
### ⚠️ FINDING: `TruncateTables` is missing 3 tables
|
||||
|
||||
The truncate list has 21 tables, but the schema has 24. Missing:
|
||||
- `booking_edit_requests` — used by 11 tests in bookings/admin
|
||||
- `exceptional_group_applications` — used by 3 tests in bookings/scheduling
|
||||
- `business_settings` — 1 row, never modified by tests
|
||||
|
||||
**Why this hasn't broken things**: CASCADE foreign keys handle cleanup:
|
||||
- `booking_edit_requests.booking_id → bookings.id` (CASCADE) → cleaned when `bookings` truncated
|
||||
- `exceptional_group_applications.group_id → exceptional_working_hours_groups.id` (CASCADE) → cleaned when groups truncated
|
||||
- `business_settings` — static seed data, never modified
|
||||
|
||||
**Action needed**: Add these 3 tables to `TruncateTables` for correctness. Currently relying on implicit CASCADE behavior.
|
||||
|
||||
### ⚠️ FINDING: `handlers/handlers_test.go` has no setup wrapper
|
||||
|
||||
`TestIntegration_UserFlow` uses `testdb.Pool(t)` directly (no Migrate, no Truncate) with `defer fixtures.DeleteUser` for cleanup. The other 3 tests in this file don't use the DB at all. This package needs a TestMain that runs Migrate once.
|
||||
|
||||
### ⚠️ FINDING: `discount_test.go` has dead code
|
||||
|
||||
`truncateDiscountTables()` helper is defined but never called. The file uses `setupTestDB(t)` which already calls the full `TruncateTables`. Safe to delete.
|
||||
|
||||
### ✅ VERIFIED SAFE: Global state
|
||||
|
||||
- `auth.TokenAuth` — set by `jwt.Init()`, called per-test currently, will be once-per-package in TestMain. No test modifies it.
|
||||
- `loginInProgress` / `loginAttempts` — package-level maps in `local.go`. Handler cleans up via defer/delete. No test modifies them directly.
|
||||
- `dav.Service` — set to `&dav.BaseService{}` in auth_test.go's setup. Persists across tests but is a read-only mock. Safe.
|
||||
- `fixtures.testEmailCounter` — increments for unique emails. Monotonically increasing, never resets. Safe (designed for this).
|
||||
|
||||
---
|
||||
|
||||
## Updated Plan
|
||||
|
||||
### Phase 1: Add TestMain to Each Package + Fix TruncateTables
|
||||
|
||||
**Step 1A: Fix `TruncateTables` to include all 24 tables**
|
||||
|
||||
Add to `testutils/testdb/testdb.go`:
|
||||
```go
|
||||
tables := []string{
|
||||
// ... existing 21 tables ...
|
||||
"booking_edit_requests", // NEW
|
||||
"exceptional_group_applications", // NEW
|
||||
"business_settings", // NEW
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1B: Add TestMain to each package**
|
||||
|
||||
Each package gets ONE `TestMain` (not per file — per package). In Go, if multiple files in the same package define `TestMain`, it's a compile error. So we need ONE file per package with TestMain.
|
||||
|
||||
| Package | TestMain goes in | Tests covered |
|
||||
|---------|-----------------|---------------|
|
||||
| `handlers/bookings` | New file `bookings_testmain_test.go` | 98 (4 files) |
|
||||
| `handlers/admin` | New file `admin_testmain_test.go` | 65 (4 files) |
|
||||
| `handlers/auth` | `auth_test.go` (add to existing) | 25 |
|
||||
| `handlers/scheduling` | New file `scheduling_testmain_test.go` | 34 (2 files) |
|
||||
| `handlers/portfolio` | `images_test.go` (add to existing) | 16 |
|
||||
| `handlers/notifications` | `notifications_test.go` (add to existing) | 12 |
|
||||
| `handlers/services` | `services_test.go` (add to existing) | 5 |
|
||||
| `handlers/user` | New file `user_testmain_test.go` | 21 (3 files) |
|
||||
| `handlers` | `handlers_test.go` (add to existing) | 4 |
|
||||
| `main` | `main_test.go` (add to existing) | 2 |
|
||||
|
||||
**TestMain template** (varies slightly per package):
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
pool := testdb.NewPool("")
|
||||
testdb.Migrate(&testing.T{}, pool)
|
||||
db.DB = pool
|
||||
jwt.Init()
|
||||
// auth_test.go also needs: dav.Service = &dav.BaseService{}
|
||||
code := m.Run()
|
||||
pool.Close()
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1C: Convert `setupTestDB(t)` to `resetTestData(t)`**
|
||||
|
||||
Each package's setup function becomes:
|
||||
```go
|
||||
func resetTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
// For packages that need working hours:
|
||||
// seedDefaultWorkingHours(t)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 1D: Remove redundant per-test operations**
|
||||
|
||||
- Remove `db.DB = pool` swap (pool is now global)
|
||||
- Remove `jwt.Init()` from per-test setup (done once in TestMain)
|
||||
- Remove `pool.Close()` from defer (pool is shared, closed in TestMain)
|
||||
- Remove `testdb.Pool(t)` calls (use global `db.DB`)
|
||||
- Remove `testdb.Migrate(t, pool)` calls (done once in TestMain)
|
||||
|
||||
### Phase 2: Clean Up
|
||||
|
||||
- Delete dead `truncateDiscountTables()` in discount_test.go
|
||||
- Consolidate `seedDefaultWorkingHours` into one shared function (currently duplicated in 4 files with slight variations)
|
||||
- Add `dav.Service` mock to TestMain for auth package only
|
||||
|
||||
### Phase 3: Smart Truncate (Optional, Later)
|
||||
|
||||
Identify which tables each test actually touches and truncate only those. Low priority — Phase 1 gives 95% of the benefit.
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment (Updated)
|
||||
|
||||
| Risk | Likelihood | Severity | Mitigation |
|
||||
|------|-----------|----------|------------|
|
||||
| Test pollution (data leaking) | Low | High | `TRUNCATE CASCADE` is reliable; verify with `-count=2` |
|
||||
| Missing tables in TruncateTables | **Confirmed** | Medium | **Fix in Phase 1A** — add 3 missing tables |
|
||||
| `TestMain` compile conflict | Low | High | ONE TestMain per package, not per file |
|
||||
| `db.DB` global race | Low | High | Tests run sequentially (`-p 1`) |
|
||||
| `jwt.Init()` called once vs per-test | Low | Medium | JWT state is idempotent; no test modifies TokenAuth |
|
||||
| `dav.Service` mock persistence | Low | Low | Only auth tests use it; mock is stateless |
|
||||
|
||||
### Verification Strategy:
|
||||
1. Run `go test -tags test -v -p 1 -count=1 ./...` — record baseline count/timing
|
||||
2. Apply Phase 1A (fix TruncateTables)
|
||||
3. Run tests — should still pass (no logic change)
|
||||
4. Apply Phase 1B-1D (TestMain + refactor)
|
||||
5. Run `go test -tags test -v -p 1 -count=2 ./...` — double-run catches state leakage
|
||||
6. Compare: 288 tests, same pass/fail, faster execution
|
||||
|
||||
---
|
||||
|
||||
## Expected Impact
|
||||
|
||||
| Metric | Before | After Phase 1 | After Phase 3 |
|
||||
|--------|--------|---------------|---------------|
|
||||
| Schema DROP+CREATE | 288 | 10 (one per package) | 10 |
|
||||
| TRUNCATE per test | 21 tables | 21 tables | ~3-5 tables |
|
||||
| Connection pools created | 288 | 10 | 10 |
|
||||
| **Estimated total time** | **~144s** | **~58s** | **~30s** |
|
||||
|
||||
> Estimates based on: DROP+CREATE ~400ms, TRUNCATE 21 tables ~100ms, pool connect ~50ms. Actual timing depends on PostgreSQL container performance.
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Change
|
||||
|
||||
- **Don't add `t.Parallel()`** — requires per-test transactions or separate databases
|
||||
- **Don't use transaction rollback** — some tests verify side effects needing committed data
|
||||
- **Don't change `init-script.sql`** — schema is correct, we're just running it too many times
|
||||
- **Don't touch `fixtures.testEmailCounter`** — it's designed to be monotonic
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/notifications"
|
||||
@@ -102,6 +103,51 @@ func TestAdminToday_CurrentNext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminToday_CurrentNext_ClosingTime verifies that the current-next endpoint
|
||||
// returns the closing time for today.
|
||||
func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed working hours for today (query uses current weekday)
|
||||
todayWeekday := int(time.Now().Weekday())
|
||||
if todayWeekday == 0 {
|
||||
todayWeekday = 7
|
||||
}
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
|
||||
VALUES ($1, '09:00', '18:00', true)
|
||||
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '18:00', is_open = true
|
||||
`, todayWeekday)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed working hours: %v", err)
|
||||
}
|
||||
|
||||
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
|
||||
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response today.CurrentNextResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if response.ClosingTime == nil {
|
||||
t.Errorf("expected closing_time in response, got nil")
|
||||
}
|
||||
|
||||
if response.ClosingTime != nil && *response.ClosingTime == "" {
|
||||
t.Error("expected closing_time to be non-empty string")
|
||||
}
|
||||
|
||||
if response.ClosingTime != nil && *response.ClosingTime != "18:00:00" && *response.ClosingTime != "18:00" {
|
||||
t.Logf("got closing_time: %s", *response.ClosingTime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminToday_Appointments tests that an admin can get a list of all
|
||||
// bookings scheduled for today with their details.
|
||||
func TestAdminToday_Appointments(t *testing.T) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -2709,6 +2710,94 @@ func TestCreateEditRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to
|
||||
// change the booking time, and a time_blocker is created to reserve the new slot.
|
||||
func TestCreateEditRequest_WithTimeChange(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
|
||||
newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
|
||||
|
||||
handler := http.HandlerFunc(RequestEditHandler)
|
||||
reqBody := map[string]interface{}{
|
||||
"new_start_time": newStartTime.Format(time.RFC3339),
|
||||
}
|
||||
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
|
||||
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var erNewTime time.Time
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query edit request: %v", err)
|
||||
}
|
||||
if !erNewTime.Truncate(time.Second).Equal(newStartTime) {
|
||||
t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime)
|
||||
}
|
||||
|
||||
var blockerCount int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time_blockers: %v", err)
|
||||
}
|
||||
if blockerCount != 1 {
|
||||
t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount)
|
||||
}
|
||||
|
||||
var blockerStart time.Time
|
||||
var blockerDuration int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query time_blocker details: %v", err)
|
||||
}
|
||||
if !blockerStart.Truncate(time.Second).Equal(newStartTime) {
|
||||
t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart)
|
||||
}
|
||||
if blockerDuration < 15 {
|
||||
t.Errorf("expected blocker duration >= 15, got %d", blockerDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification
|
||||
func TestDeleteEditRequest(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
@@ -3029,6 +3118,360 @@ func TestAdminRejectEditRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves
|
||||
// an edit request, the associated time_blocker reservation is deleted.
|
||||
func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
|
||||
|
||||
createReq := http.HandlerFunc(RequestEditHandler)
|
||||
createBody := map[string]interface{}{
|
||||
"new_start_time": newStartTime.Format(time.RFC3339),
|
||||
}
|
||||
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountBefore int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers: %v", err)
|
||||
}
|
||||
if blockerCountBefore != 1 {
|
||||
t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore)
|
||||
}
|
||||
|
||||
var editRequestID string
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get edit request ID: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
|
||||
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
|
||||
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", bookingID)
|
||||
rctx.URLParams.Add("request_id", editRequestID)
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountAfter int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers after approval: %v", err)
|
||||
}
|
||||
if blockerCountAfter != 0 {
|
||||
t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects
|
||||
// an edit request, the associated time_blocker reservation is deleted.
|
||||
func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
|
||||
|
||||
createReq := http.HandlerFunc(RequestEditHandler)
|
||||
createBody := map[string]interface{}{
|
||||
"new_start_time": newStartTime.Format(time.RFC3339),
|
||||
}
|
||||
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountBefore int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers: %v", err)
|
||||
}
|
||||
if blockerCountBefore != 1 {
|
||||
t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore)
|
||||
}
|
||||
|
||||
var editRequestID string
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get edit request ID: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler)
|
||||
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil)
|
||||
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", bookingID)
|
||||
rctx.URLParams.Add("request_id", editRequestID)
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountAfter int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers after rejection: %v", err)
|
||||
}
|
||||
if blockerCountAfter != 0 {
|
||||
t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their
|
||||
// own edit request, the associated time_blocker reservation is deleted.
|
||||
func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
|
||||
|
||||
createReq := http.HandlerFunc(RequestEditHandler)
|
||||
createBody := map[string]interface{}{
|
||||
"new_start_time": newStartTime.Format(time.RFC3339),
|
||||
}
|
||||
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountBefore int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers: %v", err)
|
||||
}
|
||||
if blockerCountBefore != 1 {
|
||||
t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore)
|
||||
}
|
||||
|
||||
delHandler := http.HandlerFunc(DeleteEditRequestHandler)
|
||||
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var blockerCountAfter int
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
|
||||
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query blockers after delete: %v", err)
|
||||
}
|
||||
if blockerCountAfter != 0 {
|
||||
t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit
|
||||
// request fails when the new time conflicts with an existing time_blocker.
|
||||
func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(db.DB, userID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set deposits_required: %v", err)
|
||||
}
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteService(db.DB, serviceID)
|
||||
|
||||
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test booking: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteBooking(db.DB, bookingID)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to confirm booking: %v", err)
|
||||
}
|
||||
|
||||
userToken := jwt.GenerateUserToken(userID)
|
||||
|
||||
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
|
||||
|
||||
createReq := http.HandlerFunc(RequestEditHandler)
|
||||
createBody := map[string]interface{}{
|
||||
"new_start_time": newStartTime.Format(time.RFC3339),
|
||||
}
|
||||
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
|
||||
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, 60, 'Existing blocker', $2)
|
||||
`, newStartTime, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create blocking time_blocker: %v", err)
|
||||
}
|
||||
|
||||
var editRequestID string
|
||||
err = db.DB.QueryRow(context.Background(),
|
||||
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get edit request ID: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
|
||||
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
|
||||
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", bookingID)
|
||||
rctx.URLParams.Add("request_id", editRequestID)
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404
|
||||
func TestBookings_RequestEdit_BookingNotFound(t *testing.T) {
|
||||
cleanup := setupTestDB(t)
|
||||
|
||||
@@ -909,6 +909,11 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
|
||||
// Delete the admin notification for this edit request
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
DELETE FROM admin_notifications
|
||||
@@ -1048,6 +1053,39 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewStartTime != nil {
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
|
||||
var durationMinutes int
|
||||
if len(req.NewServices) > 0 {
|
||||
_ = tx.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
||||
FROM services s
|
||||
WHERE s.id = ANY($1)
|
||||
`, req.NewServices).Scan(&durationMinutes)
|
||||
} else {
|
||||
_ = tx.QueryRow(r.Context(), `
|
||||
SELECT COALESCE(SUM(s.duration_minutes), 60)
|
||||
FROM booking_services bs
|
||||
JOIN services s ON bs.service_id = s.id
|
||||
WHERE bs.booking_id = $1
|
||||
`, bookingID).Scan(&durationMinutes)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Delete existing admin notification for edit_request before creating new one (refreshes timestamp)
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
DELETE FROM admin_notifications
|
||||
@@ -1290,6 +1328,15 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check time blocker overlap: %v", err)
|
||||
}
|
||||
if blockerOverlap {
|
||||
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build update query for bookings table
|
||||
@@ -1355,6 +1402,11 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
|
||||
// Acknowledge the admin notification for this edit request
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
UPDATE admin_notifications
|
||||
@@ -1366,6 +1418,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was approved.
|
||||
// Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider.
|
||||
// The user_notification_preferences table exists but no delivery mechanism is wired yet.
|
||||
// See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system).
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -1418,6 +1476,11 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
WHERE description = $1
|
||||
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
|
||||
|
||||
// Acknowledge the admin notification for this edit request
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
UPDATE admin_notifications
|
||||
@@ -1430,6 +1493,9 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Notify user that their edit request was denied.
|
||||
// Same as approve TODO above — needs user_notifications table or email delivery (E5).
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
log.Printf("Failed to commit reject edit request: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -343,6 +343,7 @@ func CleanupOldReservations(ctx context.Context) error {
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
|
||||
fifteenMinutesAgo := time.Now().Add(-15 * time.Minute)
|
||||
twentyFourHoursAgo := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
DELETE FROM time_blockers
|
||||
@@ -350,7 +351,8 @@ func CleanupOldReservations(ctx context.Context) error {
|
||||
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
|
||||
OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
|
||||
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
|
||||
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo)
|
||||
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
|
||||
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1232,3 +1232,61 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) {
|
||||
t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupOldReservations (Edit Request) ---
|
||||
|
||||
// TestCleanupOldReservations_EditRequest verifies that edit request reservations
|
||||
// older than 24 hours are deleted, while recent ones are preserved.
|
||||
func TestCleanupOldReservations_EditRequest(t *testing.T) {
|
||||
cleanup := setupTimeBlockersTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
ukLocation, _ := time.LoadLocation("Europe/London")
|
||||
|
||||
// Create old edit_request reservation (>24 hours old)
|
||||
oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2)
|
||||
`, oldTime, time.Now().Add(-25*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old edit_request reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent edit_request reservation (<24 hours old)
|
||||
recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
||||
VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2)
|
||||
`, recentTime, time.Now().Add(-12*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent edit_request reservation: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupOldReservations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old reservation was deleted
|
||||
var oldCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check old reservation: %v", err)
|
||||
}
|
||||
if oldCount != 0 {
|
||||
t.Error("expected old edit_request reservation (25h) to be deleted")
|
||||
}
|
||||
|
||||
// Verify recent reservation still exists
|
||||
var recentCount int
|
||||
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check recent reservation: %v", err)
|
||||
}
|
||||
if recentCount != 1 {
|
||||
t.Error("expected recent edit_request reservation (12h) to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ type AppointmentInfo struct {
|
||||
}
|
||||
|
||||
type CurrentNextResponse struct {
|
||||
Current *AppointmentInfo `json:"current"`
|
||||
Next *AppointmentInfo `json:"next"`
|
||||
Current *AppointmentInfo `json:"current"`
|
||||
Next *AppointmentInfo `json:"next"`
|
||||
ClosingTime *string `json:"closing_time,omitempty"` // "HH:MM" format
|
||||
}
|
||||
|
||||
// GET /api/admin/today/current-next
|
||||
@@ -158,6 +159,18 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
||||
Next: next,
|
||||
}
|
||||
|
||||
weekday := int(now.Weekday())
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
var closingTime sql.NullString
|
||||
_ = db.DB.QueryRow(r.Context(), `
|
||||
SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true
|
||||
`, weekday).Scan(&closingTime)
|
||||
if closingTime.Valid {
|
||||
response.ClosingTime = &closingTime.String
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
|
||||
@@ -351,6 +351,70 @@
|
||||
|
||||
async function submitReschedule() {
|
||||
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
|
||||
|
||||
// Re-fetch available hours to confirm slot is still open
|
||||
try {
|
||||
const dateStr = rescheduleDate.toString();
|
||||
const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`;
|
||||
const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1);
|
||||
const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate));
|
||||
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
|
||||
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const freshWH: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
|
||||
const freshAH: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
|
||||
ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
|
||||
|
||||
const dayWH = freshWH[dateStr];
|
||||
const dayAH = freshAH[dateStr];
|
||||
|
||||
if (!dayWH?.isOpen || !dayAH?.slots) {
|
||||
toast.error('This date is no longer available. Please select a different date.');
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the selected time is still available
|
||||
const freshSlots: string[] = [];
|
||||
for (const slot of dayAH.slots) {
|
||||
const [sh, sm] = slot.startTime.split(':').map(Number);
|
||||
const [eh, em] = slot.endTime.split(':').map(Number);
|
||||
for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) {
|
||||
if (m + totalDuration <= eh * 60 + em) {
|
||||
freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!freshSlots.includes(rescheduleTime)) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check lunch protection
|
||||
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
||||
const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false);
|
||||
if (freshLunch.get(rescheduleTime)?.isBlocked) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not verify slot availability. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
rescheduleSubmitting = true;
|
||||
try {
|
||||
const [hours, minutes] = rescheduleTime.split(':').map(Number);
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
|
||||
let currentAppointment = $state<Booking | null>(null);
|
||||
let nextAppointment = $state<Booking | null>(null);
|
||||
let closingTime = $state<string | null>(null); // "HH:MM" format
|
||||
let freeTimeAfter = $state(0); // minutes of free time after current/next appointment
|
||||
let freeTimeCapped = $state(false); // true if free time extends to/past closing
|
||||
let loading = $state(true);
|
||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
||||
let isInProgress = $state(false);
|
||||
@@ -52,48 +54,53 @@
|
||||
isInProgress = currentAppointment.status === 'in_progress';
|
||||
const startTime = new SvelteDate(currentAppointment.start_time);
|
||||
|
||||
if (isInProgress) {
|
||||
// Appointment is in progress - show time remaining until end
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
|
||||
// If current time is before start time, show time until start
|
||||
if (isInProgress) {
|
||||
if (now.getTime() < startTime.getTime()) {
|
||||
const timeUntilMs = startTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
||||
} else {
|
||||
// Otherwise show time until end
|
||||
const remainingMs = endTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(remainingMs / 60000));
|
||||
}
|
||||
|
||||
// Calculate free time until next appointment
|
||||
if (nextAppointment) {
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
|
||||
} else {
|
||||
freeTimeAfter = 0;
|
||||
}
|
||||
} else {
|
||||
// Appointment is upcoming - show time until start
|
||||
const timeUntilMs = startTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
||||
freeTimeAfter = 0;
|
||||
}
|
||||
|
||||
// If there's a next appointment, calculate free time after this one ends
|
||||
if (nextAppointment) {
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
|
||||
// Calculate free time after this appointment
|
||||
let rawFreeMinutes = 0;
|
||||
if (nextAppointment) {
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
|
||||
}
|
||||
|
||||
// Cap at closing time
|
||||
if (closingTime) {
|
||||
const [ch, cm] = closingTime.split(':').map(Number);
|
||||
const today = new SvelteDate();
|
||||
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0);
|
||||
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000));
|
||||
|
||||
if (!nextAppointment) {
|
||||
// No next appointment — show time until closing
|
||||
freeTimeAfter = minutesToClosing;
|
||||
freeTimeCapped = true;
|
||||
} else if (rawFreeMinutes > minutesToClosing) {
|
||||
// Next appointment is after closing — cap at closing
|
||||
freeTimeAfter = minutesToClosing;
|
||||
freeTimeCapped = true;
|
||||
} else {
|
||||
freeTimeAfter = rawFreeMinutes;
|
||||
freeTimeCapped = false;
|
||||
}
|
||||
} else {
|
||||
freeTimeAfter = nextAppointment ? rawFreeMinutes : 0;
|
||||
freeTimeCapped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +120,7 @@
|
||||
const data = await response.json();
|
||||
currentAppointment = data.current || null;
|
||||
nextAppointment = data.next || null;
|
||||
closingTime = data.closing_time || null;
|
||||
calculateTimes();
|
||||
} else {
|
||||
toast.error('Failed to load current appointment');
|
||||
@@ -203,10 +211,16 @@
|
||||
</span>
|
||||
In Progress • {timeRemaining} min remaining
|
||||
</Badge>
|
||||
{#if freeTimeAfter > 0 && timeRemaining > 29}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
{freeTimeAfter} min free afterwards
|
||||
</Badge>
|
||||
{#if timeRemaining > 29}
|
||||
{#if freeTimeCapped && freeTimeAfter === 0}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
closing after
|
||||
</Badge>
|
||||
{:else if freeTimeAfter > 0}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -28,7 +28,8 @@ No external dependencies. No paid services. No API keys needed.
|
||||
| 10 | **Password reset flow not wired to frontend** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check` endpoints. Login page has no "forgot password" link or form. |
|
||||
| 11 | **Email verification flow not wired to frontend** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend code. `+layout.svelte` has alert-based prototype. |
|
||||
| 12 | **Booking cancellation from user account** | S (2-3h) | Frontend | UserBookingModal shows booking details but no cancel button. Users must call/email to cancel. Backend endpoint exists (`DELETE /api/bookings/{id}`). |
|
||||
| 13 | **Booking rescheduling for users** | M (1-2d) | Full-stack | Users can't reschedule their own bookings. `booking_edit_requests` table exists but frontend flow is incomplete — ApprovalModal only handles approve, not decline. |
|
||||
| 13 | ~~**Booking rescheduling for users**~~ ⚠️ | M (1-2d) | Full-stack | Backend fully wired (request/approve/deny + time_blocker reservation). Frontend re-validation on submit implemented. **Remaining:** Admin UI to view/approve/deny edit requests — consider extending existing Pending Approvals panel. User notification on approve/deny tracked as TODO (blocked on E5). |
|
||||
| 50 | **Admin UI for edit request approval/denial** | M (1-2d) | Frontend | Backend endpoints exist: `GET /api/admin/bookings/{id}/edit-requests`, `POST .../approve`, `POST .../deny`. Needs UI to display pending requests with proposed vs original time, approve/deny buttons. **Consider:** Extend existing Pending Approvals panel on Today page rather than building separate page. |
|
||||
|
||||
## P2 — Medium
|
||||
|
||||
|
||||
Reference in New Issue
Block a user