calcomblock/booking_errors_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

121 lines
4.7 KiB
Go

package main
import (
"errors"
"fmt"
"testing"
)
// TestClassifyBookingError_GroundedTaxonomy locks in the mapping against the
// REAL Cal.com error bodies captured by probing the live /book endpoint
// (2026-06-21). Each case feeds a *CalcomAPIError built from an actual response
// and asserts the curated visitor copy + retry affordance. The raw Cal.com
// message must never be the rendered string.
func TestClassifyBookingError_GroundedTaxonomy(t *testing.T) {
const adminMsg = "Call us on 1234."
cases := []struct {
name string
err error
wantMsg string
wantRtry bool
}{
{
// Live: status 400, BadRequestException: "Attempting to book a meeting in the past."
name: "past instant",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "Attempting to book a meeting in the past."},
wantMsg: msgSlotUnavailable,
wantRtry: true,
},
{
// Live: status 400, BadRequestException: "User either already has booking at this time or is not available"
name: "slot taken / host unavailable",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "User either already has booking at this time or is not available"},
wantMsg: msgSlotUnavailable,
wantRtry: true,
},
{
// Live: status 400, BAD_REQUEST: "responses - {guests}email_validation_error, "
name: "invalid guest email",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {guests}email_validation_error, "},
wantMsg: msgInvalidGuestEmail,
wantRtry: false,
},
{
// Cal.com phone rejection: "{attendeePhoneNumber} invalid_number".
name: "invalid phone number",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {attendeePhoneNumber}invalid_number"},
wantMsg: msgInvalidPhone,
wantRtry: false,
},
{
// A rejected custom-field answer (a required field / generic responses error
// that names no guest/phone signal).
name: "rejected custom field answer",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BAD_REQUEST", Message: "responses - {favourite-colour} is required"},
wantMsg: msgRejectedAnswer,
wantRtry: false,
},
{
name: "event fully booked / booking limit",
err: &CalcomAPIError{Endpoint: "booking", Status: 400, Code: "BadRequestException", Message: "booking_limit reached for this event type"},
wantMsg: msgEventFullyBooked,
wantRtry: true,
},
{
// Live: status 404, NotFoundException — unmapped → Tier-2 admin message.
name: "not found (unmapped 4xx) → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 404, Code: "NotFoundException", Message: "Event type with slug nope not found"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "auth failure (401) → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 401, Code: "UnauthorizedException", Message: "Invalid API Key"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "cal.com 5xx → admin message",
err: &CalcomAPIError{Endpoint: "booking", Status: 503, Code: "", Message: "service unavailable"},
wantMsg: adminMsg,
wantRtry: false,
},
{
name: "network/transport error (not a CalcomAPIError) → admin message",
err: fmt.Errorf("post bookings: %w", errors.New("dial tcp: i/o timeout")),
wantMsg: adminMsg,
wantRtry: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifyBookingError(tc.err, adminMsg)
if got.message != tc.wantMsg {
t.Errorf("message = %q, want %q", got.message, tc.wantMsg)
}
if got.canRetry != tc.wantRtry {
t.Errorf("canRetry = %v, want %v", got.canRetry, tc.wantRtry)
}
// The raw Cal.com message must never be the rendered copy.
var apiErr *CalcomAPIError
if errors.As(tc.err, &apiErr) && apiErr.Message != "" && got.message == apiErr.Message {
t.Errorf("rendered the raw Cal.com message verbatim: %q", got.message)
}
})
}
}
// TestClassifyBookingError_BlankAdminMessageIsCallerDefaulted documents that the
// classifier renders whatever adminMessage it is given for Tier-2 — the caller
// (HandleCreateBooking) substitutes DefaultUnavailableMessage when the form
// field is blank, so the two stay in sync via the single const.
func TestClassifyBookingError_BlankAdminMessageIsCallerDefaulted(t *testing.T) {
err := &CalcomAPIError{Endpoint: "booking", Status: 404, Message: "nope"}
got := classifyBookingError(err, DefaultUnavailableMessage)
if got.message != DefaultUnavailableMessage {
t.Errorf("message = %q, want DefaultUnavailableMessage", got.message)
}
}