gcli2api / cmd /server /config_test.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
30.8 kB
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDefault(t *testing.T) {
c := Default()
if c.Listen != ":7863" {
t.Errorf("listen=%s", c.Listen)
}
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if c.SoftRateDur.Seconds() != 600 {
t.Errorf("soft=%v want 600s", c.SoftRateDur)
}
}
func TestPanelPackageDetailLimit(t *testing.T) {
c := Default()
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if c.Panel.PackageDetailLimit != 5 {
t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit)
}
configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`))
if err != nil {
t.Fatalf("parse configured limit: %v", err)
}
if configured.Panel.PackageDetailLimit != 8 {
t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit)
}
fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`))
if err != nil {
t.Fatalf("parse fallback limit: %v", err)
}
if fallback.Panel.PackageDetailLimit != 5 {
t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit)
}
}
func TestLoggingDefaults(t *testing.T) {
c := Default()
if err := c.normalize(); err != nil {
t.Fatal(err)
}
if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 {
t.Fatalf("logging defaults = %+v", c.Logging)
}
// 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。
if !c.Logging.RequestClientInfo {
t.Fatalf("request_client_info default = false, want true: %+v", c.Logging)
}
configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`))
if err != nil {
t.Fatal(err)
}
if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 {
t.Fatalf("configured logging = %+v", configured.Logging)
}
off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`))
if err != nil {
t.Fatal(err)
}
if off.Logging.RequestClientInfo {
t.Fatalf("explicit false ignored: %+v", off.Logging)
}
fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`))
if err != nil {
t.Fatal(err)
}
if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 {
t.Fatalf("logging fallback = %+v", fallback.Logging)
}
}
func TestLoadFile(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Listen != ":9999" || c.APIKey != "k" {
t.Errorf("c=%+v", c)
}
}
func TestEnvOverride(t *testing.T) {
t.Setenv("WB2A_LISTEN", ":7777")
t.Setenv("WB2A_API_KEY", "envkey")
c, err := Load("")
if err != nil {
t.Fatal(err)
}
if c.Listen != ":7777" || c.APIKey != "envkey" {
t.Errorf("c=%+v", c)
}
}
func TestBadDuration(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for bad duration")
}
}
func TestHardCreditKeyIgnored(t *testing.T) {
// 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatalf("hard_credit must be ignored (not validated): %v", err)
}
if c.SoftRateDur.Seconds() != 30 {
t.Errorf("soft_rate=%v want 30s", c.SoftRateDur)
}
}
func TestNewPoolConfigDefaults(t *testing.T) {
c := Default()
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if c.Pool.MaxInFlight != 3 {
t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight)
}
if c.Pool.BreakerThreshold != 3 {
t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold)
}
if c.BreakerCooldownDur.Minutes() != 30 {
t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur)
}
if c.BreakerCooldownMaxD.Hours() != 6 {
t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD)
}
if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 {
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
}
if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour {
t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
}
if c.SoftRateMaxDur.Hours() != 2 {
t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur)
}
if !c.SessionSticky.Enabled {
t.Error("session_sticky.enabled want true")
}
if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 {
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
}
if c.Upstash.URL != "" || c.Upstash.Token != "" {
t.Errorf("upstash default should be empty: %+v", c.Upstash)
}
}
func TestPoolConfigParsedFromFile(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{
"upstash":{"url":"https://foo.upstash.io","token":"tok"},
"pool":{
"max_in_flight":5,
"breaker_threshold":4,
"breaker_cooldown":"10m",
"breaker_cooldown_max":"2h",
"idle_weight_per_hour":0.7,
"idle_weight_max":8.0,
"prefer_expiring":false,
"expiring_soon":"72h"
},
"session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"}
}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" {
t.Errorf("upstash=%+v", c.Upstash)
}
if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 {
t.Errorf("pool=%+v", c.Pool)
}
if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 {
t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD)
}
if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 {
t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
}
if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour {
t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
}
if c.SessionSticky.Enabled {
t.Error("session_sticky.enabled want false from file")
}
if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 {
t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
}
}
func TestSoftRateMaxParsedFromFile(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.SoftRateDur.Minutes() != 5 {
t.Errorf("soft_rate=%v want 5m", c.SoftRateDur)
}
if c.SoftRateMaxDur.Minutes() != 45 {
t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur)
}
}
func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if !c.Pool.PreferExpiring {
t.Fatal("missing prefer_expiring must preserve default true")
}
}
func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" {
t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon)
}
}
func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) {
// 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.SoftRateMaxDur.Hours() != 2 {
t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur)
}
}
func TestBadSoftRateMax(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for bad soft_rate_max")
}
}
func TestBadBreakerCooldown(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for bad breaker_cooldown")
}
}
func TestUpstreamTimeoutDefaults(t *testing.T) {
// 默认:header 回落 timeout,idle 回落 300。
c := Default()
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if c.Upstream.TimeoutSeconds != 120 {
t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds)
}
if c.Upstream.HeaderTimeoutSeconds != 120 {
t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds)
}
if c.Upstream.IdleTimeoutSeconds != 300 {
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
}
}
func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) {
// 只设 timeout_seconds:header 回落同值,idle 回落 300。
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Upstream.HeaderTimeoutSeconds != 60 {
t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds)
}
if c.Upstream.IdleTimeoutSeconds != 300 {
t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
}
}
func TestUpstreamExplicitHeaderIdle(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Upstream.HeaderTimeoutSeconds != 30 {
t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds)
}
if c.Upstream.IdleTimeoutSeconds != 600 {
t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds)
}
}
func TestUpstreamEnvOverride(t *testing.T) {
t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45")
t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900")
c, err := Load("")
if err != nil {
t.Fatal(err)
}
if c.Upstream.HeaderTimeoutSeconds != 45 {
t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds)
}
if c.Upstream.IdleTimeoutSeconds != 900 {
t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds)
}
}
// TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。
func TestRetiredTravelIntervalKeyIgnored(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatalf("retired key should not fail load: %v", err)
}
if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 {
t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours)
}
}
// TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true:
// 老 config 不写这些键,行为必须与从前完全一致。
func TestScheduleEnabledByDefault(t *testing.T) {
c := Default()
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
t.Errorf("enabled defaults want true/true, got %v/%v",
c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled)
}
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
t.Errorf("travel/activity enabled defaults want true/true, got %v/%v",
c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled)
}
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours)
}
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours)
}
}
// TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态,
// 新开关缺省 true、新 hours 回落默认——对老配置零影响。
func TestScheduleLegacyConfigKeepsRunning(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
t.Errorf("legacy config must stay enabled: %+v", c.Schedule)
}
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
t.Errorf("new switches must default true on legacy config: %+v", c.Schedule)
}
if len(c.Schedule.CheckinHours) != 2 {
t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours)
}
// 新 hours 缺省 → 回落默认(非空)。
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours)
}
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
}
}
// TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到
// (issue #27 边界:此前无论怎么配小时都关不掉)。
func TestScheduleExplicitDisable(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled {
t.Errorf("want both disabled: %+v", c.Schedule)
}
// 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours)
}
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours)
}
}
// TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。
func TestScheduleTravelActivityExplicitDisable(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled {
t.Errorf("want travel/activity disabled: %+v", c.Schedule)
}
// 签到/保活开关缺省 true(互不干扰)。
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule)
}
// hours 仍回落默认。
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours)
}
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours)
}
}
// TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。
func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) {
cases := []struct{ body, wantSwitch string }{
{`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"},
{`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"},
{`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"},
{`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"},
}
for _, tc := range cases {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(tc.body), 0o600)
_, err := Load(fp)
if err == nil {
t.Fatalf("want error for %s", tc.body)
}
if !strings.Contains(err.Error(), tc.wantSwitch) {
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
}
}
}
// TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。
func TestScheduleTravelActivityExplicitHours(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours)
}
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 {
t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours)
}
}
// TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。
func TestScheduleDisableKeepsExplicitHours(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Schedule.CheckinEnabled {
t.Error("checkin should be disabled")
}
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 {
t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours)
}
}
// TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。
func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) {
cases := map[string]string{
"absent": `{}`,
"empty": `{"schedule":{}}`,
"null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`,
"emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(body), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours)
}
if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours)
}
if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours)
}
if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
}
if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
}
if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
}
})
}
}
// TestScheduleInvalidHourRejected 非法小时快速失败:指向正确的禁用开关,避免用户
// 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。
func TestScheduleInvalidHourRejected(t *testing.T) {
cases := []struct{ body, wantSwitch string }{
{`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"},
{`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"},
{`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"},
}
for _, tc := range cases {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(tc.body), 0o600)
_, err := Load(fp)
if err == nil {
t.Fatalf("want error for %s", tc.body)
}
if !strings.Contains(err.Error(), tc.wantSwitch) {
t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
}
}
}
func TestBadSessionTTL(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for bad session_sticky.ttl")
}
}
func TestWriteDefault(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建
key, err := WriteDefault(fp)
if err != nil {
t.Fatal(err)
}
// key 形如 sk-<24字符随机串>,两次生成不重复
if !strings.HasPrefix(key, "sk-") || len(key) < 20 {
t.Errorf("key=%q want sk-<random>", key)
}
if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key {
t.Errorf("two generated keys identical: %q", key)
}
// 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效
c, err := Load(fp)
if err != nil {
t.Fatalf("load generated config: %v", err)
}
if c.APIKey != key {
t.Errorf("api_key=%q want %q", c.APIKey, key)
}
if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" {
t.Errorf("generated defaults off: %+v", c)
}
if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled {
t.Errorf("generated schedule off: %+v", c.Schedule)
}
// 已存在的文件不覆盖:二次写入同一路径必须报错
if _, err := WriteDefault(fp); err == nil {
t.Error("WriteDefault must refuse to overwrite existing file")
}
}
func TestBalanceRefreshDefaults(t *testing.T) {
// 缺省:启用 + 30 分钟
c := Default()
if err := c.normalize(); err != nil {
t.Fatal(err)
}
if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute {
t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval)
}
// 显式配置 10 分钟
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600)
c2, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c2.BalanceRefreshInterval != 10*time.Minute {
t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval)
}
// 显式关闭:interval 归零(不启动)
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600)
c3, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c3.BalanceRefreshInterval != 0 {
t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval)
}
// 启用但 minutes<=0 → 回落默认 30
os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600)
c4, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c4.BalanceRefreshInterval != 5*time.Minute {
t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval)
}
}
// TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端
// 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。
func TestPromptDefaultPassthrough(t *testing.T) {
c, err := Load("")
if err != nil {
t.Fatal(err)
}
if c.Prompt.Mode != "passthrough" {
t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode)
}
// passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。
}
// TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。
func TestPromptExplicitPassthrough(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Prompt.Mode != "passthrough" {
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
}
if c.PromptText != "" {
t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText))
}
}
// TestPromptInvalidMode 非法 mode 启动报错。
func TestPromptInvalidMode(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for invalid prompt.mode")
}
}
// TestPromptFileMissing 文件路径非空但不存在 → 启动报错(fail fast)。
func TestPromptFileMissing(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600)
if _, err := Load(fp); err == nil {
t.Fatal("want error for missing prompt file")
}
}
// TestPromptFileOverride 自定义 file 覆盖内置默认。
func TestPromptFileOverride(t *testing.T) {
dir := t.TempDir()
pf := filepath.Join(dir, "my.md")
want := "我的自定义人格入口"
os.WriteFile(pf, []byte(want), 0o600)
cf := filepath.Join(dir, "c.json")
// 用 json.Marshal 拼路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。
cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}})
if err != nil {
t.Fatal(err)
}
os.WriteFile(cf, cfgJSON, 0o600)
c, err := Load(cf)
if err != nil {
t.Fatal(err)
}
if c.PromptText != want {
t.Errorf("PromptText=%q want %q", c.PromptText, want)
}
}
// TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。
func TestPromptEnvOverride(t *testing.T) {
t.Setenv("WB2A_PROMPT_MODE", "passthrough")
c, err := Load("")
if err != nil {
t.Fatal(err)
}
if c.Prompt.Mode != "passthrough" {
t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
}
}
// TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。
func TestPromptLegacyConfigNoImpact(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Prompt.Mode != "passthrough" {
t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode)
}
if c.Listen != ":9999" {
t.Errorf("listen=%q", c.Listen)
}
}
// TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效,
// 缺省空串保持现状(headers 层回落到 clientUA)。
func TestUpstreamUserAgentConfig(t *testing.T) {
// JSON 配置
dir := t.TempDir()
fp := filepath.Join(dir, "c.json")
os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600)
c, err := Load(fp)
if err != nil {
t.Fatal(err)
}
if c.Upstream.UserAgent != "WorkBuddy/1.2.3" {
t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent)
}
// 缺省为空
if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" {
t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err)
}
// env 覆盖
t.Setenv("WB2A_USER_AGENT", "EnvAgent/9")
c3, err := Load("")
if err != nil {
t.Fatal(err)
}
if c3.Upstream.UserAgent != "EnvAgent/9" {
t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent)
}
}
// TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。
// 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。
// 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。
func TestLoadConfigPathIsDirectory(t *testing.T) {
dir := t.TempDir()
asDir := filepath.Join(dir, "config.json")
if err := os.Mkdir(asDir, 0o755); err != nil {
t.Fatal(err)
}
_, err := Load(asDir)
if err == nil {
t.Fatal("want error when config path is a directory")
}
msg := err.Error()
if !strings.Contains(msg, "是目录") {
t.Errorf("error should explain it is a directory: %v", err)
}
if !strings.Contains(msg, "config.example.json") {
t.Errorf("error should suggest the fix (cp config.example.json): %v", err)
}
}
// TestServerReadTimeout 入站读取上限(issue #100):空值回落默认 300s;
// "0" = 显式不限制(0 是合法值不回落);负值 fail fast(静默钳 0 会把保护悄悄关掉)。
func TestServerReadTimeout(t *testing.T) {
c := Default()
if c.Server.ReadTimeout != "300s" {
t.Errorf("default read_timeout=%q want 300s", c.Server.ReadTimeout)
}
if err := c.normalize(); err != nil {
t.Fatalf("normalize: %v", err)
}
if c.ServerReadTimeoutDur != 300*time.Second {
t.Errorf("default dur=%v want 300s", c.ServerReadTimeoutDur)
}
c = Default()
c.Server.ReadTimeout = "" // 显式清空 = 未配置 → 回落默认
if err := c.normalize(); err != nil {
t.Fatalf("normalize empty: %v", err)
}
if c.ServerReadTimeoutDur != 300*time.Second {
t.Errorf("empty dur=%v want 300s", c.ServerReadTimeoutDur)
}
c = Default()
c.Server.ReadTimeout = "0" // 显式 0 = 不限制(http.Server ReadTimeout 0 即无超时)
if err := c.normalize(); err != nil {
t.Fatalf("normalize zero: %v", err)
}
if c.ServerReadTimeoutDur != 0 {
t.Errorf("zero dur=%v want 0", c.ServerReadTimeoutDur)
}
c = Default()
c.Server.ReadTimeout = "-5s"
if err := c.normalize(); err == nil {
t.Error("negative read_timeout should fail fast")
}
c = Default()
c.Server.ReadTimeout = "bogus"
if err := c.normalize(); err == nil {
t.Error("unparsable read_timeout should fail fast")
}
}