Files
iotlcm/internal/producer/imei/producer_test.go
T
MiFaZhan 2bec257289 chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。

- Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件)
- 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、
  库名 xiujie_admin → iotlcm、哨兵名占位
- 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异,
  不破坏「校验 MSISDN 格式」等测试断言)
- 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像
  digest 记录
- Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23),
  移除写死的 git 凭据
- README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0
  LICENSE,LF 行尾 + .gitattributes

已知:3 个测试包在源仓同一提交上同样失败(client/upstream、
service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00

263 lines
8.6 KiB
Go

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)
}