package service import ( "context" "fmt" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "oci-portal/internal/model" ) // newSystemLogEnv 建一个只含 SystemLog 表的内存库环境。 func newSystemLogEnv(t *testing.T) (*SystemLogService, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open in-memory sqlite: %v", err) } // :memory: 库每个连接彼此独立,Record 的异步 goroutine 可能拿到新连接而丢表,锁单连接 sqlDB, err := db.DB() if err != nil { t.Fatalf("db handle: %v", err) } sqlDB.SetMaxOpenConns(1) if err := db.AutoMigrate(&model.SystemLog{}); err != nil { t.Fatalf("auto migrate: %v", err) } return NewSystemLogService(db), db } // seedLogs 由旧到新插入 n 条日志,时间逐分钟递增,路径 / 用户名带序号便于断言。 func seedLogs(t *testing.T, db *gorm.DB, n int) { t.Helper() base := time.Now().Add(-time.Duration(n) * time.Minute) for i := 0; i < n; i++ { row := model.SystemLog{ Username: fmt.Sprintf("user-%02d", i), Method: "POST", Path: fmt.Sprintf("/api/v1/res-%02d", i), Status: 200, CreatedAt: base.Add(time.Duration(i) * time.Minute), } if err := db.Create(&row).Error; err != nil { t.Fatalf("seed log %d: %v", i, err) } } } func TestSystemLogList(t *testing.T) { tests := []struct { name string q SystemLogQuery wantLen int wantTotal int64 wantFirstPath string // 空串表示不校验首条 }{ {name: "零值分页取默认 20 条最新在前", q: SystemLogQuery{}, wantLen: 20, wantTotal: 25, wantFirstPath: "/api/v1/res-24"}, {name: "第二页取余下 5 条", q: SystemLogQuery{Page: 2, PageSize: 20}, wantLen: 5, wantTotal: 25, wantFirstPath: "/api/v1/res-04"}, {name: "pageSize 超上限被钳制仍能取全量", q: SystemLogQuery{PageSize: 200}, wantLen: 25, wantTotal: 25}, {name: "关键字模糊匹配路径", q: SystemLogQuery{Keyword: "res-1"}, wantLen: 10, wantTotal: 10, wantFirstPath: "/api/v1/res-19"}, {name: "关键字模糊匹配用户名", q: SystemLogQuery{Keyword: "user-05"}, wantLen: 1, wantTotal: 1, wantFirstPath: "/api/v1/res-05"}, {name: "关键字无匹配返回空页", q: SystemLogQuery{Keyword: "no-such"}, wantLen: 0, wantTotal: 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc, db := newSystemLogEnv(t) seedLogs(t, db, 25) items, total, err := svc.List(context.Background(), tt.q) if err != nil { t.Fatalf("List: %v", err) } if len(items) != tt.wantLen || total != tt.wantTotal { t.Errorf("got %d items total %d, want %d items total %d", len(items), total, tt.wantLen, tt.wantTotal) } if tt.wantFirstPath != "" && items[0].Path != tt.wantFirstPath { t.Errorf("first path = %q, want %q", items[0].Path, tt.wantFirstPath) } }) } } func TestSystemLogListStableOrder(t *testing.T) { svc, db := newSystemLogEnv(t) // 同一时间戳的两条记录按 id 倒序,保证翻页顺序稳定 ts := time.Now().Truncate(time.Second) for i := 0; i < 2; i++ { row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: ts} if err := db.Create(&row).Error; err != nil { t.Fatalf("seed: %v", err) } } items, _, err := svc.List(context.Background(), SystemLogQuery{}) if err != nil { t.Fatalf("List: %v", err) } if len(items) != 2 || items[0].ID <= items[1].ID { t.Errorf("order = %+v, want id desc within same created_at", items) } } func TestSystemLogRecord(t *testing.T) { tests := []struct { name string async bool }{ {name: "record 同步落库", async: false}, {name: "Record 异步落库", async: true}, } entry := model.SystemLog{ Username: "admin", Method: "PUT", Path: "/api/v1/oci-configs/:id", Status: 200, DurationMs: 18, ClientIP: "203.0.113.7", } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc, db := newSystemLogEnv(t) if tt.async { svc.Record(entry) svc.Wait() } else if err := svc.record(context.Background(), entry); err != nil { t.Fatalf("record: %v", err) } var got model.SystemLog if err := db.First(&got).Error; err != nil { t.Fatalf("load row: %v", err) } got.ID, got.CreatedAt = 0, entry.CreatedAt // 自动填充字段不参与对比 if got != entry { t.Errorf("row = %+v, want %+v", got, entry) } }) } } func TestSystemLogRecordFillsCreatedAt(t *testing.T) { svc, db := newSystemLogEnv(t) if err := svc.record(context.Background(), model.SystemLog{Path: "/p"}); err != nil { t.Fatalf("record: %v", err) } var got model.SystemLog if err := db.First(&got).Error; err != nil { t.Fatalf("load row: %v", err) } if got.CreatedAt.IsZero() { t.Error("createdAt is zero, want auto filled") } } func TestSystemLogCleanup(t *testing.T) { tests := []struct { name string ages []time.Duration // 每条记录距今的时长,由旧到新 retention time.Duration maxRows int wantPaths []string // 清理后应保留的路径(按时间升序编号 p0..pN) }{ { name: "只删过期", ages: []time.Duration{100 * 24 * time.Hour, time.Hour}, retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p1"}, }, { name: "超上限删最旧", ages: []time.Duration{5 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour}, retention: 90 * 24 * time.Hour, maxRows: 3, wantPaths: []string{"/p2", "/p3", "/p4"}, }, { name: "都未触发保持原样", ages: []time.Duration{2 * time.Hour, time.Hour}, retention: 90 * 24 * time.Hour, maxRows: 10, wantPaths: []string{"/p0", "/p1"}, }, { name: "过期与超量叠加", ages: []time.Duration{100 * 24 * time.Hour, 4 * time.Hour, 3 * time.Hour, 2 * time.Hour, time.Hour}, retention: 90 * 24 * time.Hour, maxRows: 2, wantPaths: []string{"/p3", "/p4"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc, db := newSystemLogEnv(t) for i, age := range tt.ages { row := model.SystemLog{Path: fmt.Sprintf("/p%d", i), CreatedAt: time.Now().Add(-age)} if err := db.Create(&row).Error; err != nil { t.Fatalf("seed %d: %v", i, err) } } if err := svc.cleanup(context.Background(), tt.retention, tt.maxRows); err != nil { t.Fatalf("cleanup: %v", err) } var paths []string if err := db.Model(&model.SystemLog{}).Order("created_at ASC").Pluck("path", &paths).Error; err != nil { t.Fatalf("load rows: %v", err) } if fmt.Sprint(paths) != fmt.Sprint(tt.wantPaths) { t.Errorf("kept = %v, want %v", paths, tt.wantPaths) } }) } } func TestSystemLogStartCleanupStopsOnCancel(t *testing.T) { svc, _ := newSystemLogEnv(t) ctx, cancel := context.WithCancel(context.Background()) svc.StartCleanup(ctx) cancel() done := make(chan struct{}) go func() { svc.Wait() close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("cleanup goroutine did not exit after context cancel") } }