Files

218 lines
6.9 KiB
Go

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")
}
}