package main import ( "context" "database/sql" "log/slog" "os" "path/filepath" "reflect" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件钉住用量落库与保留期清理**被接上组合根**(切片 U1;与 // TestWireDocumentGC_IsWired 同一手法): // // - sink 必须装进模型栈,且模型栈必须由组合根构造 —— 少任一处,落库都会 // **静默不工作**(日志照记、库里一行没有),而这不会让任何既有用例变红; // - 写入器与清理器必须在**关库之前**停掉(两者都要用那条连接池); // - 启动期必须真的把两者拉起来(`a.startUsage(ctx)`); // - 保留期 ≤0 = 关闭清理,且关闭是**显式**的(记一条 INFO)。 func TestWireUsage_IsWired(t *testing.T) { t.Parallel() seams := map[string][]string{ "wire_runtime.go": { "a.usage = a.newUsageSink()", "model.WithRuntimeUsageSink(a.usage)", }, "wire_usage.go": { "model.NewUsageWriter(a.db, a.log)", "a.cfg.Server.LLMUsageRetention", "model.PurgeUsage(ctx, g.db, before)", "a.log.Info(\"llm usage retention disabled\"", }, "wire_services.go": { // 先让单写者把队列里剩下的样本写完,再停清理,最后才关库。 "a.stopUsage()", }, "main.go": { "a.startUsage(ctx)", }, "wire_frontend.go": { // 管理端点的只读汇总出口(网关复用组合根已打开的连接池)。 "Usage: model.NewUsageReader(a.db)", }, } for file, wants := range seams { src, err := os.ReadFile(file) if err != nil { t.Fatalf("read %s: %v", file, err) } for _, want := range wants { if !strings.Contains(string(src), want) { t.Fatalf("%s 里找不到 %q —— 用量落库没有被接上组合根", file, want) } } } // 新 env 键必须与代码里的 env 标签逐字一致(AGENTS §10 铁律)。 raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) if err != nil { t.Fatalf("read .env.example: %v", err) } if !strings.Contains(string(raw), "LLM_USAGE_RETENTION=") { t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10)", "LLM_USAGE_RETENTION=") } if tag := envTagOf(t, "LLMUsageRetention"); tag != "LLM_USAGE_RETENTION" { t.Fatalf("config.ServerConfig.LLMUsageRetention 的 env 标签 = %q, 与模板不一致", tag) } } // envTagOf 返回 config.ServerConfig 上某字段的 env 标签。 func envTagOf(t *testing.T, field string) string { t.Helper() typ, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName(field) if !ok { t.Fatalf("config.ServerConfig 里没有字段 %q", field) } return typ.Tag.Get("env") } // usageGCDB 打开一个带迁移的内存库。 func usageGCDB(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, ":memory:", slog.New(slog.NewTextHandler(os.Stderr, nil))) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.MigrateUp(ctx, conn, slog.New(slog.NewTextHandler(os.Stderr, nil))); err != nil { t.Fatalf("migrate: %v", err) } return conn } // usageGCApp 构造一个只带清理器所需依赖的 app。 func usageGCApp(t *testing.T, conn *sql.DB, retention time.Duration) *app { t.Helper() return &app{ cfg: &config.Config{Server: config.ServerConfig{LLMUsageRetention: retention}}, db: conn, log: slog.New(slog.NewTextHandler(os.Stderr, nil)), } } // TestUsageRetentionGC_SweepRemovesExpiredOnly 钉住清理判据:早于保留期的样本被 // 删、保留期内的原样留着。 func TestUsageRetentionGC_SweepRemovesExpiredOnly(t *testing.T) { t.Parallel() ctx := context.Background() conn := usageGCDB(t) insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z") insertUsageAt(t, conn, "2099-01-01T00:00:00.000Z") gc := usageGCApp(t, conn, 720*time.Hour).newUsageRetentionGC() if gc == nil { t.Fatal("保留期 > 0 时必须构造清理器") } gc.sweep(ctx) if rows := countUsage(t, conn); rows != 1 { t.Fatalf("清理后剩 %d 行, want 1(只删过期那条)", rows) } var remaining string if err := conn.QueryRowContext(ctx, `SELECT created_at FROM llm_usage`).Scan(&remaining); err != nil { t.Fatalf("read remaining: %v", err) } if remaining != "2099-01-01T00:00:00.000Z" { t.Fatalf("剩下的是 %q, want 未过期那条", remaining) } } // TestUsageRetentionGC_DisabledWhenNonPositive 钉住「≤0 = 关闭清理」是显式关: // 0 与负值都不构造清理器(因而一行都不会被删),正数才构造。 func TestUsageRetentionGC_DisabledWhenNonPositive(t *testing.T) { t.Parallel() conn := usageGCDB(t) for _, retention := range []time.Duration{0, -time.Hour} { if gc := usageGCApp(t, conn, retention).newUsageRetentionGC(); gc != nil { t.Fatalf("LLM_USAGE_RETENTION=%s 时不该构造清理器", retention) } } // 关闭状态下过期行原样留着(没有任何代码路径去删它)。 insertUsageAt(t, conn, "2020-01-01T00:00:00.000Z") if rows := countUsage(t, conn); rows != 1 { t.Fatalf("关闭清理却动了数据:剩 %d 行", rows) } if gc := usageGCApp(t, conn, time.Hour).newUsageRetentionGC(); gc == nil { t.Fatal("LLM_USAGE_RETENTION=1h 时必须构造清理器") } } // TestUsageWriter_NilDBMeansNoSink 钉住「库未接 ⇒ sink 为 nil ⇒ 只记日志」的 // 组合根口径(与 model 层的 nil-sink 行为合成一条链)。 func TestUsageWriter_NilDBMeansNoSink(t *testing.T) { t.Parallel() a := &app{log: slog.New(slog.NewTextHandler(os.Stderr, nil))} if sink := a.newUsageSink(); sink != nil { t.Fatalf("库未接时 newUsageSink 必须返回 nil,实得 %#v", sink) } if gc := a.newUsageRetentionGC(); gc != nil { t.Fatal("库未接时不该构造清理器") } // 未注入 sink 的模型栈照常构造(不 panic),且模型栈不因 nil sink 变红。 stack := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, a.log) if stack == nil { t.Fatal("nil sink 不该影响模型栈构造") } } // insertUsageAt 写一行带指定 created_at 的用量样本。 func insertUsageAt(t *testing.T, conn *sql.DB, createdAt string) { t.Helper() if _, err := conn.ExecContext(context.Background(), `INSERT INTO llm_usage (purpose, adapter, model, prompt_tokens, created_at) VALUES ('chat', 'openai', 'm', 1, ?)`, createdAt); err != nil { t.Fatalf("insert usage row: %v", err) } } // countUsage 返回 llm_usage 行数。 func countUsage(t *testing.T, conn *sql.DB) int64 { t.Helper() var rows int64 if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM llm_usage`).Scan(&rows); err != nil { t.Fatalf("count llm_usage: %v", err) } return rows }