diff --git a/chat_completions_internal_test.go b/chat_completions_internal_test.go new file mode 100644 index 0000000..fc01a7e --- /dev/null +++ b/chat_completions_internal_test.go @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "testing" + + inference "dappco.re/go/core/inference" +) + +// TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil verifies the +// calibrated default wins when the caller omits the parameter (pointer is nil). +// +// Spec §11.2 — "When a parameter is omitted (nil), the server applies the +// calibrated default." +func TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil(t *testing.T) { + got := chatResolvedFloat(nil, chatDefaultTemperature) + if got != chatDefaultTemperature { + t.Fatalf("expected default %v, got %v", chatDefaultTemperature, got) + } +} + +// TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero verifies that +// an explicitly-set zero value overrides the default. +// +// Spec §11.2 — "When explicitly set (including 0.0), the server honours the +// caller's value." +func TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero(t *testing.T) { + zero := float32(0.0) + got := chatResolvedFloat(&zero, chatDefaultTemperature) + if got != 0.0 { + t.Fatalf("expected explicit 0.0 to be honoured, got %v", got) + } +} + +// TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil mirrors the +// float variant for integer sampling parameters (top_k, max_tokens). +func TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil(t *testing.T) { + got := chatResolvedInt(nil, chatDefaultTopK) + if got != chatDefaultTopK { + t.Fatalf("expected default %d, got %d", chatDefaultTopK, got) + } +} + +// TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero verifies that +// an explicitly-set zero integer overrides the default. +func TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero(t *testing.T) { + zero := 0 + got := chatResolvedInt(&zero, chatDefaultTopK) + if got != 0 { + t.Fatalf("expected explicit 0 to be honoured, got %d", got) + } +} + +// TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults verifies +// that an otherwise-empty request produces the Gemma 4 calibrated sampling +// defaults documented in RFC §11.2. +// +// temperature 1.0, top_p 0.95, top_k 64, max_tokens 2048 +func TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults(t *testing.T) { + req := &ChatCompletionRequest{Model: "lemer"} + + opts, err := chatRequestOptions(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(opts) == 0 { + t.Fatal("expected at least one inference option for defaults") + } + + cfg := inference.ApplyGenerateOpts(opts) + if cfg.Temperature != chatDefaultTemperature { + t.Fatalf("expected default temperature %v, got %v", chatDefaultTemperature, cfg.Temperature) + } + if cfg.TopP != chatDefaultTopP { + t.Fatalf("expected default top_p %v, got %v", chatDefaultTopP, cfg.TopP) + } + if cfg.TopK != chatDefaultTopK { + t.Fatalf("expected default top_k %d, got %d", chatDefaultTopK, cfg.TopK) + } + if cfg.MaxTokens != chatDefaultMaxTokens { + t.Fatalf("expected default max_tokens %d, got %d", chatDefaultMaxTokens, cfg.MaxTokens) + } +} + +// TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling verifies +// that caller-supplied sampling parameters (including zero for greedy decoding) +// override the Gemma 4 calibrated defaults. +func TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling(t *testing.T) { + temp := float32(0.0) + topP := float32(0.5) + topK := 10 + maxTokens := 512 + + req := &ChatCompletionRequest{ + Model: "lemer", + Temperature: &temp, + TopP: &topP, + TopK: &topK, + MaxTokens: &maxTokens, + } + + opts, err := chatRequestOptions(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := inference.ApplyGenerateOpts(opts) + if cfg.Temperature != 0.0 { + t.Fatalf("expected explicit temperature 0.0, got %v", cfg.Temperature) + } + if cfg.TopP != 0.5 { + t.Fatalf("expected explicit top_p 0.5, got %v", cfg.TopP) + } + if cfg.TopK != 10 { + t.Fatalf("expected explicit top_k 10, got %d", cfg.TopK) + } + if cfg.MaxTokens != 512 { + t.Fatalf("expected explicit max_tokens 512, got %d", cfg.MaxTokens) + } +} + +// TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop verifies the +// stop-token parser surfaces malformed values rather than silently ignoring +// them. +func TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop(t *testing.T) { + req := &ChatCompletionRequest{ + Model: "lemer", + Stop: []string{"oops"}, + } + if _, err := chatRequestOptions(req); err == nil { + t.Fatal("expected malformed stop entry to produce an error") + } +} + +// TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected ensures +// an all-whitespace stop entry is treated as invalid rather than as zero. +func TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected(t *testing.T) { + req := &ChatCompletionRequest{ + Model: "lemer", + Stop: []string{" "}, + } + if _, err := chatRequestOptions(req); err == nil { + t.Fatal("expected empty stop entry to produce an error") + } +} + +// TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap documents the +// finish_reason=length contract — generation that meets the caller's +// max_tokens budget is reported as length-capped. +func TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap(t *testing.T) { + cap := 10 + if !isTokenLengthCapReached(&cap, 10) { + t.Fatal("expected cap to be reached when generated == max_tokens") + } + if !isTokenLengthCapReached(&cap, 20) { + t.Fatal("expected cap to be reached when generated > max_tokens") + } + if isTokenLengthCapReached(&cap, 5) { + t.Fatal("expected cap not reached when generated < max_tokens") + } +} + +// TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap +// documents that the cap is disabled when max_tokens is unset or non-positive. +func TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap(t *testing.T) { + if isTokenLengthCapReached(nil, 999_999) { + t.Fatal("expected nil max_tokens to disable the cap") + } + zero := 0 + if isTokenLengthCapReached(&zero, 999_999) { + t.Fatal("expected zero max_tokens to disable the cap") + } + neg := -1 + if isTokenLengthCapReached(&neg, 999_999) { + t.Fatal("expected negative max_tokens to disable the cap") + } +} + +// TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent +// verifies the <|channel>thought marker routes tokens to Thinking() and +// subsequent <|channel>assistant tokens land back in Content(). Covers RFC +// §11.6. +// +// The extractor skips whitespace between the marker and the channel name +// ("<|channel>thought ...") but preserves whitespace inside channel bodies — +// so "Hello " + thought block + " World" arrives as "Hello World" with +// both separating spaces retained. +func TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent(t *testing.T) { + ex := NewThinkingExtractor() + ex.Process(inference.Token{Text: "Hello"}) + ex.Process(inference.Token{Text: "<|channel>thought planning... "}) + ex.Process(inference.Token{Text: "<|channel>assistant World"}) + + content := ex.Content() + if content != "Hello World" { + t.Fatalf("expected content %q, got %q", "Hello World", content) + } + thinking := ex.Thinking() + if thinking == nil { + t.Fatal("expected thinking content to be captured") + } + if *thinking != " planning... " { + t.Fatalf("expected thinking %q, got %q", " planning... ", *thinking) + } +} + +// TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe documents the +// nil-safe accessors so middleware can call them defensively. +func TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe(t *testing.T) { + var ex *ThinkingExtractor + if got := ex.Content(); got != "" { + t.Fatalf("expected empty content on nil receiver, got %q", got) + } + if got := ex.Thinking(); got != nil { + t.Fatalf("expected nil thinking on nil receiver, got %v", got) + } +} diff --git a/openapi.go b/openapi.go index 9933117..efd3777 100644 --- a/openapi.go +++ b/openapi.go @@ -51,6 +51,8 @@ type SpecBuilder struct { ExternalDocsURL string PprofEnabled bool ExpvarEnabled bool + ChatCompletionsEnabled bool + ChatCompletionsPath string CacheEnabled bool CacheTTL string CacheMaxEntries int @@ -150,6 +152,12 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { if sb.ExpvarEnabled { spec["x-expvar-enabled"] = true } + if sb.ChatCompletionsEnabled { + spec["x-chat-completions-enabled"] = true + } + if path := sb.effectiveChatCompletionsPath(); path != "" { + spec["x-chat-completions-path"] = normaliseOpenAPIPath(path) + } if sb.CacheEnabled { spec["x-cache-enabled"] = true } @@ -2030,6 +2038,23 @@ func (sb *SpecBuilder) effectiveSSEPath() string { return "" } +// effectiveChatCompletionsPath returns the configured chat completions path or +// the RFC §11.1 default when chat completions is enabled without an explicit +// override. An explicit path also surfaces on its own so spec generation +// reflects configuration authored ahead of runtime activation. +// +// sb.effectiveChatCompletionsPath() // "/v1/chat/completions" when enabled +func (sb *SpecBuilder) effectiveChatCompletionsPath() string { + path := core.Trim(sb.ChatCompletionsPath) + if path != "" { + return path + } + if sb.ChatCompletionsEnabled { + return defaultChatCompletionsPath + } + return "" +} + // effectiveCacheTTL returns a normalised cache TTL when it parses to a // positive duration. func (sb *SpecBuilder) effectiveCacheTTL() string { diff --git a/openapi_test.go b/openapi_test.go index adb6225..71f5ab6 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -710,6 +710,85 @@ func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLTag(t *testing.T) { } } +// TestSpecBuilder_Good_ChatCompletionsEndpointExtension verifies the chat +// completions path surfaces as x-chat-completions-path/enabled extensions per +// RFC §11.1 so SDK consumers can auto-discover the local endpoint. +func TestSpecBuilder_Good_ChatCompletionsEndpointExtension(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + ChatCompletionsEnabled: true, + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if got := spec["x-chat-completions-enabled"]; got != true { + t.Fatalf("expected x-chat-completions-enabled=true, got %v", got) + } + if got := spec["x-chat-completions-path"]; got != "/v1/chat/completions" { + t.Fatalf("expected default chat completions path, got %v", got) + } +} + +// TestSpecBuilder_Good_ChatCompletionsHonoursCustomPath verifies an explicit +// path override surfaces through the spec extension. +func TestSpecBuilder_Good_ChatCompletionsHonoursCustomPath(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + ChatCompletionsEnabled: true, + ChatCompletionsPath: "/chat", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if got := spec["x-chat-completions-path"]; got != "/chat" { + t.Fatalf("expected custom chat completions path /chat, got %v", got) + } +} + +// TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled ensures the +// extension keys are absent when chat completions is not configured. +func TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if _, ok := spec["x-chat-completions-enabled"]; ok { + t.Fatal("expected x-chat-completions-enabled to be absent when disabled") + } + if _, ok := spec["x-chat-completions-path"]; ok { + t.Fatal("expected x-chat-completions-path to be absent when disabled") + } +} + func TestSpecBuilder_Good_EnabledTransportsUseDefaultPaths(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test", diff --git a/spec_builder_helper.go b/spec_builder_helper.go index 1be7551..f8a126e 100644 --- a/spec_builder_helper.go +++ b/spec_builder_helper.go @@ -77,6 +77,8 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder { builder.SSEEnabled = runtime.Transport.SSEEnabled builder.PprofEnabled = runtime.Transport.PprofEnabled builder.ExpvarEnabled = runtime.Transport.ExpvarEnabled + builder.ChatCompletionsEnabled = runtime.Transport.ChatCompletionsEnabled + builder.ChatCompletionsPath = runtime.Transport.ChatCompletionsPath builder.CacheEnabled = runtime.Cache.Enabled if runtime.Cache.TTL > 0 { diff --git a/spec_builder_helper_test.go b/spec_builder_helper_test.go index 8654ed8..bcaba36 100644 --- a/spec_builder_helper_test.go +++ b/spec_builder_helper_test.go @@ -497,6 +497,47 @@ func TestEngine_Good_TransportConfigReportsDisabledSwaggerWithoutUI(t *testing.T } } +// TestEngine_Good_TransportConfigReportsChatCompletions verifies that the +// chat completions resolver surfaces through TransportConfig so callers can +// discover the RFC §11.1 endpoint without rebuilding the engine. +func TestEngine_Good_TransportConfigReportsChatCompletions(t *testing.T) { + gin.SetMode(gin.TestMode) + + resolver := api.NewModelResolver() + e, err := api.New(api.WithChatCompletions(resolver)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if !cfg.ChatCompletionsEnabled { + t.Fatal("expected chat completions to be enabled") + } + if cfg.ChatCompletionsPath != "/v1/chat/completions" { + t.Fatalf("expected chat completions path /v1/chat/completions, got %q", cfg.ChatCompletionsPath) + } +} + +// TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride verifies +// that WithChatCompletionsPath surfaces through TransportConfig. +func TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + + resolver := api.NewModelResolver() + e, err := api.New( + api.WithChatCompletions(resolver), + api.WithChatCompletionsPath("/chat"), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cfg := e.TransportConfig() + if cfg.ChatCompletionsPath != "/chat" { + t.Fatalf("expected chat completions path /chat, got %q", cfg.ChatCompletionsPath) + } +} + func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/transport.go b/transport.go index 977361d..33cff4b 100644 --- a/transport.go +++ b/transport.go @@ -13,18 +13,20 @@ import core "dappco.re/go/core" // // cfg := api.TransportConfig{SwaggerPath: "/swagger", WSPath: "/ws"} type TransportConfig struct { - SwaggerEnabled bool - SwaggerPath string - GraphQLPath string - GraphQLEnabled bool - GraphQLPlayground bool - GraphQLPlaygroundPath string - WSEnabled bool - WSPath string - SSEEnabled bool - SSEPath string - PprofEnabled bool - ExpvarEnabled bool + SwaggerEnabled bool + SwaggerPath string + GraphQLPath string + GraphQLEnabled bool + GraphQLPlayground bool + GraphQLPlaygroundPath string + WSEnabled bool + WSPath string + SSEEnabled bool + SSEPath string + PprofEnabled bool + ExpvarEnabled bool + ChatCompletionsEnabled bool + ChatCompletionsPath string } // TransportConfig returns the currently configured transport metadata for the engine. @@ -41,11 +43,12 @@ func (e *Engine) TransportConfig() TransportConfig { } cfg := TransportConfig{ - SwaggerEnabled: e.swaggerEnabled, - WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil, - SSEEnabled: e.sseBroker != nil, - PprofEnabled: e.pprofEnabled, - ExpvarEnabled: e.expvarEnabled, + SwaggerEnabled: e.swaggerEnabled, + WSEnabled: e.wsHandler != nil || e.wsGinHandler != nil, + SSEEnabled: e.sseBroker != nil, + PprofEnabled: e.pprofEnabled, + ExpvarEnabled: e.expvarEnabled, + ChatCompletionsEnabled: e.chatCompletionsResolver != nil, } gql := e.GraphQLConfig() cfg.GraphQLEnabled = gql.Enabled @@ -64,6 +67,22 @@ func (e *Engine) TransportConfig() TransportConfig { if e.sseBroker != nil || core.Trim(e.ssePath) != "" { cfg.SSEPath = resolveSSEPath(e.ssePath) } + if e.chatCompletionsResolver != nil || core.Trim(e.chatCompletionsPath) != "" { + cfg.ChatCompletionsPath = resolveChatCompletionsPath(e.chatCompletionsPath) + } return cfg } + +// resolveChatCompletionsPath returns the configured chat completions path or +// the spec §11.1 default when no override has been provided. +func resolveChatCompletionsPath(path string) string { + path = core.Trim(path) + if path == "" { + return defaultChatCompletionsPath + } + if !core.HasPrefix(path, "/") { + path = "/" + path + } + return path +} diff --git a/webhook.go b/webhook.go index f865284..cd9fdb7 100644 --- a/webhook.go +++ b/webhook.go @@ -8,12 +8,68 @@ import ( "crypto/sha256" "encoding/hex" "net/http" + "slices" "strconv" "time" core "dappco.re/go/core" ) +// Canonical webhook event identifiers from RFC §6. These constants mirror the +// PHP-side event catalogue so Go senders and receivers reference the same +// namespaced strings. +// +// evt := api.WebhookEventLinkClicked // "link.clicked" +const ( + // WebhookEventWorkspaceCreated fires when a new workspace is provisioned. + WebhookEventWorkspaceCreated = "workspace.created" + // WebhookEventWorkspaceDeleted fires when a workspace is permanently removed. + WebhookEventWorkspaceDeleted = "workspace.deleted" + // WebhookEventSubscriptionChanged fires when a subscription plan changes. + WebhookEventSubscriptionChanged = "subscription.changed" + // WebhookEventSubscriptionCancelled fires when a subscription is cancelled. + WebhookEventSubscriptionCancelled = "subscription.cancelled" + // WebhookEventBiolinkCreated fires when a new biolink page is created. + WebhookEventBiolinkCreated = "biolink.created" + // WebhookEventLinkClicked fires when a tracked short link is clicked. + // High-volume event — recipients should opt in explicitly. + WebhookEventLinkClicked = "link.clicked" + // WebhookEventTicketCreated fires when a support ticket is opened. + WebhookEventTicketCreated = "ticket.created" + // WebhookEventTicketReplied fires when a support ticket receives a reply. + WebhookEventTicketReplied = "ticket.replied" +) + +// WebhookEvents returns the canonical list of webhook event identifiers +// documented in RFC §6. The order is stable: catalogue groups share a prefix +// (workspace → subscription → biolink → link → ticket). +// +// for _, evt := range api.WebhookEvents() { +// registry.Enable(evt) +// } +func WebhookEvents() []string { + return []string{ + WebhookEventWorkspaceCreated, + WebhookEventWorkspaceDeleted, + WebhookEventSubscriptionChanged, + WebhookEventSubscriptionCancelled, + WebhookEventBiolinkCreated, + WebhookEventLinkClicked, + WebhookEventTicketCreated, + WebhookEventTicketReplied, + } +} + +// IsKnownWebhookEvent reports whether the given event name is one of the +// canonical identifiers documented in RFC §6. +// +// if !api.IsKnownWebhookEvent(evt) { +// return errors.New("unknown webhook event") +// } +func IsKnownWebhookEvent(name string) bool { + return slices.Contains(WebhookEvents(), core.Trim(name)) +} + // WebhookSigner produces and verifies HMAC-SHA256 signatures over webhook // payloads. Spec §6: signed payloads (HMAC-SHA256) include a timestamp and // a signature header so receivers can validate authenticity, integrity, and diff --git a/webhook_test.go b/webhook_test.go index b9769cb..395b2f5 100644 --- a/webhook_test.go +++ b/webhook_test.go @@ -208,6 +208,73 @@ func TestWebhook_VerifyRequest_Ugly_NilRequestReturnsFalse(t *testing.T) { } } +// TestWebhook_WebhookEvents_Good_ListsCanonicalIdentifiers verifies the +// exported list of canonical event names documented in RFC §6. +func TestWebhook_WebhookEvents_Good_ListsCanonicalIdentifiers(t *testing.T) { + got := WebhookEvents() + want := []string{ + "workspace.created", + "workspace.deleted", + "subscription.changed", + "subscription.cancelled", + "biolink.created", + "link.clicked", + "ticket.created", + "ticket.replied", + } + if len(got) != len(want) { + t.Fatalf("expected %d events, got %d: %v", len(want), len(got), got) + } + for i, evt := range want { + if got[i] != evt { + t.Fatalf("index %d: expected %q, got %q", i, evt, got[i]) + } + } +} + +// TestWebhook_WebhookEvents_Good_ReturnsFreshSlice ensures the returned slice +// is safe to mutate — callers never corrupt the canonical list. +func TestWebhook_WebhookEvents_Good_ReturnsFreshSlice(t *testing.T) { + first := WebhookEvents() + first[0] = "mutated" + second := WebhookEvents() + if second[0] != "workspace.created" { + t.Fatalf("expected canonical list to be immutable, got %q", second[0]) + } +} + +// TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical confirms canonical +// identifiers pass the known-event predicate. +func TestWebhook_IsKnownWebhookEvent_Good_RecognisesCanonical(t *testing.T) { + for _, evt := range WebhookEvents() { + if !IsKnownWebhookEvent(evt) { + t.Fatalf("expected %q to be recognised", evt) + } + } +} + +// TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace ensures whitespace +// around user-supplied event names does not defeat the lookup. +func TestWebhook_IsKnownWebhookEvent_Good_TrimsWhitespace(t *testing.T) { + if !IsKnownWebhookEvent(" workspace.created ") { + t.Fatal("expected whitespace-padded identifier to be recognised") + } +} + +// TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown guards against silent +// acceptance of typos or out-of-catalogue event identifiers. +func TestWebhook_IsKnownWebhookEvent_Bad_RejectsUnknown(t *testing.T) { + if IsKnownWebhookEvent("") { + t.Fatal("expected empty string to be rejected") + } + if IsKnownWebhookEvent("workspace.created.extra") { + t.Fatal("expected suffixed event to be rejected") + } + if IsKnownWebhookEvent("Workspace.Created") { + t.Fatal("expected differently-cased identifier to be rejected") + } +} + // TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance exercises the // inclusive boundary where the timestamp falls right at the tolerance edge. func TestWebhook_IsTimestampValid_Good_UsesConfiguredTolerance(t *testing.T) {