diff --git a/cmd/moonbridge/main.go b/cmd/moonbridge/main.go index 8f17326c..111cda6b 100644 --- a/cmd/moonbridge/main.go +++ b/cmd/moonbridge/main.go @@ -111,6 +111,14 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int { if *addr != "" { cfg.OverrideAddr(*addr) } + // -print-* 子命令在 SQLite 覆盖前就会退出,这里先加载持久化中的有效配置, + // 使 -print-addr/-print-mode/-print-codex-config 等读到 WebUI 改动后的状态。 + // 持久化未启用或加载失败时静默回退到 YAML 配置(YAML-only 部署是合法形态)。 + if effectiveCfg, loadErr := app.TryLoadEffectiveConfig(context.Background(), cfg); loadErr == nil { + cfg = effectiveCfg + } else { + slog.Debug("跳过持久化配置加载,使用 YAML 配置", "error", loadErr) + } if *printAddr { fmt.Fprintln(stdout, cfg.Addr) return exitOK diff --git a/cmd/moonbridge/main_test.go b/cmd/moonbridge/main_test.go index 09601db7..67bbc097 100644 --- a/cmd/moonbridge/main_test.go +++ b/cmd/moonbridge/main_test.go @@ -272,3 +272,121 @@ func TestPublishConfigFileDoesNotOverwriteExistingFinalFile(t *testing.T) { t.Fatalf("temp file stat error = %v, want not exist", err) } } + +// TestRunPrintCodexConfigReadsSQLiteOverride 是核心回归测试: +// 验证 -print-codex-config 能读到 SQLite 持久化覆盖后的配置,而非 YAML 源。 +// +// 场景:config.yml 里是 placeholder provider/model,但 SQLite 中存的是 db-provider。 +// 修复前 print 只读 YAML(半成品);修复后经 TryLoadEffectiveConfig 读到 DB 覆盖配置。 +// +// 两阶段: +// 1. 用含 db-provider 的 config.yml 跑一次 → db_sqlite 自动 seed(库获得 db-provider) +// 2. 改写 config.yml 为 placeholder provider,再跑 -print-codex-config db-alias +// → 应输出 SQLite 里的 db-upstream-model(证明读到了覆盖配置) +func TestRunPrintCodexConfigReadsSQLiteOverride(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "moonbridge.db") + configPath := filepath.Join(dir, "config.yml") + + // 第一阶段:含 db-provider 的配置,用于 seed SQLite。 + seedConfig := []byte(` +mode: Transform +server: + addr: "127.0.0.1:0" +persistence: + active_provider: db_sqlite +extensions: + db_sqlite: + enabled: true + config: + path: ` + dbPath + ` +models: + db-model: + context_window: 200000 +providers: + db-provider: + base_url: "https://db.example.test" + api_key: "db-key" + protocol: anthropic + offers: + - model: db-model + upstream_name: db-upstream-model +routes: + db-alias: + provider: db-provider + model: db-model +`) + if err := os.WriteFile(configPath, seedConfig, 0o600); err != nil { + t.Fatalf("WriteFile(seed): %v", err) + } + + // 第一次 run:触发 db_sqlite 初始化 + seed(-print-mode 提前退出,但持久化已 seed)。 + var seedStdout, seedStderr bytes.Buffer + if code := run([]string{"-config", configPath, "-print-mode"}, &seedStdout, &seedStderr); code != exitOK { + t.Fatalf("seed run exit = %d, want %d; stderr:\n%s", code, exitOK, seedStderr.String()) + } + + // 确认 SQLite 文件已创建(seed 生效)。 + if _, err := os.Stat(dbPath); err != nil { + t.Fatalf("seed did not create db file %s: %v", dbPath, err) + } + + // 第二阶段:改写 config.yml 为完全不同的 placeholder provider/model。 + // SQLite 中仍是第一阶段 seed 的 db-provider。print 应读 DB 而非这份 YAML。 + placeholderConfig := []byte(` +mode: Transform +server: + addr: "127.0.0.1:0" +persistence: + active_provider: db_sqlite +extensions: + db_sqlite: + enabled: true + config: + path: ` + dbPath + ` +models: + yaml-model: + context_window: 128000 +providers: + yaml-placeholder: + base_url: "https://yaml.example.test" + api_key: "yaml-key" + protocol: anthropic + offers: + - model: yaml-model +routes: + yaml-alias: + provider: yaml-placeholder + model: yaml-model +`) + if err := os.WriteFile(configPath, placeholderConfig, 0o600); err != nil { + t.Fatalf("WriteFile(placeholder): %v", err) + } + + // 核心断言:-print-codex-config 应输出 db-alias(DB 里的路由)而非 yaml-alias。 + // db-alias 在 YAML 中已不存在,只有读到 SQLite 覆盖配置才能生成。 + var stdout, stderr bytes.Buffer + code := run([]string{ + "-config", configPath, + "-print-codex-config", "db-alias", + }, &stdout, &stderr) + if code != exitOK { + t.Fatalf("print run exit = %d, want %d; stderr:\n%s", code, exitOK, stderr.String()) + } + + output := stdout.String() + // db-alias 是 SQLite 中的路由别名;YAML(placeholder)里只有 yaml-alias。 + // 输出含 db-alias 即证明 print 读到了 SQLite 覆盖配置。 + if !strings.Contains(output, `model = "db-alias"`) { + t.Errorf("print output missing model=\"db-alias\" (proves it read YAML not SQLite):\n%s", output) + } + // context_window = 200000 来自 DB 的 db-model;YAML 的 yaml-model 是 128000。 + // 这进一步证明读的是 SQLite 数据而非 YAML。 + if !strings.Contains(output, "model_context_window = 200000") { + t.Errorf("print output missing model_context_window=200000 (DB value; YAML yaml-model is 128000):\n%s", output) + } + // 反向验证:YAML 的 placeholder 路由不应出现(证明 DB 完全覆盖 YAML)。 + if strings.Contains(output, "yaml-alias") { + t.Errorf("print output contains yaml-alias (should not: DB should override YAML):\n%s", output) + } +} diff --git a/internal/service/app/app.go b/internal/service/app/app.go index 9bb1d659..31c1a27e 100644 --- a/internal/service/app/app.go +++ b/internal/service/app/app.go @@ -2,7 +2,6 @@ package app import ( "context" - stderrors "errors" "fmt" "io" "net/http" @@ -11,7 +10,6 @@ import ( "log/slog" "moonbridge/internal/config" - "moonbridge/internal/db" "moonbridge/internal/extension/codextool" "moonbridge/internal/format" "moonbridge/internal/logger" @@ -28,7 +26,6 @@ import ( "moonbridge/internal/service/server/trace" "moonbridge/internal/service/server/usage" "moonbridge/internal/service/stats" - "moonbridge/internal/service/store" mbtrace "moonbridge/internal/service/trace" ) @@ -122,78 +119,37 @@ func runTransform(ctx context.Context, cfg config.Config, errors io.Writer) erro return plugins.ConsumeGlobalLog(entries) }) - // Initialize persistence layer (db.Registry). - dbRegistry := db.NewRegistry(slog.Default()) - dbProviders := plugins.DBProviders() - providers := make([]db.Provider, 0, len(dbProviders)) - for _, p := range dbProviders { - if prov := p.DBProvider(); prov != nil { - dbRegistry.RegisterProvider(prov) - providers = append(providers, prov) - } - } - for _, c := range plugins.DBConsumers() { - if cons := c.DBConsumer(); cons != nil { - dbRegistry.RegisterConsumer(cons) + // === Phase 2: Persistence bootstrap === + // db.Registry 装配、ConfigStore.LoadAll 覆盖、空库 seed 等逻辑统一收敛到 + // BootstrapPersistence,使 -print-* 子命令(经 TryLoadEffectiveConfig)与 + // 本路径共享同一份覆盖语义。 + result, err := BootstrapPersistence(ctx, cfg, plugins) + if err != nil { + return fmt.Errorf("bootstrap persistence: %w", err) + } + if result.Shutdown != nil { + defer result.Shutdown() + } + cfg = result.Cfg + cs := result.Store + + // 如果 SQLite 覆盖了 cfg,用覆盖后的配置重建 provider 运行时派生状态。 + if result.Overridden { + dbProviderCfg := config.ProviderFromGlobalConfig(&cfg) + providerDefs = provider.BuildProviderDefsFromConfig(dbProviderCfg) + modelRoutes = provider.BuildModelRoutesFromConfig(dbProviderCfg) + providerMgr, err = provider.NewProviderManager(providerDefs, modelRoutes) + if err != nil { + return fmt.Errorf("rebuild provider manager from DB: %w", err) } - } - // Register the config_store consumer for configuration persistence. - configStoreConsumer := store.NewConfigStoreConsumer(logger.L()) - configStoreConsumer.SetExtensionSpecs(BuiltinExtensions().ConfigSpecs()) - dbRegistry.RegisterConsumer(configStoreConsumer) - activePersistenceProvider := ResolvePersistenceActiveProvider(cfg.Persistence.ActiveProvider, providers) - if err := dbRegistry.Init(ctx, activePersistenceProvider); err != nil { - return fmt.Errorf("init persistence: %w", err) - } - defer dbRegistry.Shutdown() - - // === Phase 2: ConfigStore bootstrap === - // Check if the store is available and has existing data. - cs := configStoreConsumer.Store() - if cs != nil { - if dbCfg, loadErr := cs.LoadAll(); loadErr == nil { - if len(dbCfg.ProviderDefs) > 0 || len(dbCfg.Routes) > 0 { - // DB has existing configuration: use it as the active config. - logger.Info("从持久化存储加载配置", - "providers", len(dbCfg.ProviderDefs), - "routes", len(dbCfg.Routes)) - cfg = *dbCfg - dbProviderCfg := config.ProviderFromGlobalConfig(&cfg) - - // Rebuild provider manager and pricing from DB-loaded config. - providerDefs = provider.BuildProviderDefsFromConfig(dbProviderCfg) - modelRoutes = provider.BuildModelRoutesFromConfig(dbProviderCfg) - providerMgr, err = provider.NewProviderManager(providerDefs, modelRoutes) - if err != nil { - return fmt.Errorf("rebuild provider manager from DB: %w", err) - } - _ = resolveDefaultClient(providerMgr, errors) - resolvePerProviderWebSearch(ctx, cfg, providerMgr, errors) + _ = resolveDefaultClient(providerMgr, errors) + resolvePerProviderWebSearch(ctx, cfg, providerMgr, errors) - pricing = provider.BuildPricingFromConfig(dbProviderCfg) - if len(pricing) > 0 { - sessionStats.SetPricing(pricing) - } - serverCfg = config.ServerFromGlobalConfig(&cfg) - } else { - // DB is empty: seed from YAML config. - logger.Info("持久化存储为空,从 YAML 导入种子配置") - if err := cs.SeedFromConfig(&cfg); err != nil { - logger.Warn("config store 种子导入失败", "error", err) - } - } - } else if loadErr != nil { - if stderrors.Is(loadErr, store.ErrConfigNotSeeded) { - logger.Info("持久化存储为空,从 YAML 导入种子配置") - if err := cs.SeedFromConfig(&cfg); err != nil { - return fmt.Errorf("seed config store from YAML: %w", err) - } - } else { - logger.Warn("config store 加载失败", "error", loadErr) - } + pricing = provider.BuildPricingFromConfig(dbProviderCfg) + if len(pricing) > 0 { + sessionStats.SetPricing(pricing) } - } else { - logger.Warn("config store 不可用,跳过持久化引导") + serverCfg = config.ServerFromGlobalConfig(&cfg) } // === Phase 3: Build Runtime === diff --git a/internal/service/app/persistence.go b/internal/service/app/persistence.go index 0ed85b70..42cc1eca 100644 --- a/internal/service/app/persistence.go +++ b/internal/service/app/persistence.go @@ -1,9 +1,17 @@ package app import ( + "context" + "errors" + "fmt" + "log/slog" "strings" + "moonbridge/internal/config" "moonbridge/internal/db" + "moonbridge/internal/extension/plugin" + "moonbridge/internal/logger" + "moonbridge/internal/service/store" ) // ResolvePersistenceActiveProvider chooses the provider name that should be @@ -32,3 +40,143 @@ func ResolvePersistenceActiveProvider(configured string, providers []db.Provider } return "" } + +// PersistenceResult 是持久化引导的结果。 +// +// 引入动机:原 runTransform 内联了 db.Registry 装配 + ConfigStore.LoadAll 覆盖逻辑 +// (app.go 历史 125-197 行),但 -print-* 子命令在覆盖发生前就退出,读到的是未经 +// SQLite 覆盖的半成品配置。将该逻辑抽成 BootstrapPersistence 后,runTransform 与 +// main 的 print 路径共享同一份覆盖语义。 +type PersistenceResult struct { + // Cfg 是覆盖后的有效配置。当 DB 为空或持久化不可用时,等于输入 cfg。 + Cfg config.Config + // Store 是活跃的 ConfigStore 句柄,runTransform 需要它注入 HTTP server + // 以支持运行时热重载。nil 表示持久化未启用或 store 不可用。 + Store store.ConfigStore + // Overridden 指示 Cfg 是否被 SQLite 中的配置覆盖。为 true 时调用方需重建 + // 派生状态(providerMgr/pricing 等)。 + Overridden bool + // Shutdown 释放 db.Registry 持有的资源(数据库连接等)。无副作用时为 nil。 + // 调用方必须 defer 调用以避免资源泄漏。 + Shutdown func() error +} + +// BootstrapPersistence 装配持久化层并用 SQLite 中的配置覆盖 YAML 配置。 +// +// 职责:db.Registry 装配(provider + consumer 注册)、dbRegistry.Init、 +// ConfigStore.LoadAll 覆盖、空库 seed、ErrConfigNotSeeded 处理。 +// 不负责 providerMgr/pricing 重建——那属于 server 运行时构建关注点,由调用方 +// 根据 Overridden 自行处理。 +// +// cfg 与 plugins 由调用方预先构建:plugins 来自 YAML cfg(与历史行为一致,不重建)。 +// +// 失败语义(Fail Fast,与历史 runTransform 内联逻辑等价): +// - dbRegistry.Init 失败:返回 error(致命,与历史 app.go:146 一致)。 +// - LoadAll 返回 ErrConfigNotSeeded:从 YAML seed 到 DB,seed 失败返回 error +// (与历史 app.go:189 一致)。 +// - LoadAll 返回其它错误:记 Warn 后继续(与历史 app.go:192 一致)。 +// - 持久化未启用(无 db provider / store 不可用):返回原 cfg,Store 为 nil。 +func BootstrapPersistence(ctx context.Context, cfg config.Config, plugins *plugin.Registry) (PersistenceResult, error) { + dbRegistry := db.NewRegistry(slog.Default()) + + dbProviders := plugins.DBProviders() + providers := make([]db.Provider, 0, len(dbProviders)) + for _, p := range dbProviders { + if prov := p.DBProvider(); prov != nil { + dbRegistry.RegisterProvider(prov) + providers = append(providers, prov) + } + } + for _, c := range plugins.DBConsumers() { + if cons := c.DBConsumer(); cons != nil { + dbRegistry.RegisterConsumer(cons) + } + } + + // Register the config_store consumer for configuration persistence. + configStoreConsumer := store.NewConfigStoreConsumer(logger.L()) + configStoreConsumer.SetExtensionSpecs(BuiltinExtensions().ConfigSpecs()) + dbRegistry.RegisterConsumer(configStoreConsumer) + + activePersistenceProvider := ResolvePersistenceActiveProvider(cfg.Persistence.ActiveProvider, providers) + if err := dbRegistry.Init(ctx, activePersistenceProvider); err != nil { + // Init 失败时 dbRegistry 可能已打开部分资源,Shutdown 兜底释放。 + _ = dbRegistry.Shutdown() + return PersistenceResult{Cfg: cfg}, fmt.Errorf("init persistence: %w", err) + } + + result := PersistenceResult{ + Cfg: cfg, + Store: configStoreConsumer.Store(), + Shutdown: dbRegistry.Shutdown, + } + + // === ConfigStore bootstrap === + cs := result.Store + if cs == nil { + // store 不可用:持久化 consumer 被禁用或无 provider。保持 cfg 不变。 + logger.Warn("config store 不可用,跳过持久化引导") + return result, nil + } + + dbCfg, loadErr := cs.LoadAll() + switch { + case loadErr == nil: + if len(dbCfg.ProviderDefs) > 0 || len(dbCfg.Routes) > 0 { + // DB has existing configuration: use it as the active config. + logger.Info("从持久化存储加载配置", + "providers", len(dbCfg.ProviderDefs), + "routes", len(dbCfg.Routes)) + result.Cfg = *dbCfg + result.Overridden = true + } else { + // DB is empty: seed from YAML config. + logger.Info("持久化存储为空,从 YAML 导入种子配置") + if err := cs.SeedFromConfig(&cfg); err != nil { + logger.Warn("config store 种子导入失败", "error", err) + } + } + case errors.Is(loadErr, store.ErrConfigNotSeeded): + logger.Info("持久化存储为空,从 YAML 导入种子配置") + if err := cs.SeedFromConfig(&cfg); err != nil { + _ = dbRegistry.Shutdown() + return PersistenceResult{Cfg: cfg}, fmt.Errorf("seed config store from YAML: %w", err) + } + default: + logger.Warn("config store 加载失败", "error", loadErr) + } + return result, nil +} + +// TryLoadEffectiveConfig 是 BootstrapPersistence 的轻量入口,供只读场景 +// (如 -print-* 子命令)使用。 +// +// 引入动机:main.go 的 print 路径在 cfg 加载后就退出,到不了 runTransform 内的 +// SQLite 覆盖点。此函数让 print 路径读到覆盖后的有效配置。 +// +// 与 BootstrapPersistence 的区别:调用方无需预先构建 plugin.Registry。本函数 +// 用 BuiltinExtensions() 构建最小 registry(含必要的 InitAll,因为 DBProvider +// 的可用性依赖插件 Init 注入的状态),引导完成后立即 ShutdownAll + dbRegistry.Shutdown, +// 返回纯 cfg,不留悬空资源。 +// +// 失败(持久化未启用、DB 不可用、Init 失败)返回 error,调用方 fallback 到 YAML cfg。 +func TryLoadEffectiveConfig(ctx context.Context, cfg config.Config) (config.Config, error) { + plugins := BuiltinExtensions().NewRegistry(slog.Default(), cfg) + if err := plugins.InitAll(&cfg); err != nil { + plugins.ShutdownAll() + return cfg, fmt.Errorf("init plugins for effective config load: %w", err) + } + defer plugins.ShutdownAll() + + result, err := BootstrapPersistence(ctx, cfg, plugins) + if err != nil { + if result.Shutdown != nil { + result.Shutdown() + } + return cfg, err + } + if result.Shutdown != nil { + defer result.Shutdown() + } + return result.Cfg, nil +} diff --git a/internal/service/app/persistence_test.go b/internal/service/app/persistence_test.go new file mode 100644 index 00000000..df8024fb --- /dev/null +++ b/internal/service/app/persistence_test.go @@ -0,0 +1,252 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "path/filepath" + "testing" + + "moonbridge/internal/config" + "moonbridge/internal/extension/plugin" +) + +// 这些测试验证 BootstrapPersistence 的核心契约:SQLite 中的配置能覆盖 YAML 配置。 +// 使用真实 SQLite 文件(经 db.Registry + ConfigStore 流程),而非 mock,以验证 +// 完整逻辑链(db.Registry 装配 → Init → LoadAll → cfg 覆盖)。 +// +// cfg 通过 LoadFromYAMLWithOptions 构造,注入 builtin extension specs,使 +// db_sqlite 插件能正确解码 path 字段(模拟 main.go 的真实加载路径)。 + +// discardLogger 返回一个只输出 Error 级别的 slog logger,避免测试日志噪音。 +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(&discardWriter{}, &slog.HandlerOptions{Level: slog.LevelError})) +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// cfgFromYAML 用 builtin extension specs 解析 YAML,返回注入了 specs 的 config.Config。 +func cfgFromYAML(t *testing.T, yaml string) config.Config { + t.Helper() + cfg, err := config.LoadFromYAMLWithOptions([]byte(yaml), config.LoadOptions{ + ExtensionSpecs: BuiltinExtensions().ConfigSpecs(), + }) + if err != nil { + t.Fatalf("LoadFromYAMLWithOptions: %v\nyaml:\n%s", err, yaml) + } + return cfg +} + +// cfgWithSQLiteAt 构造一份启用 db_sqlite、库文件指向 absPath 的 Transform cfg。 +// cfgWithSQLiteAt 构造一份启用 db_sqlite、库文件指向 absPath 的 Transform cfg。 +// 含一个占位 provider(yaml-placeholder)以通过 Transform 模式的 providers 必填校验; +// 它与 DB 中的 db-provider 内容不同,用于验证覆盖是否生效。 +func cfgWithSQLiteAt(t *testing.T, absPath string) config.Config { + t.Helper() + yaml := fmt.Sprintf(` +mode: Transform +server: + addr: "127.0.0.1:0" +persistence: + active_provider: db_sqlite +extensions: + db_sqlite: + enabled: true + config: + path: %q +models: + yaml-model: + context_window: 128000 +providers: + yaml-placeholder: + base_url: "https://yaml.example.test" + api_key: "yaml-key" + protocol: anthropic + offers: + - model: yaml-model +routes: + yaml-alias: + provider: yaml-placeholder + model: yaml-model +`, absPath) + return cfgFromYAML(t, yaml) +} + +// dbStoredCfgYAML 是要写入 SQLite 的配置内容,与 YAML 源不同,用于验证覆盖生效。 +// 通过 SaveConfig 注入,使 DB 中的 provider/route 与 YAML 源的空 provider 不同。 +const dbStoredCfgYAML = ` +mode: Transform +server: + addr: "127.0.0.1:9999" +models: + db-model: + context_window: 200000 +providers: + db-provider: + base_url: "https://db.example.test" + api_key: "db-key" + protocol: anthropic + offers: + - model: db-model + upstream_name: db-upstream-model +routes: + db-alias: + provider: db-provider + model: db-upstream-model +` + +// newPluginsForCfg 构建 plugin.Registry 并 InitAll,注册 cleanup 调 ShutdownAll。 +func newPluginsForCfg(t *testing.T, cfg config.Config) *plugin.Registry { + t.Helper() + plugins := BuiltinExtensions().NewRegistry(discardLogger(), cfg) + if err := plugins.InitAll(&cfg); err != nil { + t.Fatalf("plugins.InitAll: %v", err) + } + t.Cleanup(plugins.ShutdownAll) + return plugins +} + +// TestBootstrapPersistence_OverridesCfgFromSQLite 验证核心修复: +// 当 SQLite 中存在配置时,BootstrapPersistence 用 DB 配置覆盖输入 cfg。 +func TestBootstrapPersistence_OverridesCfgFromSQLite(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + abs, err := filepath.Abs(dbPath) + if err != nil { + t.Fatalf("abs: %v", err) + } + + // 第一遍:空库 → BootstrapPersistence 自动从 seedCfg 建 seed。 + seedCfg := cfgWithSQLiteAt(t, abs) + plugins1 := newPluginsForCfg(t, seedCfg) + r1, err := BootstrapPersistence(context.Background(), seedCfg, plugins1) + if err != nil { + t.Fatalf("first BootstrapPersistence (seed): %v", err) + } + if r1.Store == nil { + t.Fatal("first run Store = nil; cannot inject DB content") + } + // 用 dbStoredCfg 覆盖库内容(模拟 WebUI 编辑后的持久化状态)。 + dbStoredCfg := cfgFromYAML(t, dbStoredCfgYAML) + if _, err := r1.Store.SaveConfig(context.Background(), &dbStoredCfg); err != nil { + t.Fatalf("SaveConfig to inject DB content: %v", err) + } + if r1.Shutdown != nil { + r1.Shutdown() + } + + // 第二遍:DB 现在有 db-provider/db-alias(取代了 seed 的 yaml-placeholder), + // 应覆盖输入 cfg(YAML 自带 yaml-placeholder,但 DB 整体替换后只剩 db-provider)。 + yamlCfg := cfgWithSQLiteAt(t, abs) + plugins2 := newPluginsForCfg(t, yamlCfg) + result, err := BootstrapPersistence(context.Background(), yamlCfg, plugins2) + if err != nil { + t.Fatalf("second BootstrapPersistence: %v", err) + } + if result.Shutdown != nil { + defer result.Shutdown() + } + + if !result.Overridden { + t.Fatal("Overridden = false, want true (DB should override cfg)") + } + if got := len(result.Cfg.ProviderDefs); got != 1 { + t.Fatalf("ProviderDefs count = %d, want 1 (from DB); got %+v", got, result.Cfg.ProviderDefs) + } + def, ok := result.Cfg.ProviderDefs["db-provider"] + if !ok { + t.Fatalf("ProviderDefs missing db-provider; got keys %+v", result.Cfg.ProviderDefs) + } + if def.BaseURL != "https://db.example.test" { + t.Errorf("db-provider BaseURL = %q, want https://db.example.test", def.BaseURL) + } + if _, ok := result.Cfg.Routes["db-alias"]; !ok { + t.Errorf("Routes missing db-alias; got %+v", result.Cfg.Routes) + } + if result.Store == nil { + t.Error("Store = nil, want non-nil (server runtime needs it)") + } +} + +// TestBootstrapPersistence_SeedsWhenDBEmpty 验证空库分支: +// DB 未初始化时,从 YAML seed 到 DB,cfg 不变。 +func TestBootstrapPersistence_SeedsWhenDBEmpty(t *testing.T) { + abs, err := filepath.Abs(filepath.Join(t.TempDir(), "empty.db")) + if err != nil { + t.Fatalf("abs: %v", err) + } + inCfg := cfgWithSQLiteAt(t, abs) + + plugins := newPluginsForCfg(t, inCfg) + result, err := BootstrapPersistence(context.Background(), inCfg, plugins) + if err != nil { + t.Fatalf("BootstrapPersistence: %v", err) + } + if result.Shutdown != nil { + defer result.Shutdown() + } + + if result.Overridden { + t.Error("Overridden = true, want false (empty DB should not override)") + } + // cfg 应保持输入内容,未被覆盖。 + if result.Cfg.Addr != inCfg.Addr { + t.Errorf("cfg.Addr = %q, want original %q", result.Cfg.Addr, inCfg.Addr) + } + // store 应已 seed:再次 LoadAll 应能拿到数据。 + if result.Store == nil { + t.Fatal("Store = nil; want non-nil after seed") + } + loaded, loadErr := result.Store.LoadAll() + if loadErr != nil { + t.Fatalf("post-seed LoadAll: %v", loadErr) + } + if len(loaded.ProviderDefs) == 0 && len(loaded.Routes) == 0 { + t.Error("post-seed DB still empty; seed did not populate config") + } +} + +// TestBootstrapPersistence_DisabledPersistenceReturnsOriginalCfg 验证: +// 未启用持久化(无 db_sqlite extension)时,Store 为 nil,cfg 不变。 +func TestBootstrapPersistence_DisabledPersistenceReturnsOriginalCfg(t *testing.T) { + // 无 Persistence.ActiveProvider,无 db_sqlite extension。 + inCfg := cfgFromYAML(t, ` +mode: Transform +server: + addr: "127.0.0.1:0" +models: + yaml-model: + context_window: 128000 +providers: + yaml-placeholder: + base_url: "https://yaml.example.test" + api_key: "yaml-key" + protocol: anthropic + offers: + - model: yaml-model +routes: + yaml-alias: + provider: yaml-placeholder + model: yaml-model +`) + + plugins := newPluginsForCfg(t, inCfg) + result, err := BootstrapPersistence(context.Background(), inCfg, plugins) + if err != nil { + t.Fatalf("BootstrapPersistence: %v", err) + } + if result.Shutdown != nil { + defer result.Shutdown() + } + + if result.Overridden { + t.Error("Overridden = true, want false when persistence disabled") + } + if result.Store != nil { + t.Errorf("Store = %v, want nil when persistence disabled", result.Store) + } + if result.Cfg.Addr != inCfg.Addr { + t.Errorf("cfg.Addr = %q, want %q (unchanged)", result.Cfg.Addr, inCfg.Addr) + } +}