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),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user