calcomblock/dategrid_alignment_test.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

209 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// These tests pin the calendar-grid layout contract, written against the
// second bidbuddy.com.au report (2026-06-10, 20:59): the grid renders fixed
// SunSat column headers but poured consecutive day buttons in from column 1
// with no offset, so June 10 (a Wednesday) sat under "Sun" and tapping the
// cell under "Tue" (showing 12) selected Friday, June 12. The first date must
// be preceded by int(weekday) spacer cells so every button lands under its
// real weekday header. "Today" (window start + IsPast/IsToday flags) must be
// resolved in the visitor/site timezone, not the server clock's.
// divergentZone returns an IANA zone whose current calendar date differs from
// the server-local date right now. Pacific/Kiritimati (UTC+14) and
// Pacific/Pago_Pago (UTC-11) are 25 hours apart, so at any instant at least
// one of them is on a different date than the server.
func divergentZone(t *testing.T) (*time.Location, string) {
t.Helper()
serverToday := time.Now().Format("2006-01-02")
for _, name := range []string{"Pacific/Kiritimati", "Pacific/Pago_Pago"} {
loc, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%s): %v", name, err)
}
if time.Now().In(loc).Format("2006-01-02") != serverToday {
return loc, name
}
}
t.Fatal("no zone diverges from server-local date — zones are 25h apart, impossible")
return nil, ""
}
// firstDateParam extracts the YYYY-MM-DD value of the first date= query param
// in rendered HTML — i.e. the first day button of the grid.
func firstDateParam(t *testing.T, html string) string {
t.Helper()
i := strings.Index(html, "date=")
if i < 0 || i+15 > len(html) {
t.Fatalf("no date= param found in rendered HTML")
}
return html[i+5 : i+15]
}
func TestLeadingBlanks(t *testing.T) {
cell := func(date string) DateCell { return DateCell{Date: date} }
cases := []struct {
name string
dates []DateCell
want int
}{
{"wednesday start needs 3 pads", []DateCell{cell("2026-06-10")}, 3},
{"sunday start needs none", []DateCell{cell("2026-06-14")}, 0},
{"saturday start needs 6", []DateCell{cell("2026-06-13")}, 6},
{"empty grid", nil, 0},
{"unparseable date", []DateCell{cell("garbage")}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := leadingBlanks(tc.dates); got != tc.want {
t.Errorf("leadingBlanks(%v) = %d, want %d", tc.dates, got, tc.want)
}
})
}
}
// TestHandleGetDateGrid_AlignsFirstDateToWeekdayColumn drives /date-grid with
// a future Wednesday weekStart and asserts exactly 3 spacer cells render
// before the first day button, so the 7-column grid lines dates up with the
// SunSat headers.
func TestHandleGetDateGrid_AlignsFirstDateToWeekdayColumn(t *testing.T) {
router := newTestRouter(t)
// Next Wednesday at least a week out, so it is never clamped to today.
start := time.Now().AddDate(0, 0, 7)
for start.Weekday() != time.Wednesday {
start = start.AddDate(0, 0, 1)
}
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-align&username=alice&eventType=30min&weeks=2&weekStart="+start.Format("2006-01-02"), nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if got := strings.Count(body, "calcom-date-pad"); got != 3 {
t.Errorf("Wednesday window: want 3 leading pad cells, got %d", got)
}
padIdx := strings.Index(body, "calcom-date-pad")
btnIdx := strings.Index(body, "date=")
if padIdx < 0 || btnIdx < 0 || padIdx > btnIdx {
t.Errorf("pad cells must precede the first day button (padIdx=%d, btnIdx=%d)", padIdx, btnIdx)
}
if !strings.Contains(body, `calcom-date-pad" aria-hidden="true"`) {
t.Errorf("pad cells must be aria-hidden")
}
}
// TestHandleGetDateGrid_SundayStartHasNoPads is the zero case: a window
// starting on a Sunday begins flush in column 1.
func TestHandleGetDateGrid_SundayStartHasNoPads(t *testing.T) {
router := newTestRouter(t)
start := time.Now().AddDate(0, 0, 7)
for start.Weekday() != time.Sunday {
start = start.AddDate(0, 0, 1)
}
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-sun&username=alice&eventType=30min&weeks=2&weekStart="+start.Format("2006-01-02"), nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := strings.Count(rec.Body.String(), "calcom-date-pad"); got != 0 {
t.Errorf("Sunday window: want 0 pad cells, got %d", got)
}
}
// TestHandleGetDateGrid_TodayResolvedInEventTimezone pins the clamp rule to
// the MEETING's calendar, not the server's: a past weekStart must clamp to
// today in the event's schedule timezone. Pre-fix the handler clamped to the
// server-local date, so a UTC container made a Perth schedule's grid start on
// yesterday until 08:00 AWST.
func TestHandleGetDateGrid_TodayResolvedInEventTimezone(t *testing.T) {
loc, name := divergentZone(t)
fakeCalcom(t, name, nil)
h, _ := newKeyedHandler(t)
wantToday := time.Now().In(loc).Format("2006-01-02")
req := httptest.NewRequest(http.MethodGet,
"/date-grid?blockId=b-tz&username=alice&eventType=30min&weeks=2&weekStart=2020-01-01", nil)
rec := httptest.NewRecorder()
h.HandleGetDateGrid(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String())
}
if got := firstDateParam(t, rec.Body.String()); got != wantToday {
t.Errorf("clamped window must start at today in %s (%s), got %s", name, wantToday, got)
}
}
// TestCalcomBookingFunc_InitialGridUsesSiteTimezone covers the initial
// server-side block render with a cold event-timezone cache: the window must
// start at today in the SITE timezone (Site Settings → Localization),
// resolved via the settings manager stashed at router-mount time — not at
// the server clock's date. Page render never calls Cal.com.
func TestCalcomBookingFunc_InitialGridUsesSiteTimezone(t *testing.T) {
s, q := newTestSettings()
loc, name := divergentZone(t)
seedSiteTimezone(q, name)
renderSettings = s
resetEventTZCache()
t.Cleanup(func() { renderSettings = nil })
html := CalcomBookingFunc(context.Background(), map[string]any{
"_block_id": "b-init",
"username": "alice",
"eventTypeSlug": "30min",
})
wantToday := time.Now().In(loc).Format("2006-01-02")
if got := firstDateParam(t, html); got != wantToday {
t.Errorf("initial grid must start at today in site tz %s (%s), got %s", name, wantToday, got)
}
wantPads := int(time.Now().In(loc).Weekday())
if got := strings.Count(html, "calcom-date-pad"); got != wantPads {
t.Errorf("initial grid: want %d pad cells for %s, got %d", wantPads, wantToday, got)
}
}
// TestCalcomBookingFunc_InitialGridPrefersCachedEventTimezone asserts the
// initial render uses the meeting timezone over the site setting once the
// cache is warm (any visitor's first HTMX interaction warms it).
func TestCalcomBookingFunc_InitialGridPrefersCachedEventTimezone(t *testing.T) {
s, q := newTestSettings()
loc, name := divergentZone(t)
seedSiteTimezone(q, "UTC") // site setting present but unset-equivalent
storeEventTZ("alice", "30min", name)
renderSettings = s
t.Cleanup(func() {
renderSettings = nil
resetEventTZCache()
})
html := CalcomBookingFunc(context.Background(), map[string]any{
"_block_id": "b-init2",
"username": "alice",
"eventTypeSlug": "30min",
})
wantToday := time.Now().In(loc).Format("2006-01-02")
if got := firstDateParam(t, html); got != wantToday {
t.Errorf("initial grid must start at today in cached event tz %s (%s), got %s", name, wantToday, got)
}
if !strings.Contains(html, name) {
t.Errorf("ShowTimezone label should carry the event tz %s", name)
}
}