package repository import ( "context" "strings" "testing" "time" "yingce/backend/internal/model" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) type sqlCaptureLogger struct { statements []string } func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:api-log-record-types?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { t.Fatal(err) } now := time.Now() for _, item := range []model.ApiCallLog{ {ID: "request", Capability: "video", RequestKind: "create", CreatedAt: now}, {ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now}, {ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now}, {ID: "image-download", Capability: "image", RequestKind: "download", CreatedAt: now}, {ID: "upload", Capability: "image", RequestKind: "upload", CreatedAt: now}, {ID: "local-save", Capability: "image", RequestKind: "local_save", CreatedAt: now}, {ID: "register", Capability: "image", RequestKind: "register", CreatedAt: now}, } { if err := db.Create(&item).Error; err != nil { t.Fatal(err) } } repo := New(db) for _, tc := range []struct { kind string count int }{{"", 1}, {"request", 1}, {"download", 2}, {"all", 7}} { filter := APICallLogFilter{AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, RecordType: tc.kind} logs, total, err := repo.QueryAPICallLogs(filter) if err != nil { t.Fatal(err) } if len(logs) != tc.count || total != int64(tc.count) { t.Fatalf("kind=%s: count=%d total=%d", tc.kind, len(logs), total) } exported, err := repo.ExportAPICallLogs(filter, 100) if err != nil || len(exported) != tc.count { t.Fatalf("kind=%s: export count=%d err=%v", tc.kind, len(exported), err) } } } func TestSQLiteAPICallLogRangeUsesInstantNotOffsetText(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:api-log-utc-range?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { t.Fatal(err) } local := time.FixedZone("CST", 8*3600) inside := time.Date(2026, 10, 1, 2, 2, 34, 0, local) // 2026-09-30 18:02 UTC outside := time.Date(2026, 10, 1, 9, 0, 0, 0, local) // 2026-10-01 01:00 UTC for _, item := range []model.ApiCallLog{ {ID: "inside-local-midnight", Capability: "audio", RequestKind: "create", CreatedAt: inside}, {ID: "outside-utc-day", Capability: "audio", RequestKind: "create", CreatedAt: outside}, } { if err := db.Create(&item).Error; err != nil { t.Fatal(err) } } logs, total, err := New(db).QueryAPICallLogs(APICallLogFilter{ AnalyticsFilter: AnalyticsFilter{ From: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), }, RecordType: "request", }) if err != nil { t.Fatal(err) } if total != 1 || len(logs) != 1 || logs[0].ID != "inside-local-midnight" { ids := make([]string, 0, len(logs)) for _, log := range logs { ids = append(ids, log.ID) } t.Fatalf("total=%d ids=%v, want only the 02:02+08 record inside the UTC day", total, ids) } } func (l *sqlCaptureLogger) LogMode(logger.LogLevel) logger.Interface { return l } func (*sqlCaptureLogger) Info(context.Context, string, ...any) {} func (*sqlCaptureLogger) Warn(context.Context, string, ...any) {} func (*sqlCaptureLogger) Error(context.Context, string, ...any) {} func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, query func() (string, int64), _ error) { statement, _ := query() l.statements = append(l.statements, statement) } func TestRecordUserActivityQualifiesPostgresConflictColumns(t *testing.T) { capture := &sqlCaptureLogger{} db, err := gorm.Open(postgres.New(postgres.Config{ DSN: "host=localhost user=test dbname=test sslmode=disable", PreferSimpleProtocol: true, }), &gorm.Config{ DisableAutomaticPing: true, DryRun: true, Logger: capture, SkipDefaultTransaction: true, }) if err != nil { t.Fatalf("open dry-run postgres database: %v", err) } repo := New(db) cases := []struct { event string column string }{ {event: "login", column: "login_count"}, {event: "task", column: "task_count"}, {event: "agent_message", column: "agent_message_count"}, {event: "asset", column: "asset_count"}, {event: "resource", column: "resource_count"}, } for _, tc := range cases { t.Run(tc.event, func(t *testing.T) { capture.statements = nil if err := repo.RecordUserActivity("user-1", tc.event, 1, time.Unix(1_700_000_000, 0)); err != nil { t.Fatalf("record activity: %v", err) } if len(capture.statements) == 0 { t.Fatal("expected generated SQL") } statement := capture.statements[len(capture.statements)-1] if !strings.Contains(statement, "user_daily_activities."+tc.column) { t.Fatalf("conflict update column is not target-qualified: %s", statement) } if tc.event != "login" && !strings.Contains(statement, "COALESCE(user_daily_activities.first_active_at") { t.Fatalf("first active column is not target-qualified: %s", statement) } }) } } func TestQueryAPICallLogsSearchesFailureFields(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:api-log-error-search?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.User{}, &model.ModelChannel{}, &model.ApiCallLog{}); err != nil { t.Fatal(err) } now := time.Now() item := model.ApiCallLog{ ID: "api-log-1", UserID: "user-1", RequestKind: "create", Status: model.ApiCallStatusFailed, ErrorCode: "request_not_sent", Error: "模型服务拒绝了请求,请检查模型和参数;上游:invalid parameter: size", CreatedAt: now, } if err := db.Create(&item).Error; err != nil { t.Fatal(err) } repo := New(db) for _, keyword := range []string{"模型服务拒绝", "invalid parameter", "request_not_sent"} { logs, total, err := repo.QueryAPICallLogs(APICallLogFilter{ AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, Keyword: keyword, Page: 1, Limit: 20, }) if err != nil { t.Fatalf("QueryAPICallLogs(%q) error = %v", keyword, err) } if total != 1 || len(logs) != 1 || logs[0].ID != item.ID { t.Fatalf("QueryAPICallLogs(%q) = total:%d logs:%#v", keyword, total, logs) } } } func TestQueryAPICallLogsHidesInternalPollStages(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:api-log-visible-stages?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { t.Fatal(err) } now := time.Now() logs := []model.ApiCallLog{ {ID: "image-create", UserID: "user-1", Capability: "image", RequestKind: "create", CreatedAt: now}, {ID: "image-poll", UserID: "user-1", Capability: "image", RequestKind: "poll", CreatedAt: now.Add(time.Second)}, {ID: "image-download", UserID: "user-1", Capability: "image", RequestKind: "download", CreatedAt: now.Add(2 * time.Second)}, {ID: "video-create", UserID: "user-1", Capability: "video", RequestKind: "create", CreatedAt: now.Add(3 * time.Second)}, {ID: "video-poll", UserID: "user-1", Capability: "video", RequestKind: "poll", CreatedAt: now.Add(4 * time.Second)}, } if err := db.Create(&logs).Error; err != nil { t.Fatal(err) } items, total, err := New(db).QueryAPICallLogs(APICallLogFilter{ AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, Page: 1, Limit: 20, }) if err != nil { t.Fatal(err) } if total != 2 || len(items) != 2 { t.Fatalf("visible logs = total:%d items:%#v, want only create logs without polls or downloads", total, items) } if items[0].ID != "video-create" || items[1].ID != "image-create" { t.Fatalf("visible logs = %#v, want video-create and image-create", items) } } func TestLatestProviderRequestIDsForTasksReturnsNewestPerTask(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:latest-provider-ids?mode=memory&cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil { t.Fatal(err) } base := time.Now().Add(-time.Hour) for _, log := range []model.ApiCallLog{ {ID: "old-task-1", TaskID: "task-1", ProviderRequestID: "provider-old", CreatedAt: base}, {ID: "new-task-1", TaskID: "task-1", ProviderRequestID: "provider-new", CreatedAt: base.Add(2 * time.Minute)}, {ID: "task-2", TaskID: "task-2", ProviderRequestID: "provider-2", CreatedAt: base.Add(time.Minute)}, {ID: "empty", TaskID: "task-3", ProviderRequestID: "", CreatedAt: base.Add(3 * time.Minute)}, } { if err := db.Create(&log).Error; err != nil { t.Fatal(err) } } ids, err := New(db).LatestProviderRequestIDsForTasks([]string{"task-1", "task-2", "task-3"}) if err != nil { t.Fatal(err) } if ids["task-1"] != "provider-new" || ids["task-2"] != "provider-2" { t.Fatalf("unexpected provider IDs: %#v", ids) } if _, ok := ids["task-3"]; ok { t.Fatalf("empty provider ID should be omitted: %#v", ids) } }