package contract_test import ( "encoding/json" "reflect" "testing" "time" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/fullsync/contract" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/queue/zset" ) // Check every old field, including zero/omitempty behavior, so future adapters // can adopt the contract without silently changing the persisted wire format. func TestLegacyFieldSchemas(t *testing.T) { for _, pair := range []struct{ old, next any }{ {zset.RunMeta{}, contract.RunMeta{}}, {zset.FailureRecord{}, contract.FailureRecord{}}, {zset.Task{}, contract.Task{}}, } { old, next := reflect.TypeOf(pair.old), reflect.TypeOf(pair.next) for i := 0; i < old.NumField(); i++ { field := old.Field(i) replacement, ok := next.FieldByName(field.Name) if !ok || replacement.Tag.Get("json") != field.Tag.Get("json") || replacement.Type.Kind() != field.Type.Kind() { t.Errorf("%s.%s lost its legacy schema: old=%+v new=%+v", old.Name(), field.Name, field, replacement) } } } } func TestLegacyRunJSONRoundTrip(t *testing.T) { now := time.Date(2026, 9, 20, 1, 2, 3, 456, time.UTC) legacy := zset.RunMeta{ RunID: "legacy", Operation: "current_month_usage_full_sync", QueryMonth: "202609", Status: "completed_with_failures", ProducerDone: true, CurrentRound: 5, SuccessCount: 9, FailedCount: 1, CreatedAt: now, ProducerCompletedAt: now, CompletedAt: now, } data := mustJSON(t, legacy) var decoded contract.RunMeta if err := json.Unmarshal(data, &decoded); err != nil { t.Fatal(err) } if err := decoded.Validate(); err != nil { t.Fatalf("legacy run rejected: %v", err) } if got := string(mustJSON(t, decoded)); got != string(data) { t.Fatalf("legacy wire format changed:\n%s\n%s", data, got) } // New fields do not prevent old readers from consuming the same metadata. decoded.SyncType = contract.SyncReplay decoded.SourceRunID = "source" decoded.ReplayCycle = 1 decoded.EnqueuedCount = 10 decoded.PendingFailureCount = 1 var oldReader zset.RunMeta if err := json.Unmarshal(mustJSON(t, decoded), &oldReader); err != nil { t.Fatal(err) } oldReader.SyncType = "" oldReader.SourceRunID = "" oldReader.ReplayCycle = 0 oldReader.EnqueuedCount = 0 oldReader.PendingFailureCount = 0 if !reflect.DeepEqual(oldReader, legacy) { t.Fatalf("old reader lost fields: %+v", oldReader) } } func TestLegacyFailureAndTaskJSON(t *testing.T) { legacy := zset.FailureRecord{ Version: 1, TaskID: "source:op:42", OriginalRunID: "source", LatestRunID: "latest", QueryMonth: "202609", Operation: "op", CardID: 42, ICCID: "iccid", MSISDN: "msisdn", IMSI: "imsi", ChanKey: "chan", TempKey: "template", Carrier: "mobile", FailureType: "mobile_busy_exhausted", Round: 5, Attempts: 7, Code: 13000, ErrCode: "BUSY", Message: "try again", ReviewStatus: "pending", ReviewRemark: "remark", ReviewedBy: "operator", ReviewedAt: "2026-09-20", RetryCycle: 2, ReplayRunID: "replayed", FirstFailedAt: time.Unix(1, 0).UTC(), LastFailedAt: time.Unix(2, 0).UTC(), } var record contract.FailureRecord if err := json.Unmarshal(mustJSON(t, legacy), &record); err != nil { t.Fatal(err) } if string(mustJSON(t, record)) != string(mustJSON(t, legacy)) { t.Fatal("failure record wire format changed") } if summary := record.Summary(); summary.ErrorCategory != contract.ErrorRateLimited || summary.TaskID != legacy.TaskID || summary.FailedAt != legacy.LastFailedAt { t.Fatalf("incorrect failure projection: %+v", summary) } task := zset.Task{TaskID: "r:op:42", RunID: "r", Operation: "op", QueryMonth: "202609", ChanKey: "c", TempKey: "t", Operator: 2, CardID: 42, ICCID: "i", MSISDN: "m", IMSI: "s", NextExecuteAt: 1234} var newTask contract.Task if err := json.Unmarshal(mustJSON(t, task), &newTask); err != nil { t.Fatal(err) } newTask.NextExecuteAt = 9999 if string(mustJSON(t, newTask)) != string(mustJSON(t, task)) { t.Fatal("task wire format or scheduling exclusion changed") } } func mustJSON(t *testing.T, value any) []byte { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatal(err) } return data }