package runtime import ( "context" "encoding/json" "fmt" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/testutil" ) // seedReplaySource writes a completed source run with n failures into store. func seedReplaySource(t *testing.T, redis zset.RedisClient, store *zset.Store, name string, n int) { t.Helper() ctx := context.Background() key, _ := store.RunMetaKey(name) failed, _ := store.FailedKey(name) if _, err := redis.Do(ctx, "HSET", key, "runId", name, "operation", "usage", "status", "completed_with_failures", "producerDone", "1", "currentRound", "1", "failedCount", fmt.Sprint(n)); err != nil { t.Fatal(err) } for i := 1; i <= n; i++ { rec := zset.FailureRecord{TaskID: fmt.Sprintf("task-%d", i), CardID: int64(i), ICCID: fmt.Sprintf("iccid-%d", i), ChanKey: "ch", TempKey: "template", Operation: "usage", FailureType: "mobile_busy"} payload, _ := json.Marshal(rec) if _, err := redis.Do(ctx, "HSET", failed, rec.TaskID, string(payload)); err != nil { t.Fatal(err) } } } // awaitTargetMaxRound waits for the handoff to create the target and returns // the persisted MaxRound of the replayed Run. func awaitTargetMaxRound(t *testing.T, svc *SimpleReplay, store *zset.Store, replayRunID string) int { t.Helper() ctx := context.Background() deadline := time.Now().Add(4 * time.Second) for time.Now().Before(deadline) { meta, err := store.GetRunMeta(ctx, replayRunID) if err == nil && meta.MaxRound > 0 && meta.ProducerDone { return meta.MaxRound } time.Sleep(20 * time.Millisecond) } meta, err := store.GetRunMeta(ctx, replayRunID) t.Fatalf("target never settled: %+v %v", meta, err) return 0 } func TestReplayMaxRoundsPrefersRequestThenConfig(t *testing.T) { redis := testutil.Redis(t) ctx := context.Background() store, _ := zset.New(redis, "flow:") // Use a default that differs from DefaultReplayMaxRounds so the assertion // proves the injected config value is used, not the built-in constant. svc, err := NewSimpleReplayWithDefault(5, store) if err != nil { t.Fatal(err) } seedReplaySource(t, redis, store, "flow:default-source", 2) receipt, err := svc.Request(ctx, "flow:default-source", "42", 0) if err != nil { t.Fatal(err) } if got := awaitTargetMaxRound(t, svc, store, receipt.ReplayRunID); got != 5 { t.Fatalf("unspecified request must inherit config default 5, got %d", got) } seedReplaySource(t, redis, store, "flow:override-source", 2) override, err := svc.Request(ctx, "flow:override-source", "42", 7) if err != nil { t.Fatal(err) } if got := awaitTargetMaxRound(t, svc, store, override.ReplayRunID); got != 7 { t.Fatalf("explicit request must win over config, got %d", got) } } func TestReplayMaxRoundsRejectsOutOfRange(t *testing.T) { redis := testutil.Redis(t) ctx := context.Background() store, _ := zset.New(redis, "flow:") svc, _ := NewSimpleReplay(store) seedReplaySource(t, redis, store, "flow:source", 1) for _, requested := range []int{-1, MaxReplayRounds + 1} { if _, err := svc.Request(ctx, "flow:source", "42", requested); err != contract.ErrInvalidArgument { t.Fatalf("maxRounds=%d must be rejected, got %v", requested, err) } } // The rejected requests must not have reserved the source. if got, err := redis.Do(ctx, "HGET", "flow:{flow:source}:meta", "replayTargetId"); err == nil && got != nil && got.String() != "" { t.Fatalf("rejected request must not reserve the source: %v", got.String()) } } // A worker restart re-enters through reconcile() without caller arguments, so // the budget must survive on the reservation rather than come from the caller. func TestReplayMaxRoundsSurvivesRestartRecovery(t *testing.T) { redis := testutil.Redis(t) ctx := context.Background() store, _ := zset.New(redis, "flow:") seedReplaySource(t, redis, store, "flow:source", 2) // Reserve with an explicit budget, then simulate a restart by building a // fresh service whose configured default differs from the reservation. first, _ := NewSimpleReplayWithDefault(1, store) receipt, err := first.Request(ctx, "flow:source", "42", 5) if err != nil { t.Fatal(err) } restarted, _ := NewSimpleReplayWithDefault(9, store) restarted.reconcile(ctx) if got := awaitTargetMaxRound(t, restarted, store, receipt.ReplayRunID); got != 5 { t.Fatalf("recovery must use the persisted budget 5, not the new default 9: got %d", got) } }