188 lines
8.8 KiB
Go
188 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|