Download cmd/server/config_test.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 30.8 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/server/config_test.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/cmd/server/config_test.go
-
curl -L -o config_test.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/cmd/server/config_test.go
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") | |
| } | |
| } | |