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>
121 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|