| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637 |
- package faultRecordQueue
- import (
- "context"
- "encoding/csv"
- "encoding/json"
- "fmt"
- "os"
- "strings"
- "sync"
- "testing"
- "time"
- "fwh-toolbox-server/domain"
- proto "fwh-toolbox-server/function/FaultRecordProtocol"
- "fwh-toolbox-server/repositories/mqtt/fwh_mqtt"
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/bson/primitive"
- )
- const (
- sampleLog = "../../../../docs/fault-record/CloudLog_2026-10-14_104644.csv"
- sampleArchive = "../../../../docs/fault-record/2026-10-14_05_46_20.tar.bz2"
- sampleGateway = "AFCI_TEST_0713"
- sampleBatch = "2026-10-14_05_46_20"
- )
- /************** fakes **************/
- type memStore struct {
- mu sync.Mutex
- ops map[primitive.ObjectID]*domain.FaultRecordOperation
- }
- func (s *memStore) Get(_ context.Context, id primitive.ObjectID) (*domain.FaultRecordOperation, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- cp := *s.ops[id]
- cp.StageTimes = map[string]time.Time{}
- for k, v := range s.ops[id].StageTimes {
- cp.StageTimes[k] = v
- }
- return &cp, nil
- }
- func (s *memStore) UpdateStatus(_ context.Context, id primitive.ObjectID, st domain.FaultRecordOpStatus, extra bson.M) (bool, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- op := s.ops[id]
- if op.Status.Terminal() {
- return false, nil
- }
- op.Status = st
- op.StageTimes[string(st)] = time.Now()
- applyFields(op, extra)
- return true, nil
- }
- func (s *memStore) SetFields(_ context.Context, id primitive.ObjectID, f bson.M) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- applyFields(s.ops[id], f)
- return nil
- }
- func (s *memStore) AppendMessage(_ context.Context, id primitive.ObjectID, m domain.FaultRecordOpMessage) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.ops[id].Messages = append(s.ops[id].Messages, m)
- return nil
- }
- func applyFields(op *domain.FaultRecordOperation, f bson.M) {
- for k, v := range f {
- var idx int
- if n, _ := fmt.Sscanf(k, "messages.%d", &idx); n == 1 {
- op.Messages[idx] = v.(domain.FaultRecordOpMessage)
- continue
- }
- switch k {
- case "error":
- op.Error = v.(string)
- case "reply":
- r := v.(proto.Reply)
- op.Reply = &r
- case "fileName":
- op.FileName = v.(string)
- case "faultRecordId":
- op.FaultRecordID = v.(int64)
- case "uploadEquipNo":
- op.UploadEquipNo = v.(string)
- case "minioObject":
- op.MinioObject = v.(string)
- case "fileSize":
- op.FileSize = int64(v.(int))
- case "exitSent":
- op.ExitSent = v.(bool)
- case "timeoutStage":
- op.TimeoutStage = v.(domain.FaultRecordOpStatus)
- case "cancelRequested":
- op.CancelReq = v.(bool)
- }
- }
- }
- // fakeBus 收到 377 后按脚本回放消息
- type fakeBus struct {
- mu sync.Mutex
- handlers map[string][]fwh_mqtt.Handler
- published []fwh_mqtt.FwhMqttMsg
- script func(b *fakeBus, msg fwh_mqtt.FwhMqttMsg)
- }
- func (b *fakeBus) Subscribe(_ context.Context, topic string, h fwh_mqtt.Handler) (func(), error) {
- b.mu.Lock()
- defer b.mu.Unlock()
- if b.handlers == nil {
- b.handlers = map[string][]fwh_mqtt.Handler{}
- }
- b.handlers[topic] = append(b.handlers[topic], h)
- return func() {}, nil
- }
- func (b *fakeBus) Publish(_ context.Context, topic string, msg fwh_mqtt.FwhMqttMsg) error {
- b.mu.Lock()
- b.published = append(b.published, msg)
- script := b.script
- b.mu.Unlock()
- if script != nil {
- go script(b, msg)
- }
- return nil
- }
- func (b *fakeBus) push(raw string) {
- cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
- // 与实际一致:378/379 走 c2s/mng,380 走 s2c/mng;607/610 走 s2c/debug,608/609 走 c2s/debug
- topic := fwh_mqtt.C2STopic("mng/" + sampleGateway)
- switch cmdType {
- case proto.CmdTypeReplyAck:
- topic = fwh_mqtt.S2CTopic("mng/" + sampleGateway)
- case proto.CmdTypeUploadCall, proto.CmdTypeUploadDoneA:
- topic = fwh_mqtt.S2CTopic("debug/" + sampleGateway)
- case proto.CmdTypeUploadAck, proto.CmdTypeUploadDone:
- topic = fwh_mqtt.C2STopic("debug/" + sampleGateway)
- }
- b.mu.Lock()
- hs := append([]fwh_mqtt.Handler(nil), b.handlers[topic]...)
- b.mu.Unlock()
- for _, h := range hs {
- h(fwh_mqtt.Message{Topic: topic, Raw: []byte(raw), ReceivedAt: time.Now()})
- }
- }
- type memFiles struct {
- mu sync.Mutex
- data map[string][]byte
- }
- func (f *memFiles) Put(_ context.Context, key string, d []byte) error {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.data[key] = d
- return nil
- }
- func (f *memFiles) Get(_ context.Context, key string) ([]byte, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- d, ok := f.data[key]
- if !ok {
- return nil, fmt.Errorf("not found")
- }
- return d, nil
- }
- type fakeLock struct{ locked bool }
- func (l fakeLock) Acquire(context.Context, string, string, time.Duration) (func(), bool, error) {
- return func() {}, !l.locked, nil
- }
- /************** helpers **************/
- // sampleRound 读取样本日志第二轮(05:45~05:46)按时间正序的报文
- func sampleRound(t *testing.T) []string {
- t.Helper()
- f, err := os.Open(sampleLog)
- if err != nil {
- t.Skipf("sample log not found: %v", err)
- }
- defer f.Close()
- rows, err := csv.NewReader(f).ReadAll()
- if err != nil {
- t.Fatal(err)
- }
- var list []string
- for i := len(rows) - 1; i >= 1; i-- { // CSV 为倒序
- if strings.HasPrefix(rows[i][8], "2026-10-14 05:4") && rows[i][8] >= "2026-10-14 05:45" {
- list = append(list, rows[i][7])
- }
- }
- return list
- }
- // pickCmd 从样本中取出与 cmd 相关的报文(378/379/380 按 dataArea.cmd 过滤),不含 377
- func pickCmd(msgs []string, cmd int) []string {
- var out []string
- for _, raw := range msgs {
- cmdType, _, da, _ := proto.SplitEnvelope([]byte(raw))
- if cmdType == proto.CmdTypeRequest {
- continue
- }
- if cmdType >= proto.CmdTypeAck && cmdType <= proto.CmdTypeReplyAck {
- var v struct {
- Cmd int `json:"cmd"`
- }
- json.Unmarshal(da, &v)
- if v.Cmd != cmd {
- continue
- }
- } else if cmd != proto.CmdGetRecord {
- continue
- }
- out = append(out, raw)
- }
- return out
- }
- type harness struct {
- d deps
- store *memStore
- bus *fakeBus
- files *memFiles
- op *domain.FaultRecordOperation
- }
- func newHarness(t *testing.T, kind domain.FaultRecordOpKind, req domain.FaultRecordOpRequest) *harness {
- t.Helper()
- op := &domain.FaultRecordOperation{
- ID: primitive.NewObjectID(), Env: "test", GatewaySn: sampleGateway,
- DeviceType: proto.DeviceTypeAGate, DeviceSn: sampleGateway,
- Kind: kind, Cmd: kind.Cmd(), Request: req,
- Status: domain.FaultRecordStatusPending, StageTimes: map[string]time.Time{"pending": time.Now()},
- }
- h := &harness{
- store: &memStore{ops: map[primitive.ObjectID]*domain.FaultRecordOperation{op.ID: op}},
- bus: &fakeBus{},
- files: &memFiles{data: map[string][]byte{}},
- op: op,
- }
- archive, _ := os.ReadFile(sampleArchive)
- to := defaultTimeouts
- to.downloadInterval = 10 * time.Millisecond
- to.cancelPoll = 10 * time.Millisecond
- to.drain = 50 * time.Millisecond
- to.afterUploadAck = 10 * time.Millisecond
- h.d = deps{
- bus: func(string) (mqttBus, error) { return h.bus, nil },
- store: h.store,
- files: h.files,
- download: func(env, equipNo, fileName string) ([]byte, error) {
- if equipNo != sampleGateway || fileName != sampleBatch+".tar.bz2" || archive == nil {
- return nil, fmt.Errorf("not found")
- }
- return archive, nil
- },
- lock: fakeLock{},
- downloadURL: func(env, equipNo, fileName string) string {
- return "https://" + env + ".example/hes-upload/v1/file/download/logger/" + equipNo + "/" + fileName
- },
- timeouts: to,
- }
- return h
- }
- func (h *harness) run(t *testing.T) *domain.FaultRecordOperation {
- t.Helper()
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if err := h.d.run(ctx, h.op.ID); err != nil {
- t.Fatalf("run: %v", err)
- }
- op, _ := h.store.Get(ctx, h.op.ID)
- return op
- }
- // replay 收到 377 后依次推送消息
- func replay(msgs []string) func(*fakeBus, fwh_mqtt.FwhMqttMsg) {
- return func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
- if m.CmdType != proto.CmdTypeRequest {
- return
- }
- for _, raw := range msgs {
- b.push(raw)
- }
- }
- }
- /************** tests **************/
- func TestRecordFetchFullFlow(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.bus.script = replay(msgs)
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone {
- t.Fatalf("status = %s, error = %s", op.Status, op.Error)
- }
- if op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 || op.MinioObject == "" {
- t.Errorf("record fields: %+v", op)
- }
- for _, s := range []string{"sent", "accepted", "ready", "uploading", "uploaded", "downloaded", "done"} {
- if _, ok := op.StageTimes[s]; !ok {
- t.Errorf("missing stage %s", s)
- }
- }
- // 发送的 377 + 样本全部报文(含 609 之后的 610)+ 1 条 HTTP 下载记录
- var hasHTTP bool
- for _, m := range op.Messages {
- if m.Direction == "http" && strings.Contains(m.Raw, `"result":"ok"`) {
- hasHTTP = true
- }
- }
- // 顺序:…609 → 610 → HTTP 下载(收到 610 后才下载)
- first, last := op.Messages[0], op.Messages[len(op.Messages)-1]
- prev := op.Messages[len(op.Messages)-2]
- if first.CmdType != proto.CmdTypeRequest || last.Direction != "http" || prev.CmdType != proto.CmdTypeUploadDoneA || !hasHTTP || len(op.Messages) != len(msgs)+2 {
- t.Errorf("messages = %d (first %d, prev %d, last %s, http %v)", len(op.Messages), first.CmdType, prev.CmdType, last.Direction, hasHTTP)
- }
- b, _ := json.Marshal(h.bus.published[0].DataArea)
- if string(b) != `{"cmd":2,"data":{"list":[{"type":"aGate","info":[{"sn":"AFCI_TEST_0713","index":[7]}]}]}}` {
- t.Errorf("request = %s", b)
- }
- }
- func TestDeviceInfoFlow(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdRealtime)
- h := newHarness(t, domain.FaultRecordOpDeviceInfo, domain.FaultRecordOpRequest{CodeList: []int{3}})
- h.bus.script = replay(msgs)
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone || op.Reply == nil || len(op.Reply.List) != 1 {
- t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
- }
- }
- func TestBusyReply(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
- h.bus.script = replay([]string{
- `{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
- `{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":"busy"}}`,
- })
- if op := h.run(t); op.Status != domain.FaultRecordStatusBusy {
- t.Fatalf("status = %s", op.Status)
- }
- }
- func TestIgnoresOtherCmdAndOtherGateway(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
- h.bus.script = replay([]string{
- `{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":3,"result":0}}`,
- `{"cmdType":379,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":0,"result":"ok"}}`, // 其他 cmd
- `{"cmdType":379,"equipNo":"OTHER","dataArea":{"cmd":3,"result":"busy"}}`, // 其他网关
- `{"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"}]}]}]}}`,
- })
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone || op.Reply.List[0].Info[0].IdInfo[0].IdData != "1194" {
- t.Fatalf("status = %s reply = %+v", op.Status, op.Reply)
- }
- }
- func TestAckTimeout(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
- h.d.timeouts.ack = 50 * time.Millisecond
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusTimeout || op.TimeoutStage != domain.FaultRecordStatusAccepted {
- t.Fatalf("status = %s stage = %s", op.Status, op.TimeoutStage)
- }
- }
- func TestCancelSendsExit(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.bus.script = func(b *fakeBus, m fwh_mqtt.FwhMqttMsg) {
- if m.CmdType != proto.CmdTypeRequest {
- return
- }
- if da, _ := json.Marshal(m.DataArea); strings.Contains(string(da), `"cmd":2`) {
- b.push(`{"cmdType":378,"equipNo":"AFCI_TEST_0713","dataArea":{"cmd":2,"result":0}}`)
- time.Sleep(30 * time.Millisecond)
- h.store.SetFields(context.Background(), h.op.ID, bson.M{"cancelRequested": true})
- }
- }
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusCancelled || !op.ExitSent {
- t.Fatalf("status = %s exitSent = %v", op.Status, op.ExitSent)
- }
- last, _ := json.Marshal(h.bus.published[len(h.bus.published)-1].DataArea)
- if string(last) != `{"cmd":4,"data":{}}` {
- t.Errorf("exit request = %s", last)
- }
- }
- func TestRecoverSentIsInterrupted(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.op.Status = domain.FaultRecordStatusAccepted
- if op := h.run(t); op.Status != domain.FaultRecordStatusInterrupted || len(h.bus.published) != 0 {
- t.Fatalf("status = %s published = %d", op.Status, len(h.bus.published))
- }
- }
- func TestRecoverReadyDownloadsDirectly(t *testing.T) {
- if _, err := os.Stat(sampleArchive); err != nil {
- t.Skip("sample archive not found")
- }
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.op.Status = domain.FaultRecordStatusReady
- h.op.FileName = sampleBatch + ".tar.bz2"
- h.op.StageTimes["ready"] = time.Now()
- if op := h.run(t); op.Status != domain.FaultRecordStatusDone || len(h.bus.published) != 0 {
- t.Fatalf("status = %s error = %s", op.Status, op.Error)
- }
- }
- func TestGatewayLocked(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpParamRead, domain.FaultRecordOpRequest{IdList: []int{5}})
- h.d.lock = fakeLock{locked: true}
- if op := h.run(t); op.Status != domain.FaultRecordStatusGatewayLocked {
- t.Fatalf("status = %s", op.Status)
- }
- }
- // 订阅不到平台下发方向(无 607/380/610)时:靠 608 取得平台记录 ID;没有 610 不会提前下载,超时时按规则直接下载一次
- func TestRecordFetchWithoutS2C(t *testing.T) {
- var c2sOnly []string
- for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
- cmdType, _, _, _ := proto.SplitEnvelope([]byte(raw))
- if cmdType != proto.CmdTypeUploadCall && cmdType != proto.CmdTypeReplyAck && cmdType != proto.CmdTypeUploadDoneA {
- c2sOnly = append(c2sOnly, raw)
- }
- }
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.d.timeouts.upload = 300 * time.Millisecond
- h.bus.script = replay(c2sOnly)
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone || op.FaultRecordID != 5226 {
- t.Fatalf("status = %s faultRecordId = %d error = %s", op.Status, op.FaultRecordID, op.Error)
- }
- for _, s := range []string{"uploading", "uploaded", "downloaded"} {
- if _, ok := op.StageTimes[s]; !ok {
- t.Errorf("missing stage %s", s)
- }
- }
- // 下载发生在超时时,而不是收到 609 时
- if op.StageTimes["downloaded"].Sub(op.StageTimes["uploaded"]) < 200*time.Millisecond {
- t.Error("should not download before 610 or timeout")
- }
- }
- // 一次获取多条记录:请求包含全部序号;压缩包中缺少的序号在结果中提示
- func TestRecordFetchMultiple(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndexes: []int{7, 9}})
- h.bus.script = replay(msgs)
- op := h.run(t)
- b, _ := json.Marshal(h.bus.published[0].DataArea)
- if !strings.Contains(string(b), `"index":[7,9]`) {
- t.Errorf("request = %s", b)
- }
- // 样本压缩包只有记录 7
- if op.Status != domain.FaultRecordStatusDone || !strings.Contains(op.Error, "9") {
- t.Fatalf("status = %s error = %q", op.Status, op.Error)
- }
- }
- // 收到 610 后等待 afterUploadAck 再下载
- func TestDownloadAfter610Delay(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.d.timeouts.afterUploadAck = 150 * time.Millisecond
- h.bus.script = replay(msgs)
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone {
- t.Fatalf("status = %s error = %s", op.Status, op.Error)
- }
- var at610 time.Time
- for _, m := range op.Messages {
- if m.CmdType == proto.CmdTypeUploadDoneA {
- at610 = m.At
- }
- }
- if at610.IsZero() || op.StageTimes["downloaded"].Sub(at610) < 150*time.Millisecond {
- t.Errorf("download should start ≥150ms after 610 (610 at %v, downloaded at %v)", at610, op.StageTimes["downloaded"])
- }
- }
- // 收到 610 后开始下载;连续相同的失败只保留一条并累计次数,成功后另记一条
- func TestDownloadRetryDedupesFailures(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.bus.script = replay(msgs)
- real := h.d.download
- var mu sync.Mutex
- calls := 0
- h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
- mu.Lock()
- calls++
- n := calls
- mu.Unlock()
- if n <= 3 {
- return nil, fmt.Errorf("404 Not Found")
- }
- return real(env, equipNo, fileName)
- }
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone {
- t.Fatalf("status = %s error = %s", op.Status, op.Error)
- }
- var httpMsgs []domain.FaultRecordOpMessage
- for _, m := range op.Messages {
- if m.Direction == "http" {
- httpMsgs = append(httpMsgs, m)
- }
- }
- 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"`) {
- t.Fatalf("http records = %+v", httpMsgs)
- }
- }
- // 一直失败时重试到总超时,记为下载失败并注明尝试次数
- func TestDownloadRetryUntilTimeout(t *testing.T) {
- msgs := pickCmd(sampleRound(t), proto.CmdGetRecord)
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.d.timeouts.upload = 200 * time.Millisecond
- h.d.timeouts.downloadInterval = 30 * time.Millisecond
- h.bus.script = replay(msgs)
- h.d.download = func(string, string, string) ([]byte, error) { return nil, fmt.Errorf("404 Not Found") }
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDownloadFailed || !strings.Contains(op.Error, "已尝试") {
- t.Fatalf("status = %s error = %s", op.Status, op.Error)
- }
- n := 0
- for _, m := range op.Messages {
- if m.Direction == "http" {
- n++
- }
- }
- if n != 1 {
- t.Errorf("same failures should collapse into 1 record, got %d", n)
- }
- }
- // 新固件:609.fileName 不带 .tar.bz2、608 不带 id;下载地址仍须带后缀,平台记录 ID 取自 607
- func TestNewFirmware609WithoutSuffix(t *testing.T) {
- var msgs []string
- for _, raw := range pickCmd(sampleRound(t), proto.CmdGetRecord) {
- raw = strings.Replace(raw, `"fileName":"2026-10-14_05_46_20.tar.bz2"`, `"fileName":"2026-10-14_05_46_20"`, 1)
- 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)
- msgs = append(msgs, raw)
- }
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- h.bus.script = replay(msgs)
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone || op.FileName != sampleBatch+".tar.bz2" || op.FaultRecordID != 5226 {
- t.Fatalf("status = %s file = %s frid = %d error = %s", op.Status, op.FileName, op.FaultRecordID, op.Error)
- }
- }
- // 手动重试下载:状态为 uploaded 且有 DownloadRetryAt 时,从重试时刻重新计算总超时并直接下载
- func TestManualDownloadRetry(t *testing.T) {
- if _, err := os.Stat(sampleArchive); err != nil {
- t.Skip("sample archive not found")
- }
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- now := time.Now()
- h.op.Status = domain.FaultRecordStatusUploaded
- h.op.FileName = sampleBatch + ".tar.bz2"
- h.op.StageTimes["ready"] = now.Add(-time.Hour) // 原总超时早已过去
- h.op.StageTimes["uploaded"] = now.Add(-time.Hour)
- h.op.DownloadRetryAt = &now
- calls := 0
- real := h.d.download
- h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
- calls++
- if calls == 1 {
- return nil, fmt.Errorf("404 Not Found")
- }
- return real(env, equipNo, fileName)
- }
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDone || calls != 2 || len(h.bus.published) != 0 {
- t.Fatalf("status = %s calls = %d published = %d error = %s", op.Status, calls, len(h.bus.published), op.Error)
- }
- }
- // 手动重试只尝试 3 次;已保存的文件名不带后缀(旧记录)时,下载链接仍按规则补齐 .tar.bz2
- func TestManualRetryThreeAttemptsAndSuffix(t *testing.T) {
- h := newHarness(t, domain.FaultRecordOpRecordFetch, domain.FaultRecordOpRequest{RecordIndex: 7})
- now := time.Now()
- h.op.Status = domain.FaultRecordStatusUploaded
- h.op.FileName = sampleBatch // 旧记录:不带后缀
- h.op.StageTimes["ready"] = now
- h.op.StageTimes["uploaded"] = now
- h.op.DownloadRetryAt = &now
- var names []string
- h.d.download = func(env, equipNo, fileName string) ([]byte, error) {
- names = append(names, fileName)
- return nil, fmt.Errorf("404 Not Found")
- }
- op := h.run(t)
- if op.Status != domain.FaultRecordStatusDownloadFailed || len(names) != 3 || !strings.Contains(op.Error, "已尝试 3 次") {
- t.Fatalf("status = %s attempts = %d error = %s", op.Status, len(names), op.Error)
- }
- for _, n := range names {
- if n != sampleBatch+".tar.bz2" {
- t.Errorf("download file name = %s", n)
- }
- }
- var url string
- for _, m := range op.Messages {
- if m.Direction == "http" {
- url = m.Raw
- }
- }
- if !strings.Contains(url, sampleBatch+".tar.bz2") || !strings.Contains(url, `"count":3`) {
- t.Errorf("http record = %s", url)
- }
- }
|