package batch import ( "context" "encoding/json" "errors" "io" "reflect" "strings" "testing" "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/entity" ) type executorStub struct { calls int batchNo string err error } func (s *executorStub) Execute(_ context.Context, batchNo string) error { s.calls++ s.batchNo = batchNo return s.err } type processingExecutorStub struct { executorStub canCalls int main *entity.BatchMain can bool canErr error selfClaim bool } type blockingExecutorStub struct { calls int } func (s *blockingExecutorStub) Execute(ctx context.Context, _ string) error { s.calls++ <-ctx.Done() return ctx.Err() } func (s *processingExecutorStub) CanExecuteProcessing(_ context.Context, main *entity.BatchMain) (bool, error) { s.canCalls++ s.main = main return s.can, s.canErr } func (s *processingExecutorStub) ClaimsProcessingReentry() bool { return s.selfClaim } type repositoryStub struct { mains []*entity.BatchMain findErr error markErr error claimed bool findCalls int markCalls int markBatchNo string markType int failErr error failCalls int failBatchNo string failType int failReason string successErr error successCalls int successBatchNo string successType int successCount int successFails int successReason string reclaimErr error reclaimSet bool reclaimed bool reclaimCalls int reclaimBatchNo string reclaimType int } func (s *repositoryStub) FindMain(context.Context, string) (*entity.BatchMain, error) { s.findCalls++ if s.findErr != nil { return nil, s.findErr } if len(s.mains) == 0 { return nil, nil } index := s.findCalls - 1 if index >= len(s.mains) { index = len(s.mains) - 1 } return s.mains[index], nil } func (s *repositoryStub) MarkProcessing(_ context.Context, batchNo string, batchType int) (bool, error) { s.markCalls++ s.markBatchNo = batchNo s.markType = batchType return s.claimed, s.markErr } func (s *repositoryStub) MarkFailed(_ context.Context, batchNo string, batchType int, reason string) (bool, error) { s.failCalls++ s.failBatchNo = batchNo s.failType = batchType s.failReason = reason return s.failErr == nil, s.failErr } func (s *repositoryStub) MarkSuccess(_ context.Context, batchNo string, batchType, success, fail int, reason string) (bool, error) { s.successCalls++ s.successBatchNo = batchNo s.successType = batchType s.successCount = success s.successFails = fail s.successReason = reason return s.successErr == nil, s.successErr } func (s *repositoryStub) ReclaimProcessing(_ context.Context, batchNo string, batchType int, _ *gtime.Time) (bool, error) { s.reclaimCalls++ s.reclaimBatchNo = batchNo s.reclaimType = batchType if !s.reclaimSet { return s.reclaimErr == nil, s.reclaimErr } return s.reclaimed, s.reclaimErr } func TestMessageJSONContract(t *testing.T) { body, err := json.Marshal(Message{BatchNo: "BATCH-1", BatchType: Type103}) if err != nil { t.Fatalf("marshal message: %v", err) } const expected = `{"batchNo":"BATCH-1","batchType":103}` if string(body) != expected { t.Fatalf("message JSON = %s, want %s", body, expected) } } func TestDispatcherSupportsLifecycleTypesAndExplicitRegistration(t *testing.T) { executor := &executorStub{} dispatcher := NewDispatcher(executor) for _, batchType := range []int{Type103, Type104} { actual, ok := dispatcher.Executor(batchType) if !ok || actual != executor { t.Fatalf("batch type %d was not registered with shared executor", batchType) } } if _, ok := dispatcher.Executor(102); ok { t.Fatal("unexpected executor for unsupported batch type") } inbound := &executorStub{} dispatcher.Register(1, inbound) actual, ok := dispatcher.Executor(1) if !ok || actual != inbound { t.Fatal("registered inbound executor was not returned") } dataExecutor := &executorStub{} dispatcher.Register(Type107, dataExecutor) dispatcher.Register(Type109, dataExecutor) dispatcher.Register(Type110, dataExecutor) for _, batchType := range []int{Type107, Type109, Type110} { actual, ok = dispatcher.Executor(batchType) if !ok || actual != dataExecutor { t.Fatalf("batch type %d was not registered with the data service executor", batchType) } } } func TestRuntimeAcknowledgesInvalidAndUnclaimableMessages(t *testing.T) { g.Log().SetWriter(io.Discard) tests := []struct { name string message Message mains []*entity.BatchMain claimed bool wantFind int wantMarked int }{ {name: "empty batch number", message: Message{BatchType: Type103}}, {name: "blank batch number", message: Message{BatchNo: " ", BatchType: Type103}}, {name: "unsupported type", message: Message{BatchNo: "B-1", BatchType: 102}}, {name: "not found", message: Message{BatchNo: "B-1", BatchType: Type103}, wantFind: 1}, {name: "type mismatch", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, wantFind: 1}, {name: "processing", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 1}, {name: "terminal", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: 3}}, wantFind: 1}, {name: "CAS lost", message: Message{BatchNo: "B-1", BatchType: Type103}, mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}, {BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusProcessing}}, wantFind: 2, wantMarked: 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repository := &repositoryStub{mains: tt.mains, claimed: tt.claimed} executor := &executorStub{} runtime := NewRuntime(repository, NewDispatcher(executor)) if err := runtime.Handle(context.Background(), tt.message); err != nil { t.Fatalf("Handle() error = %v", err) } if repository.findCalls != tt.wantFind || repository.markCalls != tt.wantMarked { t.Fatalf("repository calls: find=%d mark=%d, want find=%d mark=%d", repository.findCalls, repository.markCalls, tt.wantFind, tt.wantMarked) } if executor.calls != 0 { t.Fatalf("executor calls = %d, want 0", executor.calls) } }) } } func TestRuntimeExecutesRegisteredDataServiceBatch(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-109", BatchType: Type109, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, } dataExecutor := &executorStub{} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type109, dataExecutor) dispatcher.Register(Type110, dataExecutor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-109", BatchType: Type109}); err != nil { t.Fatalf("Handle() error = %v", err) } if dataExecutor.calls != 1 || dataExecutor.batchNo != "B-109" { t.Fatalf("data executor was not invoked: %+v", dataExecutor) } } func TestRuntimeExecutesTrafficCutoffConfigWithDedicatedExecutor(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-41", BatchType: Type41, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, } dataExecutor := &executorStub{err: errors.New("data executor must not be called")} trafficCutoffConfigExecutor := &executorStub{} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type107, dataExecutor) dispatcher.Register(Type41, trafficCutoffConfigExecutor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41", BatchType: Type41}); err != nil { t.Fatalf("Handle() error = %v", err) } if trafficCutoffConfigExecutor.calls != 1 || trafficCutoffConfigExecutor.batchNo != "B-41" { t.Fatalf("traffic cutoff config executor was not invoked: %+v", trafficCutoffConfigExecutor) } if dataExecutor.calls != 0 { t.Fatalf("data executor calls = %d, want 0", dataExecutor.calls) } } func TestRuntimeIgnoresFinalizedTrafficCutoffConfigDuplicateMessage(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-41-DONE", BatchType: Type41, BatchAsyncStatus: AsyncStatusSuccess}}, } trafficCutoffConfigExecutor := &executorStub{err: errors.New("traffic cutoff config executor must not be called")} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type41, trafficCutoffConfigExecutor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-41-DONE", BatchType: Type41}); err != nil { t.Fatalf("Handle() error = %v", err) } if trafficCutoffConfigExecutor.calls != 0 { t.Fatalf("traffic cutoff config executor calls = %d, want 0", trafficCutoffConfigExecutor.calls) } } func TestRuntimeMarksExecutorFailureAsFailed(t *testing.T) { expectedErr := errors.New("execute failed") repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, } executor := &executorStub{err: expectedErr} runtime := NewRuntime(repository, NewDispatcher(executor)) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); err != nil { t.Fatalf("Handle() error = %v, want nil", err) } if repository.markCalls != 1 || repository.markBatchNo != "B-104" || repository.markType != Type104 { t.Fatalf("unexpected claim call: %+v", repository) } if executor.calls != 1 || executor.batchNo != "B-104" { t.Fatalf("unexpected executor call: %+v", executor) } if repository.failCalls != 1 || repository.failBatchNo != "B-104" || repository.failType != Type104 || repository.failReason != expectedErr.Error() { t.Fatalf("unexpected failure mark call: %+v", repository) } } func TestRuntimeReturnsRetryableExecutorFailureWithoutFinalizing(t *testing.T) { sourceErr := errors.New("source file is temporarily unavailable") repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, } executor := &executorStub{err: Retryable(sourceErr)} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(1, executor) runtime := NewRuntime(repository, dispatcher) err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1}) if !errors.Is(err, sourceErr) || !IsRetryable(err) { t.Fatalf("Handle() error = %v, want retryable wrapper of %v", err, sourceErr) } if repository.failCalls != 0 { t.Fatalf("MarkFailed calls = %d, want 0", repository.failCalls) } } func TestRuntimeReclaimsRetryableProcessingBatch(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "INB-RETRY", BatchType: 1, BatchAsyncStatus: AsyncStatusProcessing}}, reclaimSet: true, reclaimed: true, } executor := &executorStub{} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(1, executor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "INB-RETRY", BatchType: 1, Retryable: true}); err != nil { t.Fatalf("Handle() error = %v", err) } if repository.reclaimCalls != 1 || repository.reclaimBatchNo != "INB-RETRY" || repository.reclaimType != 1 { t.Fatalf("unexpected reclaim call: %+v", repository) } if executor.calls != 1 { t.Fatalf("executor calls = %d, want 1", executor.calls) } } func TestRuntimeMarksTimeoutAsFailed(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-TIMEOUT", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, } executor := &blockingExecutorStub{} runtime := NewRuntime( repository, NewDispatcher(executor), WithExecutionTimeout(time.Millisecond), WithFinalizationTimeout(time.Second), ) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-TIMEOUT", BatchType: Type104}); err != nil { t.Fatalf("Handle() error = %v, want nil", err) } if executor.calls != 1 { t.Fatalf("executor calls = %d, want 1", executor.calls) } if repository.failCalls != 1 || !strings.Contains(repository.failReason, "timed out after 1ms") { t.Fatalf("unexpected timeout failure mark: %+v", repository) } } func TestRuntimeReturnsFailureMarkErrors(t *testing.T) { executeErr := errors.New("execute failed") markErr := errors.New("mark failed") repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-104", BatchType: Type104, BatchAsyncStatus: AsyncStatusPending}}, claimed: true, failErr: markErr, } runtime := NewRuntime(repository, NewDispatcher(&executorStub{err: executeErr})) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-104", BatchType: Type104}); !errors.Is(err, markErr) { t.Fatalf("Handle() error = %v, want %v", err, markErr) } } func TestRuntimeCompensatesFailureMarkWithoutReexecutingBatch(t *testing.T) { markErr := errors.New("mark failed") repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-RETRY", BatchType: Type104, BatchAsyncStatus: AsyncStatusProcessing}}, failErr: markErr, } executor := &executorStub{err: errors.New("must not execute again")} runtime := NewRuntime(repository, NewDispatcher(executor)) err := runtime.Handle(context.Background(), Message{ BatchNo: "B-RETRY", BatchType: Type104, FailureReason: "original executor failure", }) if !errors.Is(err, markErr) { t.Fatalf("Handle() error = %v, want %v", err, markErr) } if executor.calls != 0 { t.Fatalf("executor calls = %d, want 0 for failure finalization retry", executor.calls) } if repository.markCalls != 0 { t.Fatalf("MarkProcessing calls = %d, want 0 for failure finalization retry", repository.markCalls) } if repository.failCalls != 1 || repository.failReason != "original executor failure" { t.Fatalf("unexpected failure finalization call: %+v", repository) } } func TestRuntimeAllowsSelfClaimingProcessingReEntryWithoutRefreshingStartTime(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}}, } executor := &processingExecutorStub{can: true, selfClaim: true} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type105, executor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil { t.Fatalf("Handle() error = %v", err) } if executor.canCalls != 1 || executor.main == nil || executor.main.BatchNo != "RST-1" { t.Fatalf("processing check not called with main: %+v", executor) } if repository.markCalls != 0 { t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls) } if repository.reclaimCalls != 0 { t.Fatalf("ReclaimProcessing calls = %d, want 0 for self-claiming executor", repository.reclaimCalls) } if executor.calls != 1 || executor.batchNo != "RST-1" { t.Fatalf("executor calls = %+v", executor.executorStub) } } func TestRuntimeMarksProcessingReEntryFailureAsFailed(t *testing.T) { expectedErr := errors.New("resume failed") repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "RST-1", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}}, } executor := &processingExecutorStub{can: true} executor.err = expectedErr dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type105, executor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-1", BatchType: Type105}); err != nil { t.Fatalf("Handle() error = %v, want nil", err) } if repository.markCalls != 0 { t.Fatalf("MarkProcessing calls = %d, want 0", repository.markCalls) } if repository.failCalls != 1 || repository.failBatchNo != "RST-1" || repository.failType != Type105 || repository.failReason != expectedErr.Error() { t.Fatalf("unexpected failure mark call: %+v", repository) } } func TestRuntimeIgnoresProcessingWhenOptInExecutorDeclines(t *testing.T) { repository := &repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "RST-2", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}}, } executor := &processingExecutorStub{can: false} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type105, executor) runtime := NewRuntime(repository, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-2", BatchType: Type105}); err != nil { t.Fatalf("Handle() error = %v", err) } if executor.canCalls != 1 { t.Fatalf("processing check calls = %d, want 1", executor.canCalls) } if executor.calls != 0 { t.Fatalf("executor calls = %d, want 0", executor.calls) } } func TestRuntimeReturnsDatabaseErrors(t *testing.T) { findErr := errors.New("find failed") runtime := NewRuntime(&repositoryStub{findErr: findErr}, NewDispatcher(&executorStub{})) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, findErr) { t.Fatalf("find error = %v, want %v", err, findErr) } markErr := errors.New("mark failed") runtime = NewRuntime(&repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "B-1", BatchType: Type103, BatchAsyncStatus: AsyncStatusPending}}, markErr: markErr, }, NewDispatcher(&executorStub{})) if err := runtime.Handle(context.Background(), Message{BatchNo: "B-1", BatchType: Type103}); !errors.Is(err, markErr) { t.Fatalf("mark error = %v, want %v", err, markErr) } checkErr := errors.New("check failed") processing := &processingExecutorStub{canErr: checkErr} dispatcher := NewDispatcher(&executorStub{}) dispatcher.Register(Type105, processing) runtime = NewRuntime(&repositoryStub{ mains: []*entity.BatchMain{{BatchNo: "RST-3", BatchType: Type105, BatchAsyncStatus: AsyncStatusProcessing}}, }, dispatcher) if err := runtime.Handle(context.Background(), Message{BatchNo: "RST-3", BatchType: Type105}); !errors.Is(err, checkErr) { t.Fatalf("processing check error = %v, want %v", err, checkErr) } } func TestMessageHasOnlyContractFields(t *testing.T) { typeOfMessage := reflect.TypeOf(Message{}) if typeOfMessage.NumField() != 4 { t.Fatalf("Message has %d fields, want 4", typeOfMessage.NumField()) } if field, ok := typeOfMessage.FieldByName("FailureReason"); !ok || field.Tag.Get("json") != "failureReason,omitempty" { t.Fatalf("FailureReason JSON contract is missing or incorrect") } if field, ok := typeOfMessage.FieldByName("Retryable"); !ok || field.Tag.Get("json") != "retryable,omitempty" { t.Fatalf("Retryable JSON contract is missing or incorrect") } }