package imei import ( "context" "errors" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model" queue "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" ) type cardScanner struct{ cards []Card } func (s cardScanner) SnapshotMaxID(context.Context) (int64, error) { return int64(len(s.cards)), nil } func (s cardScanner) Scan(_ context.Context, last, max int64, limit int) ([]Card, error) { end := last + int64(limit) if end > max { end = max } return s.cards[last:end], nil } func newProducer(t *testing.T, cards []Card, config ...Config) (*Producer, *queue.Store) { t.Helper() redis := testutil.Redis(t) store, err := queue.New(redis, "imei:") if err != nil { t.Fatal(err) } cfg := Config{PageSize: 1} if len(config) > 0 { cfg = config[0] } producer, err := New(cardScanner{cards: cards}, store, cfg) if err != nil { t.Fatal(err) } return producer, store } // TestProduceEnqueuesEligibleCards 覆盖 §7 T1:全量入队, // 跳过无通道、无模板、不参与轮询的卡。 func TestProduceEnqueuesEligibleCards(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{ {ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}, // 合格 {ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5", SyncEnabled: true}, // sync_enabled=1 跳过 {ID: 3, ICCID: "c", ChanKey: "ch"}, // 无模板 {ID: 4, ICCID: "d", ChanKey: "ch", TempKey: "sim_yd_v5"}, // 合格 }) if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "full"}); err != nil { t.Fatal(err) } meta, err := store.GetRunMeta(ctx, "full") if err != nil { t.Fatal(err) } if !meta.ProducerDone { t.Fatal("producerDone 应为 true") } // 用 ActiveCount 而非 EnqueuedCount:后者是 RunMeta 上的既有死字段 // (只有 RoundStats.EnqueuedCount 会被真实写入),状态链路同样如此。 if meta.ActiveCount != 2 { t.Fatalf("activeCount = %d, want 2", meta.ActiveCount) } claims, err := store.ClaimRun(ctx, "full", "ch", time.Now(), time.Now().Add(time.Minute), 10) if err != nil { t.Fatal(err) } if len(claims) != 2 { t.Fatalf("claims = %d, want 2", len(claims)) } for _, claim := range claims { if claim.Task.CardID != 1 && claim.Task.CardID != 4 { t.Fatalf("不应入队 card %d", claim.Task.CardID) } if claim.Task.Operation != FullSyncOperation { t.Fatalf("operation = %q, want %q", claim.Task.Operation, FullSyncOperation) } } } // TestProduceRePollIncludesCardsThatAlreadyHaveImei 是用户决策的直接回归测试: // 全量重查(而非只查空值卡),已有 IMEI 的卡也必须入队,否则无法发现换机。 func TestProduceRePollIncludesCardsThatAlreadyHaveImei(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{ {ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5", Imei: "865466070817587"}, {ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5", Imei: ""}, }) if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "repoll"}); err != nil { t.Fatal(err) } meta, err := store.GetRunMeta(ctx, "repoll") if err != nil { t.Fatal(err) } if meta.ActiveCount != 2 { t.Fatalf("全量重查应入队全部合格卡(含已有值),activeCount = %d, want 2", meta.ActiveCount) } } // TestProduceAppliesChannelScope 覆盖 §7 T2:按通道裁剪。 func TestProduceAppliesChannelScope(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{ {ID: 1, ICCID: "a", ChanKey: "173", TempKey: "sim_yd_v5"}, {ID: 2, ICCID: "b", ChanKey: "189", TempKey: "sim_yd_v5"}, {ID: 3, ICCID: "c", ChanKey: "173", TempKey: "sim_yd_v5"}, }) scope := &model.SyncScope{Channels: []string{"173"}, TriggerSource: model.TriggerSourceManual} if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "scoped", Scope: scope}); err != nil { t.Fatal(err) } meta, err := store.GetRunMeta(ctx, "scoped") if err != nil { t.Fatal(err) } if meta.ActiveCount != 2 { t.Fatalf("activeCount = %d, want 2(只入 173 通道)", meta.ActiveCount) } if meta.ScopeChannels != 1 { t.Fatalf("scopeChannels = %d, want 1", meta.ScopeChannels) } if meta.ScopeDigest == "" { t.Fatal("scopeDigest 不应为空") } claims, err := store.ClaimRun(ctx, "scoped", "173", time.Now(), time.Now().Add(time.Minute), 10) if err != nil { t.Fatal(err) } if len(claims) != 2 { t.Fatalf("173 通道应有 2 张卡,实际 %d", len(claims)) } } // TestProduceRejectsInvalidScope 非法 scope 应在建 Run 前失败。 func TestProduceRejectsInvalidScope(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}}) // 超过 200 个通道 tooMany := make([]string, 0, model.MaxScopeChannels+1) for i := 0; i <= model.MaxScopeChannels; i++ { tooMany = append(tooMany, "ch"+string(rune('a'+i%26))) } // 通道名重复会被去重,故用不重复的 tooMany = tooMany[:0] for i := 0; i <= model.MaxScopeChannels; i++ { tooMany = append(tooMany, "ch"+padInt(i)) } err := producer.Produce(ctx, model.ImeiQueueStartMessage{ RunID: "bad-scope", Scope: &model.SyncScope{Channels: tooMany}, }) if err == nil { t.Fatal("超限 scope 应被拒绝") } if _, runErr := store.GetRunMeta(ctx, "bad-scope"); runErr == nil { t.Fatal("非法 scope 不应创建 Run") } } // TestProduceIsIdempotentForSameRun 同一 RunID 重复投递不重复入队。 func TestProduceIsIdempotentForSameRun(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{ {ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}, {ID: 2, ICCID: "b", ChanKey: "ch", TempKey: "sim_yd_v5"}, }) for i := 0; i < 2; i++ { if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "dup"}); err != nil { t.Fatal(err) } } meta, err := store.GetRunMeta(ctx, "dup") if err != nil { t.Fatal(err) } if meta.ActiveCount != 2 { t.Fatalf("activeCount = %d, want 2(重复投递不应翻倍)", meta.ActiveCount) } } // TestProduceSkipsFinishedRun 终态 Run 不被重投消息复活。 func TestProduceSkipsFinishedRun(t *testing.T) { ctx := context.Background() producer, store := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}}) if err := producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "done"}); err != nil { t.Fatal(err) } // 排空队列让 Run 进入终态:先领取并终结任务,再收尾。 claims, err := store.ClaimRun(ctx, "done", "ch", time.Now(), time.Now().Add(time.Minute), 10) if err != nil || len(claims) != 1 { t.Fatalf("claims=%v err=%v", claims, err) } if _, err = store.FinalizeRunTasks(ctx, claims, []queue.TaskOutcome{{Success: true}}); err != nil { t.Fatal(err) } if completed, err := store.CompleteRunIfIdle(ctx, "done"); err != nil || !completed { t.Fatalf("complete run: completed=%v err=%v", completed, err) } err = producer.Produce(ctx, model.ImeiQueueStartMessage{RunID: "done"}) if !errors.Is(err, ErrRunFinished) { t.Fatalf("终态 Run 重投应返回 ErrRunFinished,实际 %v", err) } } // TestProduceRejectsEmptyRunID 空 RunID 直接失败。 func TestProduceRejectsEmptyRunID(t *testing.T) { ctx := context.Background() producer, _ := newProducer(t, []Card{{ID: 1, ICCID: "a", ChanKey: "ch", TempKey: "sim_yd_v5"}}) if err := producer.Produce(ctx, model.ImeiQueueStartMessage{}); err == nil { t.Fatal("空 RunID 应被拒绝") } } // TestScopeDigestIsStableAcrossOrdering 摘要对通道顺序不敏感, // 使「同一批通道以不同顺序请求」得到同一个 digest。 // // 排序由 NormalizeScope 完成,所以必须走归一化后再算 digest // (直接对未排序输入调 scopeDigest 得到不同结果属预期行为)。 func TestScopeDigestIsStableAcrossOrdering(t *testing.T) { a, err := model.NormalizeScope(&model.SyncScope{Channels: []string{"173", "189", "216"}}) if err != nil { t.Fatal(err) } b, err := model.NormalizeScope(&model.SyncScope{Channels: []string{"216", "173", "189"}}) if err != nil { t.Fatal(err) } if scopeDigest(a) != scopeDigest(b) { t.Fatalf("digest 应对顺序不敏感: %q vs %q", scopeDigest(a), scopeDigest(b)) } if scopeDigest(a) == "" { t.Fatal("非空 scope 的 digest 不应为空") } if scopeDigest(nil) != "" { t.Fatal("全量同步的 digest 应为空") } } func padInt(value int) string { if value == 0 { return "0" } digits := make([]byte, 0, 8) for value > 0 { digits = append([]byte{byte('0' + value%10)}, digits...) value /= 10 } return string(digits) }