package runtimeconfig import ( "io" "log/slog" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件钉住「`.env` = 默认/播种层,models.json = 一等来源」的启动补齐规则(主人 // 裁决,实现见 materialize.go):组集合由面板全权决定、键级以面板优先、每个键只在 // 面板**缺**它时才由 `.env` 补,且整件事幂等可重复。 // materializeEnv 是补齐用例的基线 `.env`:扁平 OpenAI 块 + 一块 anthropic 配置, // 每个值都与面板文件里的测试值**不同**,好让「面板优先」与「env 补齐」一眼可辨。 func materializeEnv() config.LLMConfig { return config.LLMConfig{ Provider: ProviderOpenAI, BaseURL: "https://env.example/v1", APIKey: "sk-env-key", Model: "env-model", Fallback: "env-fallback", AnthropicBaseURL: "https://env-anthropic.example", AnthropicAPIKey: "sk-env-anthropic", AnthropicModel: "env-claude", AnthropicFallback: "env-claude-mini", } } // discardLog 让补齐用例的日志不进测试输出(断言日志内容另开一例)。 func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // readRaw 读回文件原文:幂等与「不覆盖」两类断言都必须比字节,不能只看解出来的组。 func readRaw(t *testing.T, path string) string { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("读回 %s: %v", path, err) } return string(raw) } // fillStrings 把补齐记录渲染成 "组 id.键名",便于直接比对期望集合。 func fillStrings(fills []Fill) []string { out := make([]string, 0, len(fills)) for _, f := range fills { out = append(out, f.String()) } return out } // equalStrings 比对两份字符串集合(顺序敏感,补齐顺序即 groupFillSpecs 顺序)。 func equalStrings(got, want []string) bool { if len(got) != len(want) { return false } for i := range got { if got[i] != want[i] { return false } } return true } // TestMaterializePanelKeyBeatsEnv 是本任务的核心:面板**已有**该键时,即使 `.env` // 的值不同,也**必须**保留面板值(否则就是「面板改一次、重启一次被 .env 打回去」)。 // 同时断言本次不改动文件一个字节。 func TestMaterializePanelKeyBeatsEnv(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-panel","model":"panel-model"} ]`) before := readRaw(t, path) res, err := Materialize(path, materializeEnv(), discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if res.Warn != nil { t.Fatalf("Warn = %v, want nil", res.Warn) } if len(res.Filled) != 0 { t.Fatalf("Filled = %v, want 空(面板四个键都在)", fillStrings(res.Filled)) } want := []string{"openai.provider", "openai.baseUrl", "openai.apiKey", "openai.model"} if got := fillStrings(res.PanelWins); !equalStrings(got, want) { t.Fatalf("PanelWins = %v, want %v", got, want) } g := res.Groups[0] if g.BaseURL != "https://panel.example/v1" || g.APIKey != "sk-panel" || g.Model != "panel-model" { t.Fatalf("组 = %+v, want 面板值原样保留", g) } if res.Written || readRaw(t, path) != before { t.Fatal("无键可补时不得改动文件(连重新格式化都不做)") } } // TestMaterializeFillsMissingKeysFromEnv 覆盖键级补齐的三种形态:面板缺一个可选键 // (apiKey)、缺一个必填键(provider)、以及 anthropic 组必须取 anthropic 那块 env // 而不是扁平块。补齐后必须**真的落盘**。 func TestMaterializeFillsMissingKeysFromEnv(t *testing.T) { t.Parallel() env := materializeEnv() t.Run("缺 apiKey", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[ {"id":"openai","provider":"openai","baseUrl":"https://panel.example/v1","model":"panel-model"} ]`) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if want := []string{"openai.apiKey"}; !equalStrings(fillStrings(res.Filled), want) { t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want) } if !res.Written { t.Fatal("补齐后必须落盘") } g := res.Groups[0] if g.APIKey != "sk-env-key" || g.BaseURL != "https://panel.example/v1" || g.Model != "panel-model" { t.Fatalf("组 = %+v, want 面板值 + env 补的 apiKey", g) } // 落盘后从文件重读,仍是同一份合并结果。 groups, warn := LoadModels(path, env) if warn != nil { t.Fatalf("LoadModels: warn = %v", warn) } if groups[0].APIKey != "sk-env-key" || groups[0].Model != "panel-model" { t.Fatalf("重读 = %+v, want 合并结果已持久化", groups[0]) } }) t.Run("缺 provider", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[{"id":"g","baseUrl":"https://panel.example/v1","model":"panel-model"}]`) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if want := []string{"g.provider", "g.apiKey"}; !equalStrings(fillStrings(res.Filled), want) { t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want) } if res.Groups[0].Provider != ProviderOpenAI || res.Groups[0].APIKey != "sk-env-key" { t.Fatalf("组 = %+v, want provider/apiKey 由 env 补齐", res.Groups[0]) } if !strings.Contains(readRaw(t, path), `"provider": "openai"`) { t.Fatalf("落盘文件缺 provider: %s", readRaw(t, path)) } }) t.Run("anthropic 组取 anthropic 那块 env", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[ {"id":"claude","provider":"anthropic","baseUrl":"https://panel.example"} ]`) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if want := []string{"claude.apiKey", "claude.model"}; !equalStrings(fillStrings(res.Filled), want) { t.Fatalf("Filled = %v, want %v", fillStrings(res.Filled), want) } g := res.Groups[0] if g.APIKey != "sk-env-anthropic" || g.Model != "env-claude" { t.Fatalf("组 = %+v, want anthropic 块的 apiKey/model(不能取扁平块)", g) } }) } // TestMaterializeNeverResurrectsPanelDeletedGroup:面板把某个组删掉后,即使 `.env` // 仍然声明着它,重启也不得把它复活(组集合完全由面板决定)。 func TestMaterializeNeverResurrectsPanelDeletedGroup(t *testing.T) { t.Parallel() env := materializeEnv() // env.Provider = openai,播种时本会合成 id=openai 的组 path := writeModelsFile(t, `[ {"id":"custom","provider":"openai","baseUrl":"https://panel.example/v1","apiKey":"sk-p","model":"panel-model"} ]`) before := readRaw(t, path) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if len(res.Groups) != 1 || res.Groups[0].ID != "custom" { t.Fatalf("组集合 = %+v, want 只剩面板的 custom", res.Groups) } for _, g := range res.Groups { if g.ID == ProviderOpenAI { t.Fatal("`.env` 声明的 openai 组被复活了") } } if res.Seeded || res.Written || readRaw(t, path) != before { t.Fatal("面板非空时既不该播种也不该改动文件") } } // TestMaterializeSeedsWhenPanelMissingOrEmpty:面板配置缺失/为空 ⇒ 由 `.env` 播种出 // 合成组(= GroupFromEnv 的既有行为),并落盘成面板的一等来源。`.env` 自己没配全时 // 合成组不合法,此时**不写盘**(不制造一份无效配置),但仍报告播种结论。 func TestMaterializeSeedsWhenPanelMissingOrEmpty(t *testing.T) { t.Parallel() env := materializeEnv() want := GroupFromEnv(env) missing := filepath.Join(t.TempDir(), "models.json") cases := []struct { name string path string }{ {name: "文件不存在", path: missing}, {name: "空文件", path: writeModelsFile(t, "")}, } for _, tc := range cases { res, err := Materialize(tc.path, env, discardLog()) if err != nil { t.Fatalf("%s: Materialize: %v", tc.name, err) } if !res.Seeded || !res.Written || res.Warn != nil { t.Fatalf("%s: Seeded=%v Written=%v Warn=%v, want 播种并落盘", tc.name, res.Seeded, res.Written, res.Warn) } if len(res.Groups) != 1 { t.Fatalf("%s: 组数 = %d, want 1", tc.name, len(res.Groups)) } if got := res.Groups[0]; got.BaseURL != want[0].BaseURL || got.APIKey != want[0].APIKey || got.Model != want[0].Model || got.ID != want[0].ID { t.Fatalf("%s: 组 = %+v, want env 合成组 %+v", tc.name, got, want[0]) } groups, warn := LoadModels(tc.path, env) if warn != nil { t.Fatalf("%s: LoadModels: warn = %v", tc.name, warn) } if groups[0].APIKey != want[0].APIKey { t.Fatalf("%s: 落盘后重读 = %+v, want 合成组", tc.name, groups[0]) } } for _, blank := range []string{"[]", " \n\t", "null"} { path := writeModelsFile(t, blank) res, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("%q: Materialize: %v", blank, err) } if !res.Seeded || !res.Written { t.Fatalf("%q: Seeded=%v Written=%v, want 按「空」播种", blank, res.Seeded, res.Written) } } // `.env` 没配全 ⇒ 合成组不合法 ⇒ 不落盘,只报 Warn。 incomplete := filepath.Join(t.TempDir(), "models.json") res, err := Materialize(incomplete, config.LLMConfig{}, discardLog()) if err != nil { t.Fatalf("Materialize: %v", err) } if !res.Seeded || res.Written { t.Fatalf("Seeded=%v Written=%v, want 只播种不落盘", res.Seeded, res.Written) } if res.Warn == nil { t.Fatal("Warn = nil, want 一份「.env 自己也没配全」的告警") } if _, statErr := os.Stat(incomplete); !os.IsNotExist(statErr) { t.Fatal("不合法的合成组不得落盘") } } // TestMaterializeIsIdempotent:连跑两次,第二次必须**零改动**(字节级),无论第一次 // 走的是播种路径还是补齐路径。 func TestMaterializeIsIdempotent(t *testing.T) { t.Parallel() env := materializeEnv() t.Run("播种路径", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "models.json") first, err := Materialize(path, env, discardLog()) if err != nil || !first.Written { t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err) } afterFirst := readRaw(t, path) second, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("第二次: %v", err) } if second.Written || second.Seeded || len(second.Filled) != 0 { t.Fatalf("第二次 = %+v, want 零改动", second) } if readRaw(t, path) != afterFirst { t.Fatal("第二次改动了文件字节") } }) t.Run("补齐路径", func(t *testing.T) { t.Parallel() path := writeModelsFile(t, `[{"id":"g","baseUrl":"https://panel.example/v1","model":"m"}]`) if _, err := Materialize(path, env, discardLog()); err != nil { t.Fatalf("第一次: %v", err) } afterFirst := readRaw(t, path) second, err := Materialize(path, env, discardLog()) if err != nil { t.Fatalf("第二次: %v", err) } if second.Written || len(second.Filled) != 0 { t.Fatalf("第二次 = %+v, want 零改动", second) } if readRaw(t, path) != afterFirst { t.Fatal("第二次改动了文件字节") } }) }