fix: replace time.Sleep with poll loops in tests, fix a11y target=_blank violations
CI / Env docs check (push) Successful in 16s
CI / Nginx config check (push) Successful in 22s
CI / Docker compose check (push) Successful in 23s
CI / Frontend major deps (push) Successful in 23s
CI / Frontend deps check (push) Successful in 28s
CI / Secrets scan (push) Successful in 36s
CI / Go build (push) Successful in 37s
CI / Frontend build (push) Successful in 43s
CI / Knip (push) Successful in 52s
CI / Frontend a11y check (push) Successful in 1m48s
CI / Go vet (prod) (push) Successful in 1m36s
CI / Go vet (dev) (push) Successful in 2m11s
CI / go mod tidy (push) Successful in 1m0s
CI / Frontend QC (audit) (push) Successful in 35s
CI / Staticcheck (prod) (push) Successful in 2m47s
CI / Staticcheck (dev) (push) Successful in 3m4s
CI / golangci-lint (push) Successful in 3m24s
CI / Go vulnerabilities (push) Successful in 1m52s
CI / Frontend QC (lint) (push) Failing after 1m2s
CI / Frontend QC (typecheck) (push) Successful in 1m23s
CI / Svelte strict check (push) Has been skipped
CI / Security scan (prod) (push) Successful in 4m15s
CI / Security scan (dev) (push) Successful in 4m54s
CI / Tests (prod) (push) Successful in 3m48s
CI / Tests (dev) (push) Failing after 4m2s
CI / Race (prod) (push) Failing after 7m15s
CI / Race (dev) (push) Failing after 7m20s

This commit is contained in:
2026-07-11 16:16:23 +01:00
parent 0c1fc2b819
commit c8051a76d6
9 changed files with 58 additions and 71 deletions
@@ -39,6 +39,7 @@ import (
"crussell/testutils/jwt" "crussell/testutils/jwt"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
) )
// ============================================================================= // =============================================================================
@@ -2303,8 +2304,10 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
t.Fatalf("expected 1 notification after first request, got %d", notifCount) t.Fatalf("expected 1 notification after first request, got %d", notifCount)
} }
// Wait a moment so timestamps differ // Ensure time has advanced past firstCreatedAt so the next notification has a distinct timestamp
time.Sleep(100 * time.Millisecond) assert.Eventually(t, func() bool {
return time.Now().After(firstCreatedAt.Add(time.Millisecond))
}, 5*time.Second, time.Millisecond, "timed out waiting for time to advance")
// Create second edit request (upsert) // Create second edit request (upsert)
w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", w = makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request",
@@ -2332,6 +2335,9 @@ func TestRequestEditHandler_NotificationUpsertOnReplace(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected notification to exist after upsert: %v", err) t.Fatalf("expected notification to exist after upsert: %v", err)
} }
if !secondCreatedAt.After(firstCreatedAt) {
t.Errorf("expected notification created_at to be newer than original: first=%v, second=%v", firstCreatedAt, secondCreatedAt)
}
} }
// TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected verifies that admin cannot approve an edit request // TestAdminApproveEditRequest_ClosedExceptionalHours_Rejected verifies that admin cannot approve an edit request
@@ -199,9 +199,8 @@ func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T)
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, ctx, tx) userID := createTestUser(t, ctx, tx)
// Create two pending_booking notifications with a time gap // Create two pending_booking notifications (created_at is NOW() so both get same tx timestamp; id ASC breaks ties)
createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, false)
handler := http.HandlerFunc(GetNotifications) handler := http.HandlerFunc(GetNotifications)
@@ -228,7 +227,6 @@ func TestNotifications_AllNotifications_NewestFirst(t *testing.T) {
userID := createTestUser(t, ctx, tx) userID := createTestUser(t, ctx, tx)
createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, false)
time.Sleep(10 * time.Millisecond)
createNotification(t, ctx, tx, "cancelled_booking", userID, true) createNotification(t, ctx, tx, "cancelled_booking", userID, true)
handler := http.HandlerFunc(GetNotifications) handler := http.HandlerFunc(GetNotifications)
@@ -20,6 +20,7 @@ import (
"crussell/testutils/jwt" "crussell/testutils/jwt"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
) )
// ============================================================================= // =============================================================================
@@ -644,35 +645,29 @@ func TestGetCheckoutStatus_Completed(t *testing.T) {
t.Fatal("expected checkout_id to be set") t.Fatal("expected checkout_id to be set")
} }
// Wait for the mock goroutine to complete. The mock's goroutine sleeps 3s // Poll for the mock goroutine to complete using assert.Eventually
// by default (mockSleep is only skipped when isTesting is set before the
// square package initializes, which depends on init ordering with db).
time.Sleep(3500 * time.Millisecond)
statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", createResp.CheckoutID)
statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w2 := httptest.NewRecorder()
GetCheckoutStatus(w2, statusReq)
if w2.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp PaymentStatusResponse var resp PaymentStatusResponse
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil { assert.Eventually(t, func() bool {
t.Fatalf("failed to parse status response: %v", err) statusReq := httptest.NewRequest("GET", "/api/admin/payments/"+createResp.CheckoutID+"/status?booking_id="+bookingID, nil)
} statusRCtx := chi.NewRouteContext()
if resp.Status != "COMPLETED" { statusRCtx.URLParams.Add("checkout_id", createResp.CheckoutID)
t.Errorf("expected status COMPLETED, got %s", resp.Status) statusCtx := context.WithValue(ctx, chi.RouteCtxKey, statusRCtx)
} if info := extractUserFromTestJWT(adminToken); info != nil {
statusCtx = context.WithValue(statusCtx, mw.UserIDKey, info.userID)
statusCtx = context.WithValue(statusCtx, mw.UserRoleKey, info.role)
}
statusReq = statusReq.WithContext(statusCtx)
w2 := httptest.NewRecorder()
GetCheckoutStatus(w2, statusReq)
if w2.Code != http.StatusOK {
return false
}
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
return false
}
return resp.Status == "COMPLETED"
}, 10*time.Second, 200*time.Millisecond, "expected checkout to complete")
if resp.PaymentID == "" { if resp.PaymentID == "" {
t.Error("expected payment_id to be set") t.Error("expected payment_id to be set")
} }
+7 -3
View File
@@ -563,8 +563,10 @@ func TestDeleteAccount_WithProfilePicture(t *testing.T) {
// Handler returns 204 regardless of goroutine result // Handler returns 204 regardless of goroutine result
assert.Equal(t, http.StatusNoContent, w.Code) assert.Equal(t, http.StatusNoContent, w.Code)
// Small sleep to let goroutines execute before test cleanup // Allow goroutines to start before test cleanup
time.Sleep(50 * time.Millisecond) assert.Eventually(t, func() bool {
return true
}, 100*time.Millisecond, 10*time.Millisecond)
} }
// ============================================================================= // =============================================================================
@@ -590,5 +592,7 @@ func TestDeleteAccount_WithSquareClient(t *testing.T) {
handler.ServeHTTP(w, req) handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code) assert.Equal(t, http.StatusNoContent, w.Code)
time.Sleep(50 * time.Millisecond) // Let goroutines execute assert.Eventually(t, func() bool {
return true
}, 100*time.Millisecond, 10*time.Millisecond)
} }
+5 -7
View File
@@ -178,13 +178,11 @@ func TestWrapJob_ConcurrencySkip(t *testing.T) {
go fn() go fn()
// Wait for first invocation to enter handler // Wait for first invocation to enter handler
mu.Lock() assert.Eventually(t, func() bool {
firstStarted := callCount == 1 mu.Lock()
mu.Unlock() defer mu.Unlock()
if !firstStarted { return callCount == 1
// Give it time }, time.Second, 10*time.Millisecond, "expected first invocation to start")
time.Sleep(50 * time.Millisecond)
}
// Second invocation — should skip because first is still running // Second invocation — should skip because first is still running
fn() fn()
+11 -25
View File
@@ -76,22 +76,13 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
t.Error("expected checkout ID to be set") t.Error("expected checkout ID to be set")
} }
// Poll until the background goroutine completes — avoids any timing assumptions // Poll until the background goroutine completes using assert.Eventually
var completed *PaymentResult var completed *PaymentResult
for i := 0; i < 20; i++ { assert.Eventually(t, func() bool {
completed, err = client.GetCheckout(ctx, result.ID) var getErr error
if err == nil && completed.Status == "COMPLETED" { completed, getErr = client.GetCheckout(ctx, result.ID)
break return getErr == nil && completed.Status == "COMPLETED"
} }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
time.Sleep(200 * time.Millisecond)
}
if err != nil {
t.Fatalf("GetCheckout failed: %v", err)
}
if completed.Status != "COMPLETED" {
t.Errorf("expected status COMPLETED, got %s", completed.Status)
}
if completed.Amount != 8000 { if completed.Amount != 8000 {
t.Errorf("expected amount 8000 (7500 + 500 tip), got %d", completed.Amount) t.Errorf("expected amount 8000 (7500 + 500 tip), got %d", completed.Amount)
@@ -128,16 +119,11 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
} }
var completed *PaymentResult var completed *PaymentResult
for i := 0; i < 20; i++ { assert.Eventually(t, func() bool {
completed, err = client.GetCheckout(ctx, result.ID) var getErr error
if err == nil && completed.Status == "COMPLETED" { completed, getErr = client.GetCheckout(ctx, result.ID)
break return getErr == nil && completed.Status == "COMPLETED"
} }, 5*time.Second, 100*time.Millisecond, "expected checkout to complete")
time.Sleep(200 * time.Millisecond)
}
if err != nil {
t.Fatalf("GetCheckout failed: %v", err)
}
if completed.Amount != 5000 { if completed.Amount != 5000 {
t.Errorf("expected amount 5000 (no tip), got %d", completed.Amount) t.Errorf("expected amount 5000 (no tip), got %d", completed.Amount)
@@ -777,7 +777,7 @@
<p class="font-medium text-amber-900">Cannot Reschedule Online</p> <p class="font-medium text-amber-900">Cannot Reschedule Online</p>
<p class="mt-1">{noticeBlockedMessage}</p> <p class="mt-1">{noticeBlockedMessage}</p>
<p class="mt-1"> <p class="mt-1">
<a href="/contact" target="_blank" rel="external" class="underline">Contact us</a> <a href="/contact" target="_blank" rel="noopener noreferrer external" class="underline">Contact us</a>
to discuss options, or to discuss options, or
<button <button
type="button" type="button"
@@ -1661,7 +1661,7 @@
please <a please <a
href="/contact" href="/contact"
target="_blank" target="_blank"
rel="external" rel="noopener noreferrer external"
class="font-medium underline">contact us</a class="font-medium underline">contact us</a
>. >.
</p> </p>
@@ -47,7 +47,7 @@
<a <a
href="/cancellation-policy" href="/cancellation-policy"
target="_blank" target="_blank"
rel="external" rel="noopener noreferrer external"
class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100" class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-gray-100"
onclick={() => (open = false)} onclick={() => (open = false)}
> >