package service import ( "fmt" "strings" "testing" "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" "wails-app/internal/dao" "wails-app/internal/global" "wails-app/internal/modules/incident/repository" ) func setupIncidentTest(t *testing.T) *IncidentService { t.Helper() dsn := fmt.Sprintf("file:incident-%d?mode=memory&cache=shared", time.Now().UnixNano()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) t.Cleanup(func() { _ = sqlDB.Close() if global.GVA_DB == db { global.GVA_DB = nil } }) global.GVA_DB = db require.NoError(t, db.AutoMigrate( &dao.IncidentRecord{}, &dao.DeviceCommandLog{}, &dao.SysUser{}, &dao.ParkingLot{}, &dao.VehicleRecord{}, &dao.DigitalTicket{}, &dao.PaymentRecord{}, &dao.Channel{}, &dao.UHFReader{}, )) return NewIncidentService() } func countIncidents(t *testing.T) int64 { var count int64 require.NoError(t, global.GVA_DB.Model(&dao.IncidentRecord{}).Count(&count).Error) return count } func TestIncidentFullLifecycle(t *testing.T) { svc := setupIncidentTest(t) // 人工上报 → pending created, err := svc.CreateIncident(CreateIncidentRequest{ Category: CategoryNoEntryExit, Description: "无牌车无入场记录出场", PlateNumber: "SU-001", }, 1) require.NoError(t, err) require.Equal(t, StatusPending, created.Status) require.Equal(t, SourceManual, created.Source) require.Equal(t, LevelCritical, created.Level) // 分类默认等级 require.True(t, strings.HasPrefix(created.IncidentNo, "INC")) // pending → processing processing, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true) require.NoError(t, err) require.Equal(t, StatusProcessing, processing.Status) require.Equal(t, uint(2), processing.HandlerID) // processing → resolved(缺处置方式拒绝) _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleRemark: "已核实"}, 2, true) require.Error(t, err) // 缺备注拒绝 _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusResolved, HandleType: HandleReset}, 2, true) require.Error(t, err) // processing → resolved(合法) resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{ ToStatus: StatusResolved, HandleType: HandleReset, HandleRemark: "已人工核实放行", }, 2, true) require.NoError(t, err) require.Equal(t, StatusResolved, resolved.Status) require.NotNil(t, resolved.HandledAt) require.Equal(t, HandleReset, resolved.HandleType) require.Contains(t, resolved.EventLog, "resolved") // resolved → closed closed, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed, HandleRemark: "关闭"}, 2, true) require.NoError(t, err) require.Equal(t, StatusClosed, closed.Status) // closed → pending(重新打开) reopened, err := svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusPending, HandleRemark: "复核中"}, 2, true) require.NoError(t, err) require.Equal(t, StatusPending, reopened.Status) } func TestIncidentInvalidTransitions(t *testing.T) { svc := setupIncidentTest(t) created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1) require.NoError(t, err) // pending 不允许直接 closed _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusClosed}, 2, true) require.Error(t, err) // 未知目标状态 _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: "unknown"}, 2, true) require.Error(t, err) // 不存在的记录 _, err = svc.TransitionIncident(99999, TransitionRequest{ToStatus: StatusProcessing}, 2, true) require.ErrorIs(t, err, ErrIncidentNotFound) } func TestForceFreeRequiresAdminAndValidAmount(t *testing.T) { svc := setupIncidentTest(t) created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1) require.NoError(t, err) // 非管理员禁止强制免费 _, err = svc.TransitionIncident(created.ID, TransitionRequest{ ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20, }, 2, false) require.Error(t, err) require.Contains(t, err.Error(), "管理员权限") // 负金额拒绝 _, err = svc.TransitionIncident(created.ID, TransitionRequest{ ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: -1, }, 2, true) require.Error(t, err) // 管理员 + 合法金额 resolved, err := svc.TransitionIncident(created.ID, TransitionRequest{ ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 20, }, 2, true) require.NoError(t, err) require.Equal(t, 20.0, resolved.ForceFreeAmount) } func TestRecordIncidentBestEffortAndDedup(t *testing.T) { svc := setupIncidentTest(t) // 正常记录 id := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败", Detail: "timeout", }) require.NotZero(t, id) require.Equal(t, int64(1), countIncidents(t)) // 同设备同车 5 分钟内重复 → 去重 id2 := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败", }) require.Zero(t, id2) require.Equal(t, int64(1), countIncidents(t)) // 不同设备 → 生成 id3 := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-02", PlateNumber: "SU-001", Description: "开闸失败", }) require.NotZero(t, id3) // 无车辆标识 → 不去重 id4 := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", Description: "开闸失败", }) require.NotZero(t, id4) // 数据库未初始化(模拟 DB 故障)→ best-effort 返回 0 不 panic global.GVA_DB = nil id5 := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "GATE-X", Description: "离线", }) require.Zero(t, id5) } func TestDeviceOfflineDedupAndResolve(t *testing.T) { svc := setupIncidentTest(t) // 首次离线生成 id := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线", }) require.NotZero(t, id) // 重复离线(未关闭)→ 去重 require.Zero(t, svc.RecordIncident(RecordIncidentRequest{ Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线", })) // 设备恢复在线 → 自动关闭 svc.ResolveDeviceOffline("READER-01") var record dao.IncidentRecord require.NoError(t, global.GVA_DB.First(&record, id).Error) require.Equal(t, StatusResolved, record.Status) require.Equal(t, HandleDeviceRepaired, record.HandleType) require.Contains(t, record.EventLog, "device_repaired") // 再次离线(已解决,可再生成) require.NotZero(t, svc.RecordIncident(RecordIncidentRequest{ Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "UHF 读卡器离线", })) } func TestIncidentNoSequential(t *testing.T) { svc := setupIncidentTest(t) a, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1) require.NoError(t, err) b, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1) require.NoError(t, err) require.NotEqual(t, a.IncidentNo, b.IncidentNo) require.True(t, strings.HasPrefix(a.IncidentNo, "INC"+time.Now().Format("20060102"))) } func TestIncidentStats(t *testing.T) { svc := setupIncidentTest(t) svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"}) svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"}) // 强制免费处置 created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryNoEntryExit, Description: "丢票"}, 1) require.NoError(t, err) _, err = svc.TransitionIncident(created.ID, TransitionRequest{ ToStatus: StatusResolved, HandleType: HandleForceFree, HandleRemark: "强制免费", ForceFreeAmount: 15, }, 2, true) require.NoError(t, err) stats, err := svc.GetIncidentStats() require.NoError(t, err) require.Equal(t, int64(2), stats.Pending) // gate_failed + device_offline require.Equal(t, int64(1), stats.Resolved) // no_entry_exit 已处置 require.Equal(t, int64(3), stats.TodayCreated) require.Equal(t, int64(1), stats.OpenFundRelated) // 未关闭资金类:gate_failed(no_entry_exit 已 resolved) require.Equal(t, 15.0, stats.TodayForceFreeAmount) } func TestRecordGateCommandAndLink(t *testing.T) { svc := setupIncidentTest(t) // 指令流水写入 logID := svc.RecordGateCommand(GateCommandRecord{ DeviceCode: "GATE-01", DeviceName: "东门道闸", Action: "open", Source: SourcePassage, SessionID: 7, Result: CommandFailed, ErrorMessage: "timeout", DurationMs: 30, }) require.NotZero(t, logID) // 异常关联回填 incidentID := svc.RecordIncident(RecordIncidentRequest{ Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败", }) require.NotZero(t, incidentID) svc.LinkIncidentToCommand(logID, incidentID) var log dao.DeviceCommandLog require.NoError(t, global.GVA_DB.First(&log, logID).Error) require.Equal(t, incidentID, log.IncidentID) var incident dao.IncidentRecord require.NoError(t, global.GVA_DB.First(&incident, incidentID).Error) require.Equal(t, logID, incident.GateCommandID) // 空设备编码不写流水 require.Zero(t, svc.RecordGateCommand(GateCommandRecord{Action: "open", Result: CommandSuccess})) } func TestIncidentAssociationEnrichment(t *testing.T) { svc := setupIncidentTest(t) lot := dao.ParkingLot{LotName: "一号停车场"} require.NoError(t, global.GVA_DB.Create(&lot).Error) channel := dao.Channel{ChannelCode: "ENTRY-01", ChannelName: "东入口", Direction: "in", ParkingLotID: lot.ID} require.NoError(t, global.GVA_DB.Create(&channel).Error) device := dao.UHFReader{DeviceCode: "GATE-01", DeviceName: "东入口闸机", DeviceType: "gate", ConnectType: "tcp", ChannelID: channel.ID, ParkingLotID: lot.ID} require.NoError(t, global.GVA_DB.Create(&device).Error) session := dao.VehicleRecord{PlateNumber: "粤A12345", EntryTime: time.Now(), ParkingLotID: lot.ID, EntryChannelID: channel.ID, EntryChannelCode: channel.ChannelCode, EntryChannelName: channel.ChannelName, EntryDeviceCode: device.DeviceCode, EntryDeviceName: device.DeviceName} require.NoError(t, global.GVA_DB.Create(&session).Error) ticket := dao.DigitalTicket{TicketNo: "TICKET-001", PlateNumber: session.PlateNumber, VehicleRecordID: session.ID, EntryTime: time.Now()} require.NoError(t, global.GVA_DB.Create(&ticket).Error) payment := dao.PaymentRecord{RecordID: session.ID, PaymentEntry: "counter", PaymentMethod: "cash", PaidAt: time.Now()} require.NoError(t, global.GVA_DB.Create(&payment).Error) created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryPaymentFailed, TicketNo: ticket.TicketNo, Description: "支付异常"}, 1) require.NoError(t, err) require.Equal(t, session.ID, created.VehicleRecordID) require.Equal(t, ticket.ID, created.DigitalTicketID) require.Equal(t, payment.ID, created.PaymentRecordID) require.Equal(t, lot.ID, created.ParkingLotID) require.Equal(t, lot.LotName, created.ParkingLotName) require.Equal(t, channel.ID, created.ChannelID) require.Equal(t, channel.ChannelName, created.ChannelName) require.Equal(t, device.DeviceName, created.DeviceName) _, err = svc.CreateIncident(CreateIncidentRequest{Category: CategoryManualRaise, VehicleRecordID: 999999, Description: "错误关联"}, 1) require.ErrorContains(t, err, "停车会话不存在") } func TestTransitionCASConcurrency(t *testing.T) { svc := setupIncidentTest(t) created, err := svc.CreateIncident(CreateIncidentRequest{Category: CategoryLotFull, Description: "满位"}, 1) require.NoError(t, err) // 第一条流转成功 _, err = svc.TransitionIncident(created.ID, TransitionRequest{ToStatus: StatusProcessing}, 2, true) require.NoError(t, err) // 基于陈旧状态直接 CAS 更新(模拟并发竞争中落后的一方)→ 不生效 updated, err := svc.repo.UpdateStateIfCurrent(created.ID, StatusPending, StatusProcessing, map[string]interface{}{"handler_id": 3}) require.NoError(t, err) require.False(t, updated) // 基于当前状态 CAS 更新 → 生效 updated, err = svc.repo.UpdateStateIfCurrent(created.ID, StatusProcessing, StatusResolved, map[string]interface{}{"handle_type": HandleReset, "handle_remark": "并发竞争后生效"}) require.NoError(t, err) require.True(t, updated) } func TestIncidentListFilter(t *testing.T) { svc := setupIncidentTest(t) svc.RecordIncident(RecordIncidentRequest{Category: CategoryGateFailed, Source: SourcePassage, DeviceCode: "GATE-01", PlateNumber: "SU-001", Description: "开闸失败"}) svc.RecordIncident(RecordIncidentRequest{Category: CategoryDeviceOffline, Source: SourceDevice, DeviceCode: "READER-01", Description: "离线"}) // 按分类筛选 list, total, err := svc.ListIncidents(repository.IncidentListQuery{Category: CategoryDeviceOffline}) _ = list require.NoError(t, err) require.Equal(t, int64(1), total) // 分页默认值 list, total, err = svc.ListIncidents(repository.IncidentListQuery{}) require.NoError(t, err) require.Equal(t, int64(2), total) require.Len(t, list, 2) }