651 lines
23 KiB
Go
651 lines
23 KiB
Go
package data
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gcfg"
|
|
upstreamclient "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/client/upstream"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
|
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model"
|
|
)
|
|
|
|
func TestDurableAPNResultScheduleIsThreeMinutesAndThreeRetries(t *testing.T) {
|
|
if maxResultQueries != 4 {
|
|
t.Fatalf("首次加三次重试应为四次查询: got=%d", maxResultQueries)
|
|
}
|
|
base := time.Date(2026, 10, 15, 8, 0, 0, 0, time.UTC)
|
|
for i, minute := range []int{3, 6, 9, 12} {
|
|
got := nextResultCheck(SubBatch{SubmittedAt: base, PollCount: i})
|
|
want := base.Add(time.Duration(minute) * time.Minute)
|
|
if !got.Equal(want) {
|
|
t.Fatalf("第%d次查询: got=%v want=%v", i+1, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type durableFake struct {
|
|
mu sync.Mutex
|
|
batches map[string]SubBatch
|
|
next map[string]time.Time
|
|
outcomes map[string][]Outcome
|
|
preparedBeforeSubmit int
|
|
}
|
|
|
|
func newDurableFake() *durableFake {
|
|
return &durableFake{batches: map[string]SubBatch{}, next: map[string]time.Time{}, outcomes: map[string][]Outcome{}}
|
|
}
|
|
func (f *durableFake) HasDurableParent(context.Context, string) (bool, error) { return true, nil }
|
|
func (f *durableFake) PrepareSubBatch(_ context.Context, b SubBatch) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, ok := f.batches[b.OperationID]; !ok {
|
|
b.ID = int64(len(f.batches) + 1)
|
|
f.batches[b.OperationID] = b
|
|
}
|
|
return nil
|
|
}
|
|
func (f *durableFake) ResumeOrPrepare(ctx context.Context, _ string, p func(context.Context) error) error {
|
|
return p(ctx)
|
|
}
|
|
func (f *durableFake) GetSubBatch(_ context.Context, id string) (*SubBatch, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
b := f.batches[id]
|
|
b.NextCheckAt = f.next[id]
|
|
return &b, nil
|
|
}
|
|
func (f *durableFake) DelaySubBatchPrecheck(_ context.Context, snapshot SubBatch, next time.Time, _ string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
b := f.batches[snapshot.OperationID]
|
|
if b.Status == snapshot.Status && b.LeaseToken == snapshot.LeaseToken && f.next[snapshot.OperationID].Equal(snapshot.NextCheckAt) {
|
|
f.next[snapshot.OperationID] = next
|
|
}
|
|
return nil
|
|
}
|
|
func (f *durableFake) ReleaseUnsentSubBatch(_ context.Context, b SubBatch, _ string) error {
|
|
old := f.batches[b.OperationID]
|
|
old.Status = b.Status
|
|
old.LeaseToken = ""
|
|
if b.Status == "prepared" {
|
|
old.SubmittedAt = time.Time{}
|
|
} else {
|
|
old.PollCount--
|
|
}
|
|
f.batches[b.OperationID] = old
|
|
return nil
|
|
}
|
|
func (f *durableFake) SubBatchChannel(_ context.Context, id string) (string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.batches[id].ChanKey, nil
|
|
}
|
|
func (f *durableFake) ProtectExecutionFailure(context.Context, string, string) error { return nil }
|
|
func (f *durableFake) SaveSubBatchProgress(_ context.Context, b SubBatch, results []Outcome) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
old := f.outcomes[b.OperationID]
|
|
if old == nil {
|
|
old = make([]Outcome, len(results))
|
|
for i := range old {
|
|
old[i].Pending = true
|
|
}
|
|
}
|
|
for i, result := range results {
|
|
if old[i].Pending && !result.Pending {
|
|
old[i] = result
|
|
}
|
|
}
|
|
confirmed := false
|
|
for _, result := range old {
|
|
confirmed = confirmed || !result.Pending
|
|
}
|
|
if confirmed {
|
|
f.outcomes[b.OperationID] = old
|
|
}
|
|
return nil
|
|
}
|
|
func (f *durableFake) ClaimSubBatch(_ context.Context, id, token string, now time.Time) (*SubBatch, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
b, ok := f.batches[id]
|
|
if !ok || b.LeaseToken != "" || b.Status == "done" || b.Status == "review" || f.next[id].After(now) {
|
|
return nil, nil
|
|
}
|
|
out := b
|
|
b.LeaseToken = token
|
|
out.LeaseToken = token
|
|
if b.Status == "prepared" {
|
|
b.Status = "submitting"
|
|
b.SubmittedAt = now
|
|
out.SubmittedAt = now
|
|
} else {
|
|
b.PollCount++
|
|
out.PollCount = b.PollCount
|
|
}
|
|
f.batches[id] = b
|
|
return &out, nil
|
|
}
|
|
func (f *durableFake) ListDueSubBatches(_ context.Context, now time.Time, limit int) ([]string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var ids []string
|
|
for id, b := range f.batches {
|
|
if b.Status != "done" && b.Status != "review" && !f.next[id].After(now) {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool {
|
|
a, b := f.next[ids[i]], f.next[ids[j]]
|
|
if a.Equal(b) {
|
|
return ids[i] < ids[j]
|
|
}
|
|
return a.Before(b)
|
|
})
|
|
if len(ids) > limit {
|
|
ids = ids[:limit]
|
|
}
|
|
return ids, nil
|
|
}
|
|
func (f *durableFake) UpdateSubBatch(_ context.Context, b SubBatch, status, job, reason string, next time.Time, count int) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
old := f.batches[b.OperationID]
|
|
if old.LeaseToken != b.LeaseToken {
|
|
return errors.New("lease lost")
|
|
}
|
|
old.Status = status
|
|
old.JobID = job
|
|
old.PollCount = count
|
|
old.LeaseToken = ""
|
|
f.batches[b.OperationID] = old
|
|
f.next[b.OperationID] = next
|
|
return nil
|
|
}
|
|
func (f *durableFake) CompleteSubBatch(_ context.Context, b SubBatch, results []Outcome) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
old := f.batches[b.OperationID]
|
|
if old.LeaseToken != b.LeaseToken {
|
|
return errors.New("lease lost")
|
|
}
|
|
old.Status = "done"
|
|
old.LeaseToken = ""
|
|
f.batches[b.OperationID] = old
|
|
f.outcomes[b.OperationID] = results
|
|
return nil
|
|
}
|
|
func (f *durableFake) FinalizeReadyBatch(context.Context, string) error { return nil }
|
|
func (f *durableFake) ListRecoverableBatches(context.Context, int) ([]string, error) { return nil, nil }
|
|
|
|
type asyncFake struct {
|
|
submitCalls int
|
|
queryCalls int
|
|
submit func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
|
query func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error)
|
|
}
|
|
|
|
func (f *asyncFake) SubmitSimApnFunction(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
f.submitCalls++
|
|
return f.submit(in)
|
|
}
|
|
func (f *asyncFake) QuerySimApnFunctionResult(_ context.Context, in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
f.queryCalls++
|
|
return f.query(in)
|
|
}
|
|
func fixtureSub() SubBatch {
|
|
op, _ := OperationByBatchType(consts.BatchTypeDataStop)
|
|
return newSubBatch(&Task{Batch: Batch{BatchNo: "P-1", BatchType: consts.BatchTypeDataStop}, Operation: op, ApnName: "CMIOT"}, []*Detail{{ID: 1, ICCID: "iccid-1", MSISDN: "1001", ChanKey: "a"}, {ID: 2, ICCID: "iccid-2", MSISDN: "1002", ChanKey: "a"}})
|
|
}
|
|
func pendingResponse(in *model.SimCardApnChangeInput, code int) []model.SimBaseRes[model.SimCardApnChangeOutputItem] {
|
|
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
|
for _, key := range in.SimKeys {
|
|
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: code, Message: "结果暂不可查", ResData: model.SimCardApnChangeOutputItem{OrderNum: "job-1", Pending: true}})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestDurableAPNRestartDoesNotSubmitAcceptedAgain(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
if repo.batches[sub.OperationID].Status != "submitting" {
|
|
t.Fatal("提交前未保存意图")
|
|
}
|
|
return pendingResponse(in, 0), nil
|
|
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
if in.ReqData.QueryJobID != "job-1" {
|
|
t.Fatalf("查询丢失jobId: %+v", in.ReqData)
|
|
}
|
|
out := pendingResponse(in, 0)
|
|
out[0].ResData.Pending = false
|
|
out[1].ResData.Pending = false
|
|
out[1].Code = 12047
|
|
out[1].Message = "明确失败"
|
|
return out, nil
|
|
}}
|
|
e := &executor{}
|
|
before := time.Now()
|
|
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.next[sub.OperationID].Before(before.Add(firstResultDelay)) || repo.next[sub.OperationID].After(time.Now().Add(firstResultDelay)) {
|
|
t.Fatalf("首查未按firstResultDelay排程: %v", repo.next[sub.OperationID])
|
|
}
|
|
// A second process must respect next_check_at and never issue a second write.
|
|
restarted := &executor{}
|
|
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if upstream.queryCalls != 0 {
|
|
t.Fatal("过早查询")
|
|
}
|
|
repo.next[sub.OperationID] = time.Time{}
|
|
if err := restarted.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if upstream.submitCalls != 1 || upstream.queryCalls != 1 {
|
|
t.Fatalf("submit=%d query=%d", upstream.submitCalls, upstream.queryCalls)
|
|
}
|
|
outcomes := repo.outcomes[sub.OperationID]
|
|
if len(outcomes) != 2 || !outcomes[0].Success || outcomes[1].Success {
|
|
t.Fatalf("部分失败未逐卡保留: %+v", outcomes)
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNTimeoutRecoversMappingWithoutResubmission(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return nil, context.DeadlineExceeded
|
|
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
if !in.ReqData.RecoverOnly || in.ReqData.QueryJobID != "" || in.ReqData.OperationID != sub.OperationID {
|
|
t.Fatalf("不是按operationId恢复: %+v", in.ReqData)
|
|
}
|
|
return pendingResponse(in, 0), nil
|
|
}}
|
|
e := &executor{}
|
|
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.outcomes) != 0 {
|
|
t.Fatal("超时不能标记办理失败")
|
|
}
|
|
repo.next[sub.OperationID] = time.Time{}
|
|
if err := e.processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if upstream.submitCalls != 1 || upstream.queryCalls != 1 || repo.batches[sub.OperationID].JobID != "job-1" {
|
|
t.Fatalf("受理恢复错误: %+v", repo.batches[sub.OperationID])
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNPendingNonzeroStopsAfterQueryWindowWithoutFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
sub.Status = "accepted"
|
|
sub.JobID = "job-1"
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return pendingResponse(in, 500), nil
|
|
}}
|
|
for i := 1; i <= maxResultQueries; i++ {
|
|
repo.next[sub.OperationID] = time.Time{}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.PollCount != i {
|
|
t.Fatalf("poll count=%d want %d", b.PollCount, i)
|
|
}
|
|
if i < maxResultQueries && b.Status != "accepted" {
|
|
t.Fatalf("pending误终态: %+v", b)
|
|
}
|
|
}
|
|
if repo.batches[sub.OperationID].Status != "review" || len(repo.outcomes) != 0 {
|
|
t.Fatal("补查窗口耗尽后未确认不得写办理失败")
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNMixedResultsPersistAcrossMissingAndReview(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
out := pendingResponse(in, 0)
|
|
out[0].ResData.Pending = false
|
|
out[0].ResData.OrderNum = "single-order"
|
|
return out, nil
|
|
}, query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return nil, nil
|
|
}}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := repo.outcomes[sub.OperationID]; len(got) != 2 || !got[0].Success || !got[1].Pending {
|
|
t.Fatalf("混合确认结果未立即保存: %+v", got)
|
|
}
|
|
base := repo.batches[sub.OperationID].SubmittedAt
|
|
for i := 1; i <= maxResultQueries; i++ {
|
|
repo.next[sub.OperationID] = time.Time{}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.PollCount != i || !b.SubmittedAt.Equal(base) {
|
|
t.Fatalf("重启丢失持久时点/次数: %+v", b)
|
|
}
|
|
if i < maxResultQueries && !repo.next[sub.OperationID].Equal(base.Add(firstResultDelay+time.Duration(i)*furtherResultDelay)) {
|
|
t.Fatalf("排程未按submitted_at: %v", repo.next[sub.OperationID])
|
|
}
|
|
}
|
|
if !repo.outcomes[sub.OperationID][0].Success || repo.batches[sub.OperationID].Status != "review" || upstream.submitCalls != 1 {
|
|
t.Fatal("后续缺卡覆盖终态或重发")
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNMappingRecoveryKeepsConsumedPollCount(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
sub.Status = "uncertain"
|
|
sub.PollCount = 2
|
|
sub.SubmittedAt = time.Now().Add(-8 * time.Minute)
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
if !in.ReqData.RecoverOnly {
|
|
t.Fatal("恢复应为只读")
|
|
}
|
|
return pendingResponse(in, 0), nil
|
|
}}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.JobID != "job-1" || b.PollCount != 3 || !repo.next[sub.OperationID].Equal(sub.SubmittedAt.Add(firstResultDelay+time.Duration(b.PollCount)*furtherResultDelay)) {
|
|
t.Fatalf("恢复jobId清零或重排: %+v next=%v", b, repo.next[sub.OperationID])
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNExplicitUncertainAndMissingNeverFailCard(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{submit: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return []model.SimBaseRes[model.SimCardApnChangeOutputItem]{{SimKey: in.SimKeys[0], Code: 9999, Message: "代理未确认", ResData: model.SimCardApnChangeOutputItem{Uncertain: true}}}, nil
|
|
}}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repo.outcomes) != 0 || repo.batches[sub.OperationID].Status != "uncertain" {
|
|
t.Fatal("显式未知或缺卡不能转明确失败")
|
|
}
|
|
}
|
|
|
|
type capabilityFake struct {
|
|
*asyncFake
|
|
checkErr error
|
|
}
|
|
|
|
func (f *capabilityFake) CheckSimApnAsyncCapability(context.Context, *model.SimCardApnChangeInput) error {
|
|
return f.checkErr
|
|
}
|
|
|
|
type requestNotSent struct{}
|
|
|
|
func (requestNotSent) Error() string { return "能力预检明确拒绝" }
|
|
func (requestNotSent) APNRequestNotSent() bool { return true }
|
|
|
|
func TestDurableAPNCapabilityRefusalDoesNotClaimOrSpendQueries(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &capabilityFake{asyncFake: &asyncFake{}, checkErr: errors.New("门禁关闭")}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err == nil {
|
|
t.Fatal("应拒绝预检")
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.Status != "prepared" || b.LeaseToken != "" || !b.SubmittedAt.IsZero() || b.PollCount != 0 || upstream.submitCalls != 0 {
|
|
t.Fatalf("预检拒绝却已领取或提交: %+v", b)
|
|
}
|
|
upstream.checkErr = nil
|
|
upstream.submit = func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return pendingResponse(in, 0), nil
|
|
}
|
|
if upstream.submitCalls != 0 || !repo.next[sub.OperationID].After(time.Now()) {
|
|
t.Fatal("只读预检拒绝未持久退避")
|
|
}
|
|
repo.next[sub.OperationID] = time.Time{}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if upstream.submitCalls != 1 || repo.batches[sub.OperationID].Status != "accepted" {
|
|
t.Fatal("门禁打开且退避到期后不能恢复首发")
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNCapabilityRefusalDoesNotBlockReadOnlyQueries(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
status string
|
|
jobID string
|
|
recoverOnly bool
|
|
}{
|
|
{name: "accepted_with_job_id", status: "accepted", jobID: "job-1"},
|
|
{name: "submitting", status: "submitting", recoverOnly: true},
|
|
{name: "uncertain", status: "uncertain", recoverOnly: true},
|
|
{name: "accepted_without_job_id", status: "accepted", recoverOnly: true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
sub.Status = tt.status
|
|
sub.JobID = tt.jobID
|
|
sub.SubmittedAt = time.Now().Add(-firstResultDelay)
|
|
if err := repo.PrepareSubBatch(ctx, sub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upstream := &capabilityFake{
|
|
checkErr: errors.New("门禁关闭"),
|
|
asyncFake: &asyncFake{
|
|
submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
t.Fatal("只读补查不得重新提交")
|
|
return nil, nil
|
|
},
|
|
query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
if in.ReqData.OperationID != sub.OperationID || in.ReqData.QueryJobID != tt.jobID || in.ReqData.RecoverOnly != tt.recoverOnly {
|
|
t.Fatalf("只读查询参数错误: %+v", in.ReqData)
|
|
}
|
|
return pendingResponse(in, 0), nil
|
|
},
|
|
},
|
|
}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatalf("只读查询被提交门禁阻断: %v", err)
|
|
}
|
|
if upstream.queryCalls != 1 || upstream.submitCalls != 0 {
|
|
t.Fatalf("应仅查询一次: query=%d submit=%d", upstream.queryCalls, upstream.submitCalls)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.Status != "accepted" || b.JobID != "job-1" || b.PollCount != 1 || b.LeaseToken != "" {
|
|
t.Fatalf("只读查询结果未持久化: %+v", b)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNOnlyTypedUnsentCanReturnPrepared(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{submit: func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return nil, fmt.Errorf("预检竞态: %w", requestNotSent{})
|
|
}}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.PollCount != 0 {
|
|
t.Fatalf("确定未发送应安全回退: %+v", b)
|
|
}
|
|
upstream.submit = func(*model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
return nil, context.DeadlineExceeded
|
|
}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.batches[sub.OperationID].Status != "uncertain" {
|
|
t.Fatal("普通网络错误被误作确定未发送")
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNFinishedFailure9999IsTerminal(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
sub.Status = "accepted"
|
|
sub.JobID = "failed-job"
|
|
sub.SubmittedAt = time.Now().Add(-time.Minute)
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
upstream := &asyncFake{query: func(in *model.SimCardApnChangeInput) ([]model.SimBaseRes[model.SimCardApnChangeOutputItem], error) {
|
|
var out []model.SimBaseRes[model.SimCardApnChangeOutputItem]
|
|
for _, key := range in.SimKeys {
|
|
out = append(out, model.SimBaseRes[model.SimCardApnChangeOutputItem]{SimKey: key, Code: 9999, Message: "任务已完成且明确失败"})
|
|
}
|
|
return out, nil
|
|
}}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, upstream, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.batches[sub.OperationID].Status != "done" || len(repo.outcomes[sub.OperationID]) != 2 {
|
|
t.Fatal("明确9999被误判待查")
|
|
}
|
|
for _, out := range repo.outcomes[sub.OperationID] {
|
|
if out.Pending || out.Success || out.Code != 9999 {
|
|
t.Fatalf("明确失败终态错误: %+v", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDurableAPNRealClientSecondProbeRejectsThenSubmitsOnce(t *testing.T) {
|
|
oldAdapter := g.Cfg().GetAdapter()
|
|
defer g.Cfg().SetAdapter(oldAdapter)
|
|
ctx := context.Background()
|
|
repo := newDurableFake()
|
|
sub := fixtureSub()
|
|
_ = repo.PrepareSubBatch(ctx, sub)
|
|
probes, submits := 0, 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var in model.SimCardApnChangeInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
t.Error(err)
|
|
http.Error(w, "bad", 400)
|
|
return
|
|
}
|
|
if r.URL.Path == "/sim/query/sim-apn-capability" {
|
|
probes++
|
|
if probes == 2 {
|
|
http.Error(w, "第二次探测拒绝", 404)
|
|
return
|
|
}
|
|
type capability struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
}
|
|
out := make([]model.SimBaseRes[capability], 0, len(in.SimKeys))
|
|
for _, key := range in.SimKeys {
|
|
out = append(out, model.SimBaseRes[capability]{SimKey: key, ResData: capability{ProtocolVersion: 1}})
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
|
return
|
|
}
|
|
submits++
|
|
out := pendingResponse(&in, 0)
|
|
for i := range out {
|
|
out[i].ResData.Pending = false
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": out})
|
|
}))
|
|
defer server.Close()
|
|
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"proxy":{"address":%q,"timeout":"1s","apnAsyncProtocolVersion":1}}}`, strings.TrimPrefix(server.URL, "http://")))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g.Cfg().SetAdapter(adapter)
|
|
client := upstreamclient.New()
|
|
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := repo.batches[sub.OperationID]
|
|
if probes != 2 || submits != 0 || b.Status != "prepared" || !b.SubmittedAt.IsZero() || b.LeaseToken != "" {
|
|
t.Fatalf("双probe竞态烧掉意图: probes=%d submits=%d sub=%+v", probes, submits, b)
|
|
}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if submits != 1 || repo.batches[sub.OperationID].Status != "done" {
|
|
t.Fatalf("门禁恢复未唯一首发: submits=%d sub=%+v", submits, repo.batches[sub.OperationID])
|
|
}
|
|
if err := (&executor{}).processSubBatch(ctx, repo, client, sub.OperationID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if submits != 1 {
|
|
t.Fatal("完成后重复首发")
|
|
}
|
|
}
|
|
|
|
func TestAsyncSubBatchStableAndChannelIsolated(t *testing.T) {
|
|
op, _ := OperationByBatchType(consts.BatchTypeDataStart)
|
|
task := &Task{Batch: Batch{BatchNo: "P-2", BatchType: consts.BatchTypeDataStart}, Operation: op}
|
|
groups := map[string][]*Detail{"a": {}, "b": {}}
|
|
for i := 0; i < 203; i++ {
|
|
key := "a"
|
|
if i >= 201 {
|
|
key = "b"
|
|
}
|
|
groups[key] = append(groups[key], &Detail{ID: int64(i + 1), ICCID: fmt.Sprint(i), MSISDN: fmt.Sprint(i), ChanKey: key})
|
|
}
|
|
total := 0
|
|
for key, details := range groups {
|
|
for _, chunk := range splitAsyncDetails(details) {
|
|
if len(chunk) > 100 {
|
|
t.Fatal("超过100卡")
|
|
}
|
|
first := newSubBatch(task, chunk)
|
|
second := newSubBatch(task, chunk)
|
|
if first.OperationID != second.OperationID {
|
|
t.Fatal("operationId不稳定")
|
|
}
|
|
for _, sim := range first.Request.SimKeys {
|
|
if sim.ChanKey != key {
|
|
t.Fatal("跨通道子批")
|
|
}
|
|
}
|
|
total++
|
|
}
|
|
}
|
|
if total != 5 {
|
|
t.Fatalf("sub batches=%d want5", total)
|
|
}
|
|
}
|