Files
query-orchestration/internal/serviceconfig/chatbot/config_test.go
T
Jay Brown 33b0931b8c Merged in feature/chatbot-limits (pull request #228)
fix chatbot limits and test

* add tests
2026-05-28 19:23:39 +00:00

390 lines
13 KiB
Go

// Package chatbot — Milestone 1 §1 chatbot config tests.
//
// These tests are the authoritative behavioral spec for the chatbot
// configuration block. They predate the production code by one TDD cycle:
// when the file is first introduced, every test below fails to compile
// because the production type and its Validate method do not yet exist.
// That compile failure is the failing-state contract; backend-eng adds
// `config.go` to make the symbols resolve and the assertions pass.
//
// Plan reference: plans/chatbot_plan_codex.v10.md §8 (Configuration).
// - struct fields: ServiceURL, APIKey, RequestTimeoutSeconds,
// MaxTurnChars, RecentTurns.
// - env tags: SERVICE_URL/API_KEY required+notEmpty; the three numerics
// have envDefault values 60, 2000, 5.
// - Validate(): RequestTimeoutSeconds >= 1, MaxTurnChars >= 1,
// RecentTurns in [1,5].
//
// No mocks; no t.Skip; no commented-out tests. The env loader is fed an
// explicit map per case via env.ParseWithOptions so OS env state cannot
// leak between cases.
package chatbot_test
import (
"strings"
"testing"
"queryorchestration/internal/serviceconfig/chatbot"
"github.com/caarlos0/env/v11"
"github.com/stretchr/testify/require"
)
// requiredEnvBase is the minimal env map satisfying the two `notEmpty`
// fields. Tests that exercise validation extend this map with the field
// they want to perturb. Tests that exercise loader-level rejection (e.g.
// missing SERVICE_URL) construct their own map.
func requiredEnvBase() map[string]string {
return map[string]string{
"CHATBOT_SERVICE_URL": "http://chatbot.local:8000",
"CHATBOT_API_KEY": `{"AGENT_SERVICE_API_KEY":"value"}`,
}
}
// loadConfig builds a fresh ChatbotConfig from an explicit env map. The
// rest of the project uses env.Parse against the OS environment; tests
// cannot rely on that because parallel cases would race on the same
// process-wide env. ParseWithOptions+Environment is the documented
// caarlos0/env hook for deterministic, isolated parsing.
func loadConfig(t *testing.T, envMap map[string]string) (chatbot.ChatbotConfig, error) {
t.Helper()
cfg := chatbot.ChatbotConfig{}
err := env.ParseWithOptions(&cfg, env.Options{Environment: envMap})
return cfg, err
}
// TestChatbotConfig_Defaults exercises §8: with only the two required
// vars set, the three numerics fall back to their plan-mandated defaults
// (60s, 2000 chars, 5 recent turns) and Validate accepts the result.
func TestChatbotConfig_Defaults(t *testing.T) {
t.Parallel()
cfg, err := loadConfig(t, requiredEnvBase())
require.NoError(t, err, "loader must accept the minimal required env")
require.Equal(t, "http://chatbot.local:8000", cfg.ServiceURL)
require.Equal(t, `{"AGENT_SERVICE_API_KEY":"value"}`, cfg.APIKey)
require.Equal(t, 60, cfg.RequestTimeoutSeconds,
"RequestTimeoutSeconds default must be 60 per plan §8")
require.Equal(t, 2000, cfg.MaxTurnChars,
"MaxTurnChars default must be 2000 per plan §8")
require.Equal(t, 5, cfg.RecentTurns,
"RecentTurns default must be 5 per plan §8")
require.NoError(t, cfg.Validate(),
"defaults must validate; this is the post-load happy path used by main()")
}
// TestChatbotConfig_Validate is the table-driven coverage of every
// boundary called out in the M1 dispatch plus the explicit cases the
// team-lead listed. Each row sets exactly one field at a time so the
// failure message points at the perturbed field unambiguously.
func TestChatbotConfig_Validate(t *testing.T) {
t.Parallel()
type tc struct {
name string
// envOverride is layered onto requiredEnvBase. nil means "no
// override". To force a required field empty, set the value to
// the empty string and the test will route the case through the
// loader-rejection branch (wantLoaderErr true).
envOverride map[string]string
// loaderEnvOnly bypasses requiredEnvBase entirely; used for the
// missing-required cases where adding to a base would mask the
// missingness.
loaderEnvOnly map[string]string
// wantLoaderErr expects env.ParseWithOptions to reject before
// Validate is even called.
wantLoaderErr bool
// wantValidateErr expects Validate to reject after the loader
// accepts the parse.
wantValidateErr bool
// wantContains is a fragment that must appear in the error
// returned by whichever stage rejected.
wantContains string
}
cases := []tc{
{
name: "missing_service_url",
loaderEnvOnly: map[string]string{"CHATBOT_API_KEY": `k`},
wantLoaderErr: true,
wantContains: "CHATBOT_SERVICE_URL",
},
{
name: "empty_service_url",
loaderEnvOnly: map[string]string{
"CHATBOT_SERVICE_URL": "",
"CHATBOT_API_KEY": "k",
},
wantLoaderErr: true,
wantContains: "CHATBOT_SERVICE_URL",
},
{
name: "missing_api_key",
loaderEnvOnly: map[string]string{"CHATBOT_SERVICE_URL": "http://x"},
wantLoaderErr: true,
wantContains: "CHATBOT_API_KEY",
},
{
name: "empty_api_key",
loaderEnvOnly: map[string]string{
"CHATBOT_SERVICE_URL": "http://x",
"CHATBOT_API_KEY": "",
},
wantLoaderErr: true,
wantContains: "CHATBOT_API_KEY",
},
{
name: "request_timeout_zero_rejected",
envOverride: map[string]string{
"CHATBOT_REQUEST_TIMEOUT_SECONDS": "0",
},
wantValidateErr: true,
wantContains: "RequestTimeoutSeconds",
},
{
name: "request_timeout_negative_rejected",
envOverride: map[string]string{
"CHATBOT_REQUEST_TIMEOUT_SECONDS": "-1",
},
wantValidateErr: true,
wantContains: "RequestTimeoutSeconds",
},
{
name: "request_timeout_one_accepted",
envOverride: map[string]string{
"CHATBOT_REQUEST_TIMEOUT_SECONDS": "1",
},
},
{
name: "max_turn_chars_zero_rejected",
envOverride: map[string]string{
"CHATBOT_MAX_TURN_CHARS": "0",
},
wantValidateErr: true,
wantContains: "MaxTurnChars",
},
{
name: "max_turn_chars_negative_rejected",
envOverride: map[string]string{
"CHATBOT_MAX_TURN_CHARS": "-100",
},
wantValidateErr: true,
wantContains: "MaxTurnChars",
},
{
name: "max_turn_chars_one_accepted",
envOverride: map[string]string{
"CHATBOT_MAX_TURN_CHARS": "1",
},
},
{
name: "recent_turns_zero_rejected",
envOverride: map[string]string{
"CHATBOT_RECENT_TURNS": "0",
},
wantValidateErr: true,
wantContains: "RecentTurns",
},
{
name: "recent_turns_one_accepted",
envOverride: map[string]string{
"CHATBOT_RECENT_TURNS": "1",
},
},
{
name: "recent_turns_five_accepted",
envOverride: map[string]string{
"CHATBOT_RECENT_TURNS": "5",
},
},
{
name: "recent_turns_six_rejected",
envOverride: map[string]string{
"CHATBOT_RECENT_TURNS": "6",
},
wantValidateErr: true,
wantContains: "RecentTurns",
},
{
name: "recent_turns_negative_rejected",
envOverride: map[string]string{
"CHATBOT_RECENT_TURNS": "-1",
},
wantValidateErr: true,
wantContains: "RecentTurns",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
// Build the env map for this case. loaderEnvOnly wins, then
// envOverride layered on top of requiredEnvBase, otherwise
// just requiredEnvBase.
var envMap map[string]string
switch {
case c.loaderEnvOnly != nil:
envMap = c.loaderEnvOnly
default:
envMap = requiredEnvBase()
for k, v := range c.envOverride {
envMap[k] = v
}
}
cfg, loaderErr := loadConfig(t, envMap)
if c.wantLoaderErr {
require.Error(t, loaderErr,
"loader must reject this env; got cfg=%+v", cfg)
if c.wantContains != "" {
require.True(t,
strings.Contains(loaderErr.Error(), c.wantContains),
"loader error %q must contain %q", loaderErr.Error(), c.wantContains)
}
return
}
require.NoError(t, loaderErr,
"loader must accept this env; got %v", loaderErr)
validateErr := cfg.Validate()
if c.wantValidateErr {
require.Error(t, validateErr,
"Validate must reject; cfg=%+v", cfg)
if c.wantContains != "" {
require.True(t,
strings.Contains(validateErr.Error(), c.wantContains),
"validate error %q must contain %q", validateErr.Error(), c.wantContains)
}
return
}
require.NoError(t, validateErr,
"Validate must accept; cfg=%+v", cfg)
})
}
}
// TestChatbotConfig_MaxTurnChars_NonDefault_Loaded covers Gap 1: prior
// to this test no case asserted that CHATBOT_MAX_TURN_CHARS=5000 (a
// non-default value) was both loaded by env.ParseWithOptions and
// accepted by Validate(). The plan permits any operator-chosen value
// >= 1; this test pins that contract for a representative larger cap
// so a regression that hard-codes the default cannot pass silently.
func TestChatbotConfig_MaxTurnChars_NonDefault_Loaded(t *testing.T) {
t.Parallel()
envMap := requiredEnvBase()
envMap["CHATBOT_MAX_TURN_CHARS"] = "5000"
cfg, err := loadConfig(t, envMap)
require.NoError(t, err, "loader must accept CHATBOT_MAX_TURN_CHARS=5000")
require.Equal(t, 5000, cfg.MaxTurnChars,
"non-default CHATBOT_MAX_TURN_CHARS must be honored by the loader")
require.NoError(t, cfg.Validate(),
"Validate must accept a non-default MaxTurnChars within the legal range")
}
// TestChatbotConfig_MaxTurnChars_AtOpenAPILimit_Accepted locks the
// upper-edge boundary: MaxTurnChars=65536 (exactly the OpenAPI
// prompt.maxLength) must load and validate. Pinned alongside the
// rejection cases so an off-by-one in the ceiling check fails loudly.
func TestChatbotConfig_MaxTurnChars_AtOpenAPILimit_Accepted(t *testing.T) {
t.Parallel()
envMap := requiredEnvBase()
envMap["CHATBOT_MAX_TURN_CHARS"] = "65536"
cfg, err := loadConfig(t, envMap)
require.NoError(t, err, "loader must accept CHATBOT_MAX_TURN_CHARS=65536")
require.Equal(t, chatbot.MaxTurnCharsCeiling, cfg.MaxTurnChars,
"loaded value must equal the documented ceiling")
require.NoError(t, cfg.Validate(),
"Validate must accept MaxTurnChars exactly at MaxTurnCharsCeiling")
}
// TestChatbotConfig_MaxTurnChars_AboveOpenAPILimit_Rejected covers Gap 2:
// the OpenAPI schema (serviceAPIs/queryAPI.yaml: prompt.maxLength=65536)
// caps the prompt body at 65,536 runes. If CHATBOT_MAX_TURN_CHARS is set
// above that ceiling the system enters an inconsistent state — the
// generated request validator silently truncates the cap to 65,536 while
// the service-layer guard believes a higher cap is in force. Validate()
// MUST refuse this configuration so the inconsistency is caught at boot,
// not on the first oversized prompt. Expected to FAIL until backend-eng
// adds an upper-bound check tied to the OpenAPI maxLength.
func TestChatbotConfig_MaxTurnChars_AboveOpenAPILimit_Rejected(t *testing.T) {
t.Parallel()
envMap := requiredEnvBase()
envMap["CHATBOT_MAX_TURN_CHARS"] = "65537"
cfg, err := loadConfig(t, envMap)
require.NoError(t, err, "loader must parse the integer value")
validateErr := cfg.Validate()
require.Error(t, validateErr,
"Validate must reject MaxTurnChars > OpenAPI prompt.maxLength=65536; "+
"otherwise oversized prompts are silently rejected by the schema validator "+
"with no operator-visible signal that the configured cap is unreachable")
require.True(t,
strings.Contains(validateErr.Error(), "MaxTurnChars"),
"error %q must name the offending field for actionable diagnostics",
validateErr.Error())
}
// TestChatbotConfig_MaxTurnChars_GrosslyOverLimit_Rejected covers Gap 3:
// Validate() currently only enforces MaxTurnChars >= 1 — there is no
// upper bound at all. An operator typo such as CHATBOT_MAX_TURN_CHARS=
// 10000000 loads cleanly today, silently exceeds the OpenAPI ceiling,
// and creates the inconsistency described in Gap 2 at scale. This test
// asserts that any value above the documented ceiling is rejected.
// Expected to FAIL until backend-eng adds the upper-bound check.
func TestChatbotConfig_MaxTurnChars_GrosslyOverLimit_Rejected(t *testing.T) {
t.Parallel()
envMap := requiredEnvBase()
envMap["CHATBOT_MAX_TURN_CHARS"] = "10000000"
cfg, err := loadConfig(t, envMap)
require.NoError(t, err, "loader must parse the integer value")
validateErr := cfg.Validate()
require.Error(t, validateErr,
"Validate must reject grossly over-limit MaxTurnChars to fail-fast on operator typos")
require.True(t,
strings.Contains(validateErr.Error(), "MaxTurnChars"),
"error %q must name the offending field",
validateErr.Error())
}
// TestChatbotConfig_InflightGraceSeconds locks down the derived value
// from plan §8: "InflightGraceSeconds = 2 * RequestTimeoutSeconds". The
// helper is consumed by AddTurn (M4) to size the grace window passed to
// AbandonExpiredInflightForOwner; M1 ships only the spec-grade contract
// so M3/M4 inherit a known-good multiplier. Boundary cases mirror the
// validate boundaries: minimum legal value (1), default (60), and a
// large operator-set value (600) to defeat any accidental constant.
func TestChatbotConfig_InflightGraceSeconds(t *testing.T) {
t.Parallel()
cases := []struct {
name string
requestTimeout int
want int
}{
{name: "min_one_second", requestTimeout: 1, want: 2},
{name: "default_sixty_seconds", requestTimeout: 60, want: 120},
{name: "large_six_hundred_seconds", requestTimeout: 600, want: 1200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
cfg := chatbot.ChatbotConfig{RequestTimeoutSeconds: c.requestTimeout}
require.Equal(t, c.want, cfg.InflightGraceSeconds(),
"plan §8: InflightGraceSeconds = 2 * RequestTimeoutSeconds; "+
"with RequestTimeoutSeconds=%d expected %d, got %d",
c.requestTimeout, c.want, cfg.InflightGraceSeconds())
})
}
}