33b0931b8c
fix chatbot limits and test * add tests
390 lines
13 KiB
Go
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())
|
|
})
|
|
}
|
|
}
|