mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-01 23:15:27 +08:00
761 lines
24 KiB
Go
761 lines
24 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ─── GetModelConfig edge cases ───────────────────────────────────────
|
|
|
|
func TestGetModelConfigDeepSeekChat(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-chat")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-chat")
|
|
}
|
|
if thinking || search {
|
|
t.Fatalf("expected no thinking/search for deepseek-chat, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekReasoner(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-reasoner")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-reasoner")
|
|
}
|
|
if !thinking || search {
|
|
t.Fatalf("expected thinking=true search=false, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekChatSearch(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-chat-search")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-chat-search")
|
|
}
|
|
if thinking || !search {
|
|
t.Fatalf("expected thinking=false search=true, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekReasonerSearch(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-reasoner-search")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-reasoner-search")
|
|
}
|
|
if !thinking || !search {
|
|
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekExpertChat(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-expert-chat")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-expert-chat")
|
|
}
|
|
if thinking || search {
|
|
t.Fatalf("expected no thinking/search for deepseek-expert-chat, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekExpertReasonerSearch(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-expert-reasoner-search")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-expert-reasoner-search")
|
|
}
|
|
if !thinking || !search {
|
|
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigDeepSeekVisionReasonerSearch(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("deepseek-vision-reasoner-search")
|
|
if !ok {
|
|
t.Fatal("expected ok for deepseek-vision-reasoner-search")
|
|
}
|
|
if !thinking || !search {
|
|
t.Fatalf("expected both true, got thinking=%v search=%v", thinking, search)
|
|
}
|
|
}
|
|
|
|
func TestGetModelTypeDefaultExpertAndVision(t *testing.T) {
|
|
defaultType, ok := GetModelType("deepseek-chat")
|
|
if !ok || defaultType != "default" {
|
|
t.Fatalf("expected default model_type, got ok=%v model_type=%q", ok, defaultType)
|
|
}
|
|
expertType, ok := GetModelType("deepseek-expert-chat")
|
|
if !ok || expertType != "expert" {
|
|
t.Fatalf("expected expert model_type, got ok=%v model_type=%q", ok, expertType)
|
|
}
|
|
visionType, ok := GetModelType("deepseek-vision-chat")
|
|
if !ok || visionType != "vision" {
|
|
t.Fatalf("expected vision model_type, got ok=%v model_type=%q", ok, visionType)
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigCaseInsensitive(t *testing.T) {
|
|
thinking, search, ok := GetModelConfig("DeepSeek-Chat")
|
|
if !ok {
|
|
t.Fatal("expected ok for case-insensitive deepseek-chat")
|
|
}
|
|
if thinking || search {
|
|
t.Fatalf("expected no thinking/search for case-insensitive deepseek-chat")
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigUnknownModel(t *testing.T) {
|
|
_, _, ok := GetModelConfig("gpt-4")
|
|
if ok {
|
|
t.Fatal("expected not ok for unknown model")
|
|
}
|
|
}
|
|
|
|
func TestGetModelConfigEmpty(t *testing.T) {
|
|
_, _, ok := GetModelConfig("")
|
|
if ok {
|
|
t.Fatal("expected not ok for empty model")
|
|
}
|
|
}
|
|
|
|
// ─── lower function ──────────────────────────────────────────────────
|
|
|
|
func TestLowerFunction(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"Hello", "hello"},
|
|
{"ALLCAPS", "allcaps"},
|
|
{"already-lower", "already-lower"},
|
|
{"Mixed-CASE-123", "mixed-case-123"},
|
|
{"", ""},
|
|
}
|
|
for _, tc := range tests {
|
|
got := lower(tc.input)
|
|
if got != tc.expected {
|
|
t.Errorf("lower(%q) = %q, want %q", tc.input, got, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Config.MarshalJSON / UnmarshalJSON roundtrip ────────────────────
|
|
|
|
func TestConfigJSONRoundtrip(t *testing.T) {
|
|
trueVal := true
|
|
falseVal := false
|
|
cfg := Config{
|
|
Keys: []string{"key1", "key2"},
|
|
Accounts: []Account{{Email: "user@example.com", Password: "pass", Token: "tok"}},
|
|
ClaudeMapping: map[string]string{
|
|
"fast": "deepseek-chat",
|
|
"slow": "deepseek-reasoner",
|
|
},
|
|
AutoDelete: AutoDeleteConfig{
|
|
Mode: "single",
|
|
},
|
|
HistorySplit: HistorySplitConfig{
|
|
Enabled: &trueVal,
|
|
TriggerAfterTurns: func() *int { v := 2; return &v }(),
|
|
},
|
|
Runtime: RuntimeConfig{
|
|
TokenRefreshIntervalHours: 12,
|
|
},
|
|
Compat: CompatConfig{
|
|
WideInputStrictOutput: &trueVal,
|
|
StripReferenceMarkers: &falseVal,
|
|
},
|
|
VercelSyncHash: "hash123",
|
|
VercelSyncTime: 1234567890,
|
|
AdditionalFields: map[string]any{
|
|
"custom_field": "custom_value",
|
|
},
|
|
}
|
|
|
|
data, err := cfg.MarshalJSON()
|
|
if err != nil {
|
|
t.Fatalf("marshal error: %v", err)
|
|
}
|
|
|
|
var decoded Config
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
|
|
if len(decoded.Keys) != 2 || decoded.Keys[0] != "key1" {
|
|
t.Fatalf("unexpected keys: %#v", decoded.Keys)
|
|
}
|
|
if len(decoded.Accounts) != 1 || decoded.Accounts[0].Email != "user@example.com" {
|
|
t.Fatalf("unexpected accounts: %#v", decoded.Accounts)
|
|
}
|
|
if decoded.ClaudeMapping["fast"] != "deepseek-chat" {
|
|
t.Fatalf("unexpected claude mapping: %#v", decoded.ClaudeMapping)
|
|
}
|
|
if decoded.Runtime.TokenRefreshIntervalHours != 12 {
|
|
t.Fatalf("unexpected runtime refresh interval: %#v", decoded.Runtime.TokenRefreshIntervalHours)
|
|
}
|
|
if decoded.AutoDelete.Mode != "single" {
|
|
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
|
|
}
|
|
if decoded.HistorySplit.Enabled == nil || !*decoded.HistorySplit.Enabled {
|
|
t.Fatalf("unexpected history split enabled: %#v", decoded.HistorySplit.Enabled)
|
|
}
|
|
if decoded.HistorySplit.TriggerAfterTurns == nil || *decoded.HistorySplit.TriggerAfterTurns != 2 {
|
|
t.Fatalf("unexpected history split trigger_after_turns: %#v", decoded.HistorySplit.TriggerAfterTurns)
|
|
}
|
|
if decoded.Compat.WideInputStrictOutput == nil || !*decoded.Compat.WideInputStrictOutput {
|
|
t.Fatalf("unexpected compat wide_input_strict_output: %#v", decoded.Compat.WideInputStrictOutput)
|
|
}
|
|
if decoded.Compat.StripReferenceMarkers == nil || *decoded.Compat.StripReferenceMarkers {
|
|
t.Fatalf("unexpected compat strip_reference_markers: %#v", decoded.Compat.StripReferenceMarkers)
|
|
}
|
|
if decoded.VercelSyncHash != "hash123" {
|
|
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
|
|
}
|
|
if decoded.AdditionalFields["custom_field"] != "custom_value" {
|
|
t.Fatalf("unexpected additional fields: %#v", decoded.AdditionalFields)
|
|
}
|
|
}
|
|
|
|
func TestAutoDeleteModeResolution(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg AutoDeleteConfig
|
|
want string
|
|
}{
|
|
{name: "default", cfg: AutoDeleteConfig{}, want: "none"},
|
|
{name: "legacy all", cfg: AutoDeleteConfig{Sessions: true}, want: "all"},
|
|
{name: "single", cfg: AutoDeleteConfig{Mode: "single"}, want: "single"},
|
|
{name: "all", cfg: AutoDeleteConfig{Mode: "all"}, want: "all"},
|
|
{name: "none", cfg: AutoDeleteConfig{Mode: "none"}, want: "none"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
store := &Store{cfg: Config{AutoDelete: tc.cfg}}
|
|
if got := store.AutoDeleteMode(); got != tc.want {
|
|
t.Fatalf("AutoDeleteMode()=%q want=%q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigUnmarshalJSONPreservesUnknownFields(t *testing.T) {
|
|
raw := `{"keys":["k1"],"accounts":[],"my_custom_field":"hello","number_field":42}`
|
|
var cfg Config
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
t.Fatalf("unmarshal error: %v", err)
|
|
}
|
|
if cfg.AdditionalFields["my_custom_field"] != "hello" {
|
|
t.Fatalf("expected custom field preserved, got %#v", cfg.AdditionalFields)
|
|
}
|
|
// number_field should also be preserved
|
|
if cfg.AdditionalFields["number_field"] != float64(42) {
|
|
t.Fatalf("expected number field preserved, got %#v", cfg.AdditionalFields["number_field"])
|
|
}
|
|
}
|
|
|
|
// ─── Config.Clone ────────────────────────────────────────────────────
|
|
|
|
func TestConfigCloneIsDeepCopy(t *testing.T) {
|
|
falseVal := false
|
|
trueVal := true
|
|
turns := 2
|
|
cfg := Config{
|
|
Keys: []string{"key1"},
|
|
Accounts: []Account{{Email: "user@test.com", Token: "token"}},
|
|
ClaudeMapping: map[string]string{
|
|
"fast": "deepseek-chat",
|
|
},
|
|
Compat: CompatConfig{
|
|
StripReferenceMarkers: &falseVal,
|
|
},
|
|
HistorySplit: HistorySplitConfig{
|
|
Enabled: &trueVal,
|
|
TriggerAfterTurns: &turns,
|
|
},
|
|
AdditionalFields: map[string]any{"custom": "value"},
|
|
}
|
|
|
|
cloned := cfg.Clone()
|
|
|
|
// Modify original
|
|
cfg.Keys[0] = "modified"
|
|
cfg.Accounts[0].Email = "modified@test.com"
|
|
cfg.ClaudeMapping["fast"] = "modified-model"
|
|
if cfg.Compat.StripReferenceMarkers != nil {
|
|
*cfg.Compat.StripReferenceMarkers = true
|
|
}
|
|
if cfg.HistorySplit.Enabled != nil {
|
|
*cfg.HistorySplit.Enabled = false
|
|
}
|
|
if cfg.HistorySplit.TriggerAfterTurns != nil {
|
|
*cfg.HistorySplit.TriggerAfterTurns = 5
|
|
}
|
|
|
|
// Cloned should not be affected
|
|
if cloned.Keys[0] != "key1" {
|
|
t.Fatalf("clone keys was affected by original change: %#v", cloned.Keys)
|
|
}
|
|
if cloned.Accounts[0].Email != "user@test.com" {
|
|
t.Fatalf("clone accounts was affected: %#v", cloned.Accounts)
|
|
}
|
|
if cloned.ClaudeMapping["fast"] != "deepseek-chat" {
|
|
t.Fatalf("clone claude mapping was affected: %#v", cloned.ClaudeMapping)
|
|
}
|
|
if cloned.Compat.StripReferenceMarkers == nil || *cloned.Compat.StripReferenceMarkers {
|
|
t.Fatalf("clone compat was affected: %#v", cloned.Compat.StripReferenceMarkers)
|
|
}
|
|
if cloned.HistorySplit.Enabled == nil || !*cloned.HistorySplit.Enabled {
|
|
t.Fatalf("clone history split enabled was affected: %#v", cloned.HistorySplit.Enabled)
|
|
}
|
|
if cloned.HistorySplit.TriggerAfterTurns == nil || *cloned.HistorySplit.TriggerAfterTurns != 2 {
|
|
t.Fatalf("clone history split trigger was affected: %#v", cloned.HistorySplit.TriggerAfterTurns)
|
|
}
|
|
}
|
|
|
|
func TestConfigCloneNilMaps(t *testing.T) {
|
|
cfg := Config{
|
|
Keys: []string{"k"},
|
|
Accounts: nil,
|
|
}
|
|
cloned := cfg.Clone()
|
|
if len(cloned.Keys) != 1 {
|
|
t.Fatalf("unexpected keys length: %d", len(cloned.Keys))
|
|
}
|
|
if cloned.Accounts != nil {
|
|
t.Fatalf("expected nil accounts in clone, got %#v", cloned.Accounts)
|
|
}
|
|
}
|
|
|
|
// ─── Account.Identifier edge cases ───────────────────────────────────
|
|
|
|
func TestAccountIdentifierPreferenceMobileOverToken(t *testing.T) {
|
|
acc := Account{Mobile: "13800138000", Token: "tok"}
|
|
if acc.Identifier() != "+8613800138000" {
|
|
t.Fatalf("expected mobile identifier, got %q", acc.Identifier())
|
|
}
|
|
}
|
|
|
|
func TestAccountIdentifierPreferenceEmailOverMobile(t *testing.T) {
|
|
acc := Account{Email: "user@test.com", Mobile: "13800138000"}
|
|
if acc.Identifier() != "user@test.com" {
|
|
t.Fatalf("expected email identifier, got %q", acc.Identifier())
|
|
}
|
|
}
|
|
|
|
func TestAccountIdentifierEmptyAccount(t *testing.T) {
|
|
acc := Account{}
|
|
if acc.Identifier() != "" {
|
|
t.Fatalf("expected empty identifier for empty account, got %q", acc.Identifier())
|
|
}
|
|
}
|
|
|
|
// ─── normalizeConfigInput ────────────────────────────────────────────
|
|
|
|
func TestNormalizeConfigInputStripsQuotes(t *testing.T) {
|
|
got := normalizeConfigInput(`"base64:abc"`)
|
|
if strings.HasPrefix(got, `"`) || strings.HasSuffix(got, `"`) {
|
|
t.Fatalf("expected quotes stripped, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeConfigInputStripsSingleQuotes(t *testing.T) {
|
|
got := normalizeConfigInput("'some-value'")
|
|
if strings.HasPrefix(got, "'") || strings.HasSuffix(got, "'") {
|
|
t.Fatalf("expected single quotes stripped, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeConfigInputTrimsWhitespace(t *testing.T) {
|
|
got := normalizeConfigInput(" hello ")
|
|
if got != "hello" {
|
|
t.Fatalf("expected trimmed, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ─── parseConfigString edge cases ────────────────────────────────────
|
|
|
|
func TestParseConfigStringPlainJSON(t *testing.T) {
|
|
cfg, err := parseConfigString(`{"keys":["k1"],"accounts":[]}`)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Keys) != 1 || cfg.Keys[0] != "k1" {
|
|
t.Fatalf("unexpected keys: %#v", cfg.Keys)
|
|
}
|
|
}
|
|
|
|
func TestParseConfigStringBase64Prefix(t *testing.T) {
|
|
rawJSON := `{"keys":["base64-key"],"accounts":[]}`
|
|
b64 := base64.StdEncoding.EncodeToString([]byte(rawJSON))
|
|
cfg, err := parseConfigString("base64:" + b64)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Keys) != 1 || cfg.Keys[0] != "base64-key" {
|
|
t.Fatalf("unexpected keys: %#v", cfg.Keys)
|
|
}
|
|
}
|
|
|
|
func TestParseConfigStringInvalidBase64(t *testing.T) {
|
|
_, err := parseConfigString("base64:!!!invalid!!!")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid base64")
|
|
}
|
|
}
|
|
|
|
func TestParseConfigStringEmptyString(t *testing.T) {
|
|
_, err := parseConfigString("")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty string")
|
|
}
|
|
}
|
|
|
|
// ─── Store methods ───────────────────────────────────────────────────
|
|
|
|
func TestStoreSnapshotReturnsClone(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@test.com","token":"t1"}]}`)
|
|
store := LoadStore()
|
|
snap := store.Snapshot()
|
|
snap.Keys[0] = "modified"
|
|
if store.Keys()[0] != "k1" {
|
|
t.Fatal("snapshot modification should not affect store")
|
|
}
|
|
}
|
|
|
|
func TestStoreHasAPIKeyMultipleKeys(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["key1","key2","key3"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
if !store.HasAPIKey("key1") {
|
|
t.Fatal("expected key1 found")
|
|
}
|
|
if !store.HasAPIKey("key2") {
|
|
t.Fatal("expected key2 found")
|
|
}
|
|
if !store.HasAPIKey("key3") {
|
|
t.Fatal("expected key3 found")
|
|
}
|
|
if store.HasAPIKey("nonexistent") {
|
|
t.Fatal("expected nonexistent key not found")
|
|
}
|
|
}
|
|
|
|
func TestStoreFindAccountNotFound(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"u@test.com"}]}`)
|
|
store := LoadStore()
|
|
_, ok := store.FindAccount("nonexistent@test.com")
|
|
if ok {
|
|
t.Fatal("expected account not found")
|
|
}
|
|
}
|
|
|
|
func TestStoreCompatWideInputStrictOutputDefaultTrue(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
if !store.CompatWideInputStrictOutput() {
|
|
t.Fatal("expected default wide_input_strict_output=true when unset")
|
|
}
|
|
}
|
|
|
|
func TestStoreCompatWideInputStrictOutputCanDisable(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[],"compat":{"wide_input_strict_output":false}}`)
|
|
store := LoadStore()
|
|
if store.CompatWideInputStrictOutput() {
|
|
t.Fatal("expected wide_input_strict_output=false when explicitly configured")
|
|
}
|
|
|
|
snap := store.Snapshot()
|
|
data, err := snap.MarshalJSON()
|
|
if err != nil {
|
|
t.Fatalf("marshal failed: %v", err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(data, &out); err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
rawCompat, ok := out["compat"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected compat in marshaled output, got %#v", out)
|
|
}
|
|
if rawCompat["wide_input_strict_output"] != false {
|
|
t.Fatalf("expected explicit false in compat, got %#v", rawCompat)
|
|
}
|
|
}
|
|
|
|
func TestStoreCompatStripReferenceMarkersDefaultTrue(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
if !store.CompatStripReferenceMarkers() {
|
|
t.Fatal("expected default strip_reference_markers=true when unset")
|
|
}
|
|
}
|
|
|
|
func TestStoreCompatStripReferenceMarkersCanDisable(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[],"compat":{"strip_reference_markers":false}}`)
|
|
store := LoadStore()
|
|
if store.CompatStripReferenceMarkers() {
|
|
t.Fatal("expected strip_reference_markers=false when explicitly configured")
|
|
}
|
|
|
|
snap := store.Snapshot()
|
|
data, err := snap.MarshalJSON()
|
|
if err != nil {
|
|
t.Fatalf("marshal failed: %v", err)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(data, &out); err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
rawCompat, ok := out["compat"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected compat in marshaled output, got %#v", out)
|
|
}
|
|
if rawCompat["strip_reference_markers"] != false {
|
|
t.Fatalf("expected explicit false in compat, got %#v", rawCompat)
|
|
}
|
|
}
|
|
|
|
func TestStoreIsEnvBacked(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
if !store.IsEnvBacked() {
|
|
t.Fatal("expected env-backed store")
|
|
}
|
|
}
|
|
|
|
func TestStoreReplace(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
newCfg := Config{
|
|
Keys: []string{"new-key"},
|
|
Accounts: []Account{{Email: "new@test.com"}},
|
|
}
|
|
if err := store.Replace(newCfg); err != nil {
|
|
t.Fatalf("replace error: %v", err)
|
|
}
|
|
if !store.HasAPIKey("new-key") {
|
|
t.Fatal("expected new key after replace")
|
|
}
|
|
if store.HasAPIKey("k1") {
|
|
t.Fatal("expected old key removed after replace")
|
|
}
|
|
}
|
|
|
|
func TestStoreUpdate(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
err := store.Update(func(cfg *Config) error {
|
|
cfg.Keys = append(cfg.Keys, "k2")
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update error: %v", err)
|
|
}
|
|
if !store.HasAPIKey("k2") {
|
|
t.Fatal("expected k2 after update")
|
|
}
|
|
}
|
|
|
|
func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
|
|
"accounts":[]
|
|
}`)
|
|
store := LoadStore()
|
|
|
|
if err := store.Update(func(cfg *Config) error {
|
|
cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"})
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("add api key failed: %v", err)
|
|
}
|
|
|
|
snap := store.Snapshot()
|
|
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
|
|
t.Fatalf("unexpected keys after api key add: %#v", snap.Keys)
|
|
}
|
|
if len(snap.APIKeys) != 2 {
|
|
t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys)
|
|
}
|
|
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
|
t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0])
|
|
}
|
|
if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" {
|
|
t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1])
|
|
}
|
|
|
|
if err := store.Update(func(cfg *Config) error {
|
|
cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("delete api key failed: %v", err)
|
|
}
|
|
|
|
snap = store.Snapshot()
|
|
if len(snap.Keys) != 1 || snap.Keys[0] != "k2" {
|
|
t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys)
|
|
}
|
|
if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" {
|
|
t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys)
|
|
}
|
|
}
|
|
|
|
func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{
|
|
"keys":["k1"],
|
|
"api_keys":[{"key":"k1","name":"primary","remark":"prod"}],
|
|
"accounts":[]
|
|
}`)
|
|
store := LoadStore()
|
|
|
|
if err := store.Update(func(cfg *Config) error {
|
|
cfg.Keys = append(cfg.Keys, "k2")
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatalf("legacy key update failed: %v", err)
|
|
}
|
|
|
|
snap := store.Snapshot()
|
|
if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" {
|
|
t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys)
|
|
}
|
|
if len(snap.APIKeys) != 2 {
|
|
t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys)
|
|
}
|
|
if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" {
|
|
t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0])
|
|
}
|
|
if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" {
|
|
t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1])
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) {
|
|
cfg := Config{
|
|
Keys: []string{"legacy-key"},
|
|
APIKeys: []APIKey{
|
|
{Key: "structured-key", Name: "primary", Remark: "prod"},
|
|
},
|
|
}
|
|
cfg.NormalizeCredentials()
|
|
|
|
if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" {
|
|
t.Fatalf("unexpected normalized keys: %#v", cfg.Keys)
|
|
}
|
|
if len(cfg.APIKeys) != 1 {
|
|
t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys)
|
|
}
|
|
if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" {
|
|
t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0])
|
|
}
|
|
}
|
|
|
|
func TestStoreClaudeMapping(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`)
|
|
store := LoadStore()
|
|
mapping := store.ClaudeMapping()
|
|
if mapping["fast"] != "deepseek-chat" {
|
|
t.Fatalf("unexpected fast mapping: %q", mapping["fast"])
|
|
}
|
|
if mapping["slow"] != "deepseek-reasoner" {
|
|
t.Fatalf("unexpected slow mapping: %q", mapping["slow"])
|
|
}
|
|
}
|
|
|
|
func TestStoreClaudeMappingEmpty(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[]}`)
|
|
store := LoadStore()
|
|
mapping := store.ClaudeMapping()
|
|
// Even without config mapping, there are defaults
|
|
if mapping == nil {
|
|
t.Fatal("expected non-nil mapping (may contain defaults)")
|
|
}
|
|
}
|
|
|
|
func TestStoreSetVercelSync(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[]}`)
|
|
store := LoadStore()
|
|
if err := store.SetVercelSync("hash123", 1234567890); err != nil {
|
|
t.Fatalf("setVercelSync error: %v", err)
|
|
}
|
|
snap := store.Snapshot()
|
|
if snap.VercelSyncHash != "hash123" || snap.VercelSyncTime != 1234567890 {
|
|
t.Fatalf("unexpected vercel sync: hash=%q time=%d", snap.VercelSyncHash, snap.VercelSyncTime)
|
|
}
|
|
}
|
|
|
|
func TestStoreExportJSONAndBase64(t *testing.T) {
|
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["export-key"],"accounts":[]}`)
|
|
store := LoadStore()
|
|
jsonStr, b64Str, err := store.ExportJSONAndBase64()
|
|
if err != nil {
|
|
t.Fatalf("export error: %v", err)
|
|
}
|
|
if !strings.Contains(jsonStr, "export-key") {
|
|
t.Fatalf("expected JSON to contain key: %q", jsonStr)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(b64Str)
|
|
if err != nil {
|
|
t.Fatalf("base64 decode error: %v", err)
|
|
}
|
|
if !strings.Contains(string(decoded), "export-key") {
|
|
t.Fatalf("expected base64-decoded to contain key: %q", string(decoded))
|
|
}
|
|
}
|
|
|
|
// ─── OpenAIModelsResponse / ClaudeModelsResponse ─────────────────────
|
|
|
|
func TestOpenAIModelsResponse(t *testing.T) {
|
|
resp := OpenAIModelsResponse()
|
|
if resp["object"] != "list" {
|
|
t.Fatalf("unexpected object: %v", resp["object"])
|
|
}
|
|
data, ok := resp["data"].([]ModelInfo)
|
|
if !ok {
|
|
t.Fatalf("unexpected data type: %T", resp["data"])
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("expected non-empty models list")
|
|
}
|
|
expected := map[string]bool{
|
|
"deepseek-chat": false,
|
|
"deepseek-reasoner": false,
|
|
"deepseek-chat-search": false,
|
|
"deepseek-reasoner-search": false,
|
|
"deepseek-expert-chat": false,
|
|
"deepseek-expert-reasoner": false,
|
|
"deepseek-expert-chat-search": false,
|
|
"deepseek-expert-reasoner-search": false,
|
|
"deepseek-vision-chat": false,
|
|
"deepseek-vision-reasoner": false,
|
|
"deepseek-vision-chat-search": false,
|
|
"deepseek-vision-reasoner-search": false,
|
|
}
|
|
for _, model := range data {
|
|
if _, ok := expected[model.ID]; ok {
|
|
expected[model.ID] = true
|
|
}
|
|
}
|
|
for id, seen := range expected {
|
|
if !seen {
|
|
t.Fatalf("expected OpenAI model list to include %s", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClaudeModelsResponse(t *testing.T) {
|
|
resp := ClaudeModelsResponse()
|
|
if resp["object"] != "list" {
|
|
t.Fatalf("unexpected object: %v", resp["object"])
|
|
}
|
|
data, ok := resp["data"].([]ModelInfo)
|
|
if !ok {
|
|
t.Fatalf("unexpected data type: %T", resp["data"])
|
|
}
|
|
if len(data) == 0 {
|
|
t.Fatal("expected non-empty models list")
|
|
}
|
|
}
|