package operations import ( "context" "errors" "testing" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission" deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/media" ) func operationsTestService(t *testing.T) (*Service, *gorm.DB, time.Time) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err = db.AutoMigrate(&deviceModels.Device{}, &admissionResult{}, &media.Route{}, &adminModels.SysOperaLog{}); err != nil { t.Fatal(err) } now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC) service := NewService(db) service.Now = func() time.Time { return now } service.LocalInferenceConfigured = false service.DeviceRetry = nil return service, db, now } func TestProjectionCoversOperationsStatesAndIndependentBoundaries(t *testing.T) { service, db, now := operationsTestService(t) devices := []deviceModels.Device{ {ID: "ready", Name: "东门", Location: "一层", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1}, {ID: "auth", Name: "仓库", Location: "北区", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3}, {ID: "clock", Name: "南门", Location: "室外", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 4}, } for index := range devices { devices[index].CreatedAt, devices[index].UpdatedAt = now, now } if err := db.Create(&devices).Error; err != nil { t.Fatal(err) } admissions := []admissionResult{ {DeviceID: "ready", Address: "http://192.0.2.10/onvif", Status: "ready", Detail: "接入验证完成", CheckedAt: now}, {DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "ONVIF 认证失败", CheckedAt: now}, {DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移 96 秒", CheckedAt: now}, } if err := db.Create(&admissions).Error; err != nil { t.Fatal(err) } next := now.Add(5 * time.Minute) routes := []media.Route{ {ID: "auth:main", DeviceID: "auth", ProfileToken: "main", Path: "sense_auth", Desired: "running", Actual: "apply_failed", Detail: "媒体路径配置失败", FailureCount: 2, NextRetryAt: &next, Version: 5, UpdatedAt: now}, {ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "sense_orphan", Desired: "running", Actual: "ready", Detail: "上游拉流正常", Version: 1, UpdatedAt: now}, } if err := db.Create(&routes).Error; err != nil { t.Fatal(err) } response, err := service.List(PageRequest{}) if err != nil { t.Fatal(err) } if response.Summary.ManagedCount != 5 || response.Summary.ConvergedCount != 1 || response.Summary.ActionableProblems != 4 || response.Summary.LocalInference != "unavailable" || response.Count != 5 { t.Fatalf("unexpected summary: %#v count=%d", response.Summary, response.Count) } wanted := map[string]bool{ProblemAuthentication: false, ProblemClockDrift: false, ProblemBackoff: false, ProblemOrphan: false, ProblemUnavailable: false} for _, item := range response.List { wanted[item.ProblemType] = true if !item.OperationalOnly { t.Fatalf("problem can be mistaken for Bell alert: %#v", item) } } for state, found := range wanted { if !found { t.Fatalf("missing problem state %s: %#v", state, response.List) } } filtered, err := service.List(PageRequest{ObjectType: ObjectDevice, ProblemType: ProblemClockDrift, Keyword: "南门"}) if err != nil || filtered.Count != 1 || filtered.List[0].ObjectID != "clock" { t.Fatalf("filtered=%#v err=%v", filtered, err) } } func TestControlledRetryUsesVersionAndInProgressGate(t *testing.T) { service, db, now := operationsTestService(t) device := deviceModels.Device{ID: "auth", Name: "东门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3} device.CreatedAt, device.UpdatedAt = now, now if err := db.Create(&device).Error; err != nil { t.Fatal(err) } if err := db.Create(&admissionResult{DeviceID: "auth", Address: "http://192.0.2.11/onvif", Status: "authentication_failed", Detail: "认证失败", CheckedAt: now}).Error; err != nil { t.Fatal(err) } item, err := service.Retry(context.Background(), "device:auth", 3, 7) if err != nil || !item.RetryInProgress || item.Retryable || item.Version != 4 { t.Fatalf("item=%#v err=%v", item, err) } if _, err = service.Retry(context.Background(), "device:auth", 3, 7); !errors.Is(err, ErrRetryInProgress) { t.Fatalf("expected in-progress gate, got %v", err) } var stored deviceModels.Device if err = db.First(&stored, "id = ?", "auth").Error; err != nil || stored.RetryRequestedAt == nil { t.Fatalf("stored=%#v err=%v", stored, err) } } func TestMediaRetryQueuesWithoutDeletingOrChangingCredentials(t *testing.T) { service, db, now := operationsTestService(t) device := deviceModels.Device{ID: "camera", Name: "仓库", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "ready", Version: 1} device.CreatedAt, device.UpdatedAt = now, now if err := db.Create(&device).Error; err != nil { t.Fatal(err) } if err := db.Create(&admissionResult{DeviceID: "camera", Address: "http://192.0.2.20/onvif", Status: "ready", Detail: "接入完成", CheckedAt: now}).Error; err != nil { t.Fatal(err) } next := now.Add(time.Minute) route := media.Route{ID: "camera:main", DeviceID: "camera", ProfileToken: "main", Path: "sense_camera", Desired: "running", Actual: "apply_failed", FailureCount: 2, NextRetryAt: &next, Detail: "配置失败", Version: 6, UpdatedAt: now} if err := db.Create(&route).Error; err != nil { t.Fatal(err) } item, err := service.Retry(context.Background(), "media:camera:main", 6, 7) if err != nil || item.Actual != "retry_pending" || item.Version != 7 || !item.RetryInProgress { t.Fatalf("item=%#v err=%v", item, err) } var count int64 if err = db.Model(&media.Route{}).Where("id = ?", route.ID).Count(&count).Error; err != nil || count != 1 { t.Fatalf("route was removed: count=%d err=%v", count, err) } } func TestOrphanAndUnavailableCannotRetry(t *testing.T) { service, db, now := operationsTestService(t) route := media.Route{ID: "missing:main", DeviceID: "missing", ProfileToken: "main", Path: "orphan", Desired: "running", Actual: "ready", Version: 1, UpdatedAt: now} if err := db.Create(&route).Error; err != nil { t.Fatal(err) } if _, err := service.Retry(context.Background(), "media:missing:main", 1, 7); !errors.Is(err, ErrRetryNotAllowed) { t.Fatalf("orphan retry should be rejected: %v", err) } if _, err := service.Retry(context.Background(), "local_inference:adapter", 1, 7); !errors.Is(err, ErrRetryNotAllowed) { t.Fatalf("optional adapter retry should be rejected: %v", err) } } func TestDeviceRetryUsesExistingAdmissionPortAndOperatorIdentity(t *testing.T) { service, db, now := operationsTestService(t) device := deviceModels.Device{ID: "clock", Name: "南门", Modality: "video", CapabilitiesJSON: "[]", Status: "active", AdapterStatus: "verification_failed", Version: 3} device.CreatedAt, device.UpdatedAt = now, now if err := db.Create(&device).Error; err != nil { t.Fatal(err) } if err := db.Create(&admissionResult{DeviceID: "clock", Address: "http://192.0.2.12/onvif", Status: "clock_drift", Detail: "设备时间漂移", CheckedAt: now}).Error; err != nil { t.Fatal(err) } called := false service.DeviceRetry = func(_ context.Context, request admission.ProbeRequest) error { called = true if request.DeviceID != "clock" || request.Address != "http://192.0.2.12/onvif" || request.Version != 4 || request.UpdateBy != 9 { t.Fatalf("unexpected retry request: %#v", request) } return db.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(map[string]any{"retry_requested_at": nil, "version": request.Version + 1}).Error } item, err := service.Retry(context.Background(), "device:clock", 3, 9) if err != nil || !called || item.Version != 5 || item.RetryInProgress { t.Fatalf("item=%#v called=%v err=%v", item, called, err) } } func TestAuditIsMinimalAndDesensitized(t *testing.T) { _, db, now := operationsTestService(t) if err := WriteAudit(db, Audit{Action: "Retry", Method: "POST", Status: "1", Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/operations/:id/retry", Remark: "受控重试已排队 device:synthetic", At: now}); err != nil { t.Fatal(err) } var stored adminModels.SysOperaLog if err := db.First(&stored).Error; err != nil { t.Fatal(err) } if stored.Title != "运维中心" || stored.RequestMethod != "POST" || stored.OperParam != "" || stored.JsonResult != "" || stored.CreateBy != 7 { t.Fatalf("unexpected audit: %#v", stored) } }