executor_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. package faultRecordQueue
  2. import (
  3. "context"
  4. "encoding/csv"
  5. "encoding/json"
  6. "fmt"
  7. "os"
  8. "strings"
  9. "sync"
  10. "testing"
  11. "time"
  12. "fwh-toolbox-server/domain"
  13. proto "fwh-toolbox-server/function/FaultRecordProtocol"
  14. "fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
  15. "go.mongodb.org/mongo-driver/bson"
  16. "go.mongodb.org/mongo-driver/bson/primitive"
  17. )
  18. const (
  19. sampleLog = "../../../../docs/fault-record/CloudLog_2026-10-14_104644.csv"
  20. sampleArchive = "../../../../docs/fault-record/2026-10-14_05_46_20.tar.bz2"
  21. sampleGateway = "AFCI_TEST_0713"
  22. sampleBatch = "2026-10-14_05_46_20"
  23. )
  24. /************** fakes **************/
  25. type memStore struct {
  26. mu sync.Mutex
  27. ops map[primitive.ObjectID]*domain.FaultRecordOperation
  28. }
  29. func (s *memStore) Get(_ context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error) {
  30. s.mu.Lock()
  31. defer s.mu.Unlock()
  32. cp := *s.ops[id]
  33. cp.StageTimes = map[string]time.Time{}
  34. for k, v := range s.ops[id].StageTimes {
  35. cp.StageTimes[k] = v
  36. }
  37. return &cp, nil
  38. }
  39. func (s *memStore) UpdateStatus(_ context.Context, id primitive.ObjectID, st domain.FaultRecordOpStatus, extra bson.M) (bool, error) {
  40. s.mu.Lock()
  41. defer s.mu.Unlock()
  42. op := s.ops[id]
  43. if op.Status.Terminal() {
  44. return false, nil
  45. }
  46. op.Status = st
  47. op.StageTimes[string(st)] = time.Now()
  48. applyFields(op, extra)
  49. return true, nil
  50. }
  51. func (s *memStore) SetFields(_ context.Context, id primitive.ObjectID, f bson.M) error {
  52. s.mu.Lock()
  53. defer s.mu.Unlock()
  54. applyFields(s.ops[id], f)
  55. return nil
  56. }
  57. func (s *memStore) AppendMessage(_ context.Context, id primitive.ObjectID, m domain.FaultRecordOpMessage) error {
  58. s.mu.Lock()
  59. defer s.mu.Unlock()
  60. s.ops[id].Messages = append(s.ops[id].Messages, m)
  61. return nil
  62. }
  63. func applyFields(op *domain.FaultRecordOperation, f bson.M) {
  64. for k, v := range f {
  65. var idx int
  66. if n, _ := fmt.Sscanf(k, "messages.%d", &idx); n == 1 {
  67. op.Messages[idx] = v.(domain.FaultRecordOpMessage)
  68. continue
  69. }
  70. switch k {
  71. case "error":
  72. op.Error = v.(string)
  73. case "reply":
  74. r := v.(proto.Reply)
  75. op.Reply = &r
  76. case "fileName":
  77. op.FileName = v.(string)
  78. case "faultRecordId":
  79. op.FaultRecordID = v.(int64)
  80. case "uploadEquipNo":
  81. op.UploadEquipNo = v.(string)
  82. case "minioObject":
  83. op.MinioObject = v.(string)
  84. case "fileSize":
  85. op.FileSize = int64(v.(int))
  86. case "exitSent":
  87. op.ExitSent = v.(bool)
  88. case "timeoutStage":
  89. op.TimeoutStage = v.(domain.FaultRecordOpStatus)
  90. case "cancelRequested":
  91. op.CancelReq = v.(bool)
  92. }
  93. }
  94. }
  95. // fakeBus 收到 377 后按脚本回放消息
  96. type fakeBus struct {
  97. mu sync.Mutex
  98. handlers map[string][]fwh_mqtt.Handler
  99. published []fwh_mqtt.FwhMqttMsg
  100. script func(b *fakeBus, msg fwh_mqtt.FwhMqttMsg)
  101. }
  102. func (b *fakeBus) Subscribe(_ context.Context, topic string, h fwh_mqtt.Handler) (func(), error) {
  103. b.mu.Lock()
  104. defer b.mu.Unlock()
  105. if b.handlers == nil {
  106. b.handlers = map[string][]fwh_mqtt.Handler{}
  107. }
  108. b.handlers[topic] = append(b.handlers[topic], h)
  109. return func() {}, nil
  110. }
  111. func (b *fakeBus) Publish(_ context.Context, topic string, msg fwh_mqtt.FwhMqttMsg) error {
  112. b.mu.Lock()
  113. b.published = append(b.published, msg)
  114. script := b.script
  115. b.mu.Unlock()
  116. if script != nil {
  117. go script(b, msg)
  118. }
  119. return nil
  120. }
  121. func (b *fakeBus) push(raw string) {
  122. cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
  123. // 与实际一致:378/379 走 c2s/mng,380 走 s2c/mng;607/610 走 s2c/debug,608/609 走 c2s/debug
  124. topic := fwh_mqtt.C2STopic("mng/" + sampleGateway)
  125. switch cmdType {
  126. case proto.CmdTypeReplyAck:
  127. topic = fwh_mqtt.S2CTopic("mng/" + sampleGateway)
  128. case proto.CmdTypeUploadCall, proto.CmdTypeUploadDoneA:
  129. topic = fwh_mqtt.S2CTopic("debug/" + sampleGateway)
  130. case proto.CmdTypeUploadAck, proto.CmdTypeUploadDone:
  131. topic = fwh_mqtt.C2STopic("debug/" + sampleGateway)
  132. }
  133. b.mu.Lock()
  134. hs := append([]fwh_mqtt.Handler(nil), b.handlers[topic]...)
  135. b.mu.Unlock()
  136. for _, h := range hs {
  137. h(fwh_mqtt.Message{Topic: topic, Raw: []byte(raw), ReceivedAt: time.Now()})
  138. }
  139. }
  140. type memFiles struct {
  141. mu sync.Mutex
  142. data map[string][]byte
  143. }
  144. func (f *memFiles) Put(_ context.Context, key string, d []byte) error {
  145. f.mu.Lock()
  146. defer f.mu.Unlock()
  147. f.data[key] = d
  148. return nil
  149. }
  150. func (f *memFiles) Get(_ context.Context, key string) ([]byte, error) {
  151. f.mu.Lock()
  152. defer f.mu.Unlock()
  153. d, ok := f.data[key]
  154. if !ok {
  155. return nil, fmt.Errorf("not found")
  156. }
  157. return d, nil
  158. }
  159. type fakeLock struct{ locked bool }
  160. func (l fakeLock) Acquire(context.Context, string, string, time.Duration) (func(), bool, error) {
  161. return func() {}, !l.locked, nil
  162. }
  163. /************** helpers **************/
  164. // sampleRound 读取样本日志第二轮(05:45~05:46)按时间正序的报文
  165. func sampleRound(t *testing.T) []string {
  166. t.Helper()
  167. f, err := os.Open(sampleLog)
  168. if err != nil {
  169. t.Skipf("sample log not found: %v", err)
  170. }
  171. defer f.Close()
  172. rows, err := csv.NewReader(f).ReadAll()
  173. if err != nil {
  174. t.Fatal(err)
  175. }
  176. var list []string
  177. for i := len(rows) - 1; i >= 1; i-- { // CSV 为倒序
  178. if strings.HasPrefix(rows[i][8], "2026-10-14 05:4") && rows[i][8] >= "2026-10-14 05:45" {
  179. list = append(list, rows[i][7])
  180. }
  181. }
  182. return list
  183. }
  184. // pickCmd 从样本中取出与 cmd 相关的报文(378/379/380 按 dataArea.cmd 过滤),不含 377
  185. func pickCmd(msgs []string, cmd int) []string {
  186. var out []string
  187. for _, raw := range msgs {
  188. cmdType, _, da, _ := proto.SplitEnvelope([]byte(raw))
  189. if cmdType == proto.CmdTypeRequest {
  190. continue
  191. }
  192. if cmdType >= proto.CmdTypeAck && cmdType <= proto.CmdTypeReplyAck {
  193. var v struct {
  194. Cmd int `json:"cmd"`
  195. }
  196. json.Unmarshal(da, &v)
  197. if v.Cmd != cmd {
  198. continue
  199. }
  200. } else if cmd != proto.CmdGetRecord {
  201. continue
  202. }
  203. out = append(out, raw)
  204. }
  205. return out
  206. }
  207. type harness struct {
  208. d deps
  209. store *memStore
  210. bus *fakeBus
  211. files *memFiles
  212. op *domain.FaultRecordOperation
  213. }
  214. func newHarness(t *testing.T, kind domain.FaultRecordOpKind, req domain.FaultRecordOpRequest) *harness {
  215. t.Helper()
  216. op := &domain.FaultRecordOperation{
  217. ID: primitive.NewObjectID(), Env: "test", GatewaySn: sampleGateway,
  218. DeviceType: proto.DeviceTypeAGate, DeviceSn: sampleGateway,
  219. Kind: kind, Cmd: kind.Cmd(), Request: req,
  220. Status: domain.FaultRecordStatusPending, StageTimes: map[string]time.Time{"pending": time.Now()},
  221. }
  222. h := &harness{
  223. store: &memStore{ops: map[primitive.ObjectID]*domain.FaultRecordOperation{op.ID: op}},
  224. bus: &fakeBus{},
  225. files: &memFiles{data: map[string][]byte{}},
  226. op: op,
  227. }
  228. archive, _ := os.ReadFile(sampleArchive)
  229. to := defaultTimeouts
  230. to.downloadInterval = 10 * time.Millisecond
  231. to.cancelPoll = 10 * time.Millisecond
  232. to.drain = 50 * time.Millisecond
  233. to.afterUploadAck = 10 * time.Millisecond
  234. h.d = deps{
  235. bus: func(string) (mqttBus, error) { return h.bus, nil },
  236. store: h.store,
  237. files: h.files,
  238. download: func(env, equipNo, fileName string) ([]byte, error) {
  239. if equipNo != sampleGateway || fileName != sampleBatch+".tar.bz2" || archive == nil {
  240. return nil, fmt.Errorf("not found")
  241. }
  242. return archive, nil
  243. },
  244. lock: fakeLock{},
  245. downloadURL: func(env, equipNo, fileName string) string {
  246. return "https://" + env + ".example/hes-upload/v1/file/download/logger/" + equipNo + "/" + fileName
  247. },
  248. timeouts: to,
  249. }
  250. return h
  251. }
  252. func (h *harness) run(t *testing.T) *domain.FaultRecordOperation {
  253. t.Helper()
  254. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  255. defer cancel()
  256. if err := h.d.run(ctx, h.op.ID); err != nil {
  257. t.Fatalf("run: %v", err)
  258. }
  259. op, _ := h.store.Get(ctx, h.op.ID)
  260. return op
  261. }
  262. // replay 收到 377 后依次推送消息
  263. func replay(msgs []string) func(*fakeBus, fwh_mqtt.FwhMqttMsg) {
  264. return func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
  265. if m.CmdType != proto.CmdTypeRequest {
  266. return
  267. }
  268. for _, raw := range msgs {
  269. b.push(raw)
  270. }
  271. }
  272. }
  273. /************** tests **************/
  274. func TestRecordFetchFullFlow(t *testing.T) {
  275. msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
  276. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  277. h.bus.script = replay(msgs)
  278. op := h.run(t)
  279. if op.Status != domain.FaultRecordStatusDone {
  280. t.Fatalf("status = %s, error = %s", op.Status, op.Error)
  281. }
  282. if op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 || op.MinioObject == "" {
  283. t.Errorf("record fields: %+v", op)
  284. }
  285. for _, s := range []string{"sent", "accepted", "ready", "uploading", "uploaded", "downloaded", "done"} {
  286. if _, ok := op.StageTimes[s]; !ok {
  287. t.Errorf("missing stage %s", s)
  288. }
  289. }
  290. // 发送的 377 + 样本全部报文(含 609 之后的 610)+ 1 条 HTTP 下载记录
  291. var hasHTTP bool
  292. for _, m := range op.Messages {
  293. if m.Direction == "http" && strings.Contains(m.Raw, `"result":"ok"`) {
  294. hasHTTP = true
  295. }
  296. }
  297. // 顺序:…609 → 610 → HTTP 下载(收到 610 后才下载)
  298. first, last := op.Messages[0], op.Messages[len(op.Messages)-1]
  299. prev := op.Messages[len(op.Messages)-2]
  300. if first.CmdType != proto.CmdTypeRequest || last.Direction != "http" || prev.CmdType != proto.CmdTypeUploadDoneA || !hasHTTP || len(op.Messages) != len(msgs)+2 {
  301. t.Errorf("messages = %d (first %d, prev %d, last %s, http %v)", len(op.Messages), first.CmdType, prev.CmdType, last.Direction, hasHTTP)
  302. }
  303. b, _ := json.Marshal(h.bus.published[0].DataArea)
  304. if string(b) != `{"cmd":2,"data":{"list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","index":[7]}]}]}}` {
  305. t.Errorf("request = %s", b)
  306. }
  307. }
  308. func TestDeviceInfoFlow(t *testing.T) {
  309. msgs := pickCmd(sampleRound(t), proto.CmdRealtime)
  310. h := newHarness(t, domain.FaultRecordOpDeviceInfo, domain.FaultRecordOpRequest{CodeList: []int{3}})
  311. h.bus.script = replay(msgs)
  312. op := h.run(t)
  313. if op.Status != domain.FaultRecordStatusDone || op.Reply == nil || len(op.Reply.List) != 1 {
  314. t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
  315. }
  316. }
  317. func TestBusyReply(t *testing.T) {
  318. h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
  319. h.bus.script = replay([]string{
  320. `{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
  321. `{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":"busy"}}`,
  322. })
  323. if op := h.run(t); op.Status != domain.FaultRecordStatusBusy {
  324. t.Fatalf("status = %s", op.Status)
  325. }
  326. }
  327. func TestIgnoresOtherCmdAndOtherGateway(t *testing.T) {
  328. h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
  329. h.bus.script = replay([]string{
  330. `{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
  331. `{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":0,"result":"ok"}}`, // 其他 cmd
  332. `{"cmdType":379,"equipNo":"OTHER","dataArea":{"cmd":3,"result":"busy"}}`, // 其他网关
  333. `{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":"ok","list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","idInfo":[{"id":5,"idData":"1194","resultOne":"ok"}]}]}]}}`,
  334. })
  335. op := h.run(t)
  336. if op.Status != domain.FaultRecordStatusDone || op.Reply.List[0].Info[0].IdInfo[0].IdData != "1194" {
  337. t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
  338. }
  339. }
  340. func TestAckTimeout(t *testing.T) {
  341. h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
  342. h.d.timeouts.ack = 50 * time.Millisecond
  343. op := h.run(t)
  344. if op.Status != domain.FaultRecordStatusTimeout || op.TimeoutStage != domain.FaultRecordStatusAccepted {
  345. t.Fatalf("status = %s stage = %s", op.Status, op.TimeoutStage)
  346. }
  347. }
  348. func TestCancelSendsExit(t *testing.T) {
  349. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  350. h.bus.script = func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
  351. if m.CmdType != proto.CmdTypeRequest {
  352. return
  353. }
  354. if da, _ := json.Marshal(m.DataArea); strings.Contains(string(da), `"cmd":2`) {
  355. b.push(`{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":2,"result":0}}`)
  356. time.Sleep(30 * time.Millisecond)
  357. h.store.SetFields(context.Background(), h.op.ID, bson.M{"cancelRequested": true})
  358. }
  359. }
  360. op := h.run(t)
  361. if op.Status != domain.FaultRecordStatusCancelled || !op.ExitSent {
  362. t.Fatalf("status = %s exitSent = %v", op.Status, op.ExitSent)
  363. }
  364. last, _ := json.Marshal(h.bus.published[len(h.bus.published)-1].DataArea)
  365. if string(last) != `{"cmd":4,"data":{}}` {
  366. t.Errorf("exit request = %s", last)
  367. }
  368. }
  369. func TestRecoverSentIsInterrupted(t *testing.T) {
  370. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  371. h.op.Status = domain.FaultRecordStatusAccepted
  372. if op := h.run(t); op.Status != domain.FaultRecordStatusInterrupted || len(h.bus.published) != 0 {
  373. t.Fatalf("status = %s published = %d", op.Status, len(h.bus.published))
  374. }
  375. }
  376. func TestRecoverReadyDownloadsDirectly(t *testing.T) {
  377. if _, err := os.Stat(sampleArchive); err != nil {
  378. t.Skip("sample archive not found")
  379. }
  380. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  381. h.op.Status = domain.FaultRecordStatusReady
  382. h.op.FileName = sampleBatch + ".tar.bz2"
  383. h.op.StageTimes["ready"] = time.Now()
  384. if op := h.run(t); op.Status != domain.FaultRecordStatusDone || len(h.bus.published) != 0 {
  385. t.Fatalf("status = %s error = %s", op.Status, op.Error)
  386. }
  387. }
  388. func TestGatewayLocked(t *testing.T) {
  389. h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
  390. h.d.lock = fakeLock{locked: true}
  391. if op := h.run(t); op.Status != domain.FaultRecordStatusGatewayLocked {
  392. t.Fatalf("status = %s", op.Status)
  393. }
  394. }
  395. // 订阅不到平台下发方向(无 607/380/610)时:靠 608 取得平台记录 ID;没有 610 不会提前下载,超时时按规则直接下载一次
  396. func TestRecordFetchWithoutS2C(t *testing.T) {
  397. var c2sOnly []string
  398. for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
  399. cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
  400. if cmdType != proto.CmdTypeUploadCall && cmdType != proto.CmdTypeReplyAck && cmdType != proto.CmdTypeUploadDoneA {
  401. c2sOnly = append(c2sOnly, raw)
  402. }
  403. }
  404. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  405. h.d.timeouts.upload = 300 * time.Millisecond
  406. h.bus.script = replay(c2sOnly)
  407. op := h.run(t)
  408. if op.Status != domain.FaultRecordStatusDone || op.FaultRecordID != 5226 {
  409. t.Fatalf("status = %s faultRecordId = %d error = %s", op.Status, op.FaultRecordID, op.Error)
  410. }
  411. for _, s := range []string{"uploading", "uploaded", "downloaded"} {
  412. if _, ok := op.StageTimes[s]; !ok {
  413. t.Errorf("missing stage %s", s)
  414. }
  415. }
  416. // 下载发生在超时时,而不是收到 609 时
  417. if op.StageTimes["downloaded"].Sub(op.StageTimes["uploaded"]) < 200*time.Millisecond {
  418. t.Error("should not download before 610 or timeout")
  419. }
  420. }
  421. // 一次获取多条记录:请求包含全部序号;压缩包中缺少的序号在结果中提示
  422. func TestRecordFetchMultiple(t *testing.T) {
  423. msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
  424. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndexes: []int{7, 9}})
  425. h.bus.script = replay(msgs)
  426. op := h.run(t)
  427. b, _ := json.Marshal(h.bus.published[0].DataArea)
  428. if !strings.Contains(string(b), `"index":[7,9]`) {
  429. t.Errorf("request = %s", b)
  430. }
  431. // 样本压缩包只有记录 7
  432. if op.Status != domain.FaultRecordStatusDone || !strings.Contains(op.Error, "9") {
  433. t.Fatalf("status = %s error = %q", op.Status, op.Error)
  434. }
  435. }
  436. // 收到 610 后等待 afterUploadAck 再下载
  437. func TestDownloadAfter610Delay(t *testing.T) {
  438. msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
  439. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  440. h.d.timeouts.afterUploadAck = 150 * time.Millisecond
  441. h.bus.script = replay(msgs)
  442. op := h.run(t)
  443. if op.Status != domain.FaultRecordStatusDone {
  444. t.Fatalf("status = %s error = %s", op.Status, op.Error)
  445. }
  446. var at610 time.Time
  447. for _, m := range op.Messages {
  448. if m.CmdType == proto.CmdTypeUploadDoneA {
  449. at610 = m.At
  450. }
  451. }
  452. if at610.IsZero() || op.StageTimes["downloaded"].Sub(at610) < 150*time.Millisecond {
  453. t.Errorf("download should start ≥150ms after 610 (610 at %v, downloaded at %v)", at610, op.StageTimes["downloaded"])
  454. }
  455. }
  456. // 收到 610 后开始下载;连续相同的失败只保留一条并累计次数,成功后另记一条
  457. func TestDownloadRetryDedupesFailures(t *testing.T) {
  458. msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
  459. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  460. h.bus.script = replay(msgs)
  461. real := h.d.download
  462. var mu sync.Mutex
  463. calls := 0
  464. h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
  465. mu.Lock()
  466. calls++
  467. n := calls
  468. mu.Unlock()
  469. if n <= 3 {
  470. return nil, fmt.Errorf("404 Not Found")
  471. }
  472. return real(env, equipNo, fileName)
  473. }
  474. op := h.run(t)
  475. if op.Status != domain.FaultRecordStatusDone {
  476. t.Fatalf("status = %s error = %s", op.Status, op.Error)
  477. }
  478. var httpMsgs []domain.FaultRecordOpMessage
  479. for _, m := range op.Messages {
  480. if m.Direction == "http" {
  481. httpMsgs = append(httpMsgs, m)
  482. }
  483. }
  484. if len(httpMsgs) != 2 || !strings.Contains(httpMsgs[0].Raw, `"url":"https://test.example/hes-upload/v1/file/download/logger/AFCI_TEST_0713/2026-10-14_05_46_20.tar.bz2"`) || !strings.Contains(httpMsgs[0].Raw, `"count":3`) || !strings.Contains(httpMsgs[1].Raw, `"result":"ok"`) {
  485. t.Fatalf("http records = %+v", httpMsgs)
  486. }
  487. }
  488. // 一直失败时重试到总超时,记为下载失败并注明尝试次数
  489. func TestDownloadRetryUntilTimeout(t *testing.T) {
  490. msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
  491. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  492. h.d.timeouts.upload = 200 * time.Millisecond
  493. h.d.timeouts.downloadInterval = 30 * time.Millisecond
  494. h.bus.script = replay(msgs)
  495. h.d.download = func(string, string, string) ([]byte, error) { return nil, fmt.Errorf("404 Not Found") }
  496. op := h.run(t)
  497. if op.Status != domain.FaultRecordStatusDownloadFailed || !strings.Contains(op.Error, "已尝试") {
  498. t.Fatalf("status = %s error = %s", op.Status, op.Error)
  499. }
  500. n := 0
  501. for _, m := range op.Messages {
  502. if m.Direction == "http" {
  503. n++
  504. }
  505. }
  506. if n != 1 {
  507. t.Errorf("same failures should collapse into 1 record, got %d", n)
  508. }
  509. }
  510. // 新固件:609.fileName 不带 .tar.bz2、608 不带 id;下载地址仍须带后缀,平台记录 ID 取自 607
  511. func TestNewFirmware609WithoutSuffix(t *testing.T) {
  512. var msgs []string
  513. for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
  514. raw = strings.Replace(raw, `"fileName":"2026-10-14_05_46_20.tar.bz2"`, `"fileName":"2026-10-14_05_46_20"`, 1)
  515. raw = strings.Replace(raw, `"result":0,"filename":"2026-10-14_05_46_20","result":5226`, `"result":0,"filename":"2026-10-14_05_46_20"`, 1)
  516. msgs = append(msgs, raw)
  517. }
  518. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  519. h.bus.script = replay(msgs)
  520. op := h.run(t)
  521. if op.Status != domain.FaultRecordStatusDone || op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 {
  522. t.Fatalf("status = %s file = %s frid = %d error = %s", op.Status, op.FileName, op.FaultRecordID, op.Error)
  523. }
  524. }
  525. // 手动重试下载:状态为 uploaded 且有 DownloadRetryAt 时,从重试时刻重新计算总超时并直接下载
  526. func TestManualDownloadRetry(t *testing.T) {
  527. if _, err := os.Stat(sampleArchive); err != nil {
  528. t.Skip("sample archive not found")
  529. }
  530. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  531. now := time.Now()
  532. h.op.Status = domain.FaultRecordStatusUploaded
  533. h.op.FileName = sampleBatch + ".tar.bz2"
  534. h.op.StageTimes["ready"] = now.Add(-time.Hour) // 原总超时早已过去
  535. h.op.StageTimes["uploaded"] = now.Add(-time.Hour)
  536. h.op.DownloadRetryAt = &now
  537. calls := 0
  538. real := h.d.download
  539. h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
  540. calls++
  541. if calls == 1 {
  542. return nil, fmt.Errorf("404 Not Found")
  543. }
  544. return real(env, equipNo, fileName)
  545. }
  546. op := h.run(t)
  547. if op.Status != domain.FaultRecordStatusDone || calls != 2 || len(h.bus.published) != 0 {
  548. t.Fatalf("status = %s calls = %d published = %d error = %s", op.Status, calls, len(h.bus.published), op.Error)
  549. }
  550. }
  551. // 手动重试只尝试 3 次;已保存的文件名不带后缀(旧记录)时,下载链接仍按规则补齐 .tar.bz2
  552. func TestManualRetryThreeAttemptsAndSuffix(t *testing.T) {
  553. h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
  554. now := time.Now()
  555. h.op.Status = domain.FaultRecordStatusUploaded
  556. h.op.FileName = sampleBatch // 旧记录:不带后缀
  557. h.op.StageTimes["ready"] = now
  558. h.op.StageTimes["uploaded"] = now
  559. h.op.DownloadRetryAt = &now
  560. var names []string
  561. h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
  562. names = append(names, fileName)
  563. return nil, fmt.Errorf("404 Not Found")
  564. }
  565. op := h.run(t)
  566. if op.Status != domain.FaultRecordStatusDownloadFailed || len(names) != 3 || !strings.Contains(op.Error, "已尝试 3 次") {
  567. t.Fatalf("status = %s attempts = %d error = %s", op.Status, len(names), op.Error)
  568. }
  569. for _, n := range names {
  570. if n != sampleBatch+".tar.bz2" {
  571. t.Errorf("download file name = %s", n)
  572. }
  573. }
  574. var url string
  575. for _, m := range op.Messages {
  576. if m.Direction == "http" {
  577. url = m.Raw
  578. }
  579. }
  580. if !strings.Contains(url, sampleBatch+".tar.bz2") || !strings.Contains(url, `"count":3`) {
  581. t.Errorf("http record = %s", url)
  582. }
  583. }