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>
209 lines
7.7 KiB
Go
209 lines
7.7 KiB
Go
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
|
||
// Sun–Sat 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
|
||
// Sun–Sat 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)
|
||
}
|
||
}
|