Files
iotlcm/internal/logic/upstreamapi/apn_operation_test.go
T
2026-10-29 14:59:32 +08:00

377 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package upstreamapi
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
)
type memoryApnOperationStore struct {
mu sync.Mutex
rows map[string][2]string
failSave bool
}
func (s *memoryApnOperationStore) Claim(_ context.Context, id, ch, hash string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := id + ch
if _, ok := s.rows[k]; ok {
return false, nil
}
s.rows[k] = [2]string{hash, ""}
return true, nil
}
func (s *memoryApnOperationStore) Load(_ context.Context, id, ch string) (string, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
r := s.rows[id+ch]
return r[0], r[1], nil
}
func (s *memoryApnOperationStore) Save(ctx context.Context, id, ch, hash, body string) error {
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.failSave {
return errors.New("模拟磁盘失败")
}
r, exists := s.rows[id+ch]
if !exists || r[0] != hash {
return errors.New("意图不存在或摘要不符")
}
merged, err := mergeApnOperationJSON(r[1], body)
if err != nil {
return err
}
r[1] = merged
s.rows[id+ch] = r
return nil
}
func operationTestInput() *model.SimCardOperateChangeApnInput {
return &model.SimCardOperateChangeApnInput{SimKeys: []*model.SimKey{{ICCID: "test", ChanKey: "channel"}}, ReqData: model.SimCardOperateChangeApnInputItem{OperationID: "stable-operation", SubmitOnly: true, OperType: "1"}}
}
func acceptedOperation(input *model.SimCardOperateChangeApnInput) model.SimCardOperateChangeApnOutput {
return model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], ResData: model.SimCardOperateChangeApnItem{OrderNum: "saved-job", Pending: true}}}
}
func TestApnCapabilityExistingJobNeedsNoIntentDatabase(t *testing.T) {
input := operationTestInput()
input.ReqData.SubmitOnly = false
input.ReqData.QueryJobID = "existing-job"
// 此包未配置数据库:已有 jobId 的只读协议探测应仍然返回能力。
output, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input)
if err != nil || len(output) != 1 || output[0].ResData.ProtocolVersion != 1 || *output[0].SimKey != *input.SimKeys[0] {
t.Fatalf("已有任务的只读协议探测依赖了提交意图表: output=%+v err=%v", output, err)
}
input.SimKeys = []*model.SimKey{nil}
if _, err := (&sUpstreamAPI{}).QuerySimApnCapability(context.Background(), input); err == nil {
t.Fatal("已有jobId也必须校验冻结卡标识")
}
}
func TestApnOperationResponseLossAndRestartRecovery(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
calls := 0
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
calls++
return acceptedOperation(input), nil
}
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err != nil {
t.Fatal(err)
}
// 丢弃首次响应后,另一次调用只读受理映射,不访问运营商。
input.ReqData.RecoverOnly = true
recovered, err := durableApnOperation(context.Background(), store, "channel", input, submit)
if err != nil || calls != 1 || recovered[0].ResData.OrderNum != "saved-job" {
t.Fatalf("恢复失败 calls=%d result=%+v err=%v", calls, recovered, err)
}
input.ReqData.RecoverOnly = false
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err != nil || calls != 1 {
t.Fatalf("重复投递重新办理 calls=%d err=%v", calls, err)
}
input.ReqData.OperType = "0"
if _, err = durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
t.Fatal("相同ID不得绑定不同请求")
}
}
func TestApnOperationConcurrentSubmitOnlyCallsCarrierOnce(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
started, release, done := make(chan struct{}), make(chan struct{}), make(chan error, 1)
go func() {
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
close(started)
<-release
return acceptedOperation(input), nil
})
done <- err
}()
<-started
duplicate, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
t.Error("并发重复operationId不得重新办理")
return nil, nil
})
close(release)
if err != nil || len(duplicate) != 1 || !duplicate[0].ResData.Uncertain {
t.Fatalf("并发处理中应标待核对: %v %+v", err, duplicate)
}
if err := <-done; err != nil {
t.Fatal(err)
}
}
func TestApnOperationRecoveryRejectsDifferentPayload(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
_, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
return acceptedOperation(input), nil
})
if err != nil {
t.Fatal(err)
}
input.ReqData.RecoverOnly = true
input.ReqData.OperType = "0"
if _, err = durableApnOperation(context.Background(), store, "channel", input, nil); err == nil {
t.Fatal("恢复请求不得读取其他操作的受理映射")
}
}
func TestApnOperationIntentWithoutResponseNeverResubmits(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
payload, _ := json.Marshal(input)
digest := sha256.Sum256(payload)
_, _ = store.Claim(context.Background(), input.ReqData.OperationID, "channel", hex.EncodeToString(digest[:]))
input.ReqData.RecoverOnly = true
calls := 0
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) { calls++; return nil, nil })
if err != nil || calls != 0 || !output[0].ResData.Uncertain {
t.Fatalf("未确认意图被重新提交:calls=%d err=%v", calls, err)
}
}
func TestApnOperationPersistenceFailureNeverResubmits(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}, failSave: true}
input := operationTestInput()
calls := 0
submit := func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
calls++
return acceptedOperation(input), nil
}
if _, err := durableApnOperation(context.Background(), store, "channel", input, submit); err == nil {
t.Fatal("持久化失败不得返回已可靠保存")
}
store.failSave = false
input.ReqData.RecoverOnly = true
output, err := durableApnOperation(context.Background(), store, "channel", input, submit)
if err != nil || calls != 1 || !output[0].ResData.Uncertain {
t.Fatalf("受理映射失败后不得重复办理:calls=%d err=%v", calls, err)
}
}
func TestApnOperationClientCancellationDoesNotLoseJob(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
ctx, cancel := context.WithCancel(context.Background())
output, err := durableApnOperation(ctx, store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
cancel()
return acceptedOperation(input), nil
})
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("客户端取消导致jobId丢失:%v", err)
}
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
if saved == "" {
t.Fatal("jobId未落库")
}
}
type affectedApnTestDB struct {
gdb.DB
tx gdb.TX
}
func (d affectedApnTestDB) Transaction(ctx context.Context, f func(context.Context, gdb.TX) error) error {
return f(ctx, d.tx)
}
type affectedApnTestTX struct {
gdb.TX
hash string
rows int64
}
func (tx affectedApnTestTX) GetOne(string, ...any) (gdb.Record, error) {
return gdb.Record{"request_hash": gvar.New(tx.hash), "response_json": gvar.New("")}, nil
}
func (tx affectedApnTestTX) Exec(string, ...any) (sql.Result, error) {
return affectedApnTestResult(tx.rows), nil
}
type affectedApnTestResult int64
func (r affectedApnTestResult) LastInsertId() (int64, error) { return 0, nil }
func (r affectedApnTestResult) RowsAffected() (int64, error) { return int64(r), nil }
func TestApnOperationSQLSaveRejectsAffectedZeroAndHashMismatch(t *testing.T) {
hash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
for _, tc := range []struct {
rows int64
hash string
wantError bool
}{{0, hash, true}, {1, "wrong-hash", true}, {1, hash, false}} {
store := sqlApnOperationStore{db: affectedApnTestDB{tx: affectedApnTestTX{hash: tc.hash, rows: tc.rows}}}
err := store.Save(context.Background(), "test-operation", "test-channel", hash, "[]")
if (err != nil) != tc.wantError {
t.Fatalf("rows=%d hash=%s err=%v", tc.rows, tc.hash, err)
}
}
}
type retryCheckpointApnStore struct {
memoryApnOperationStore
failed bool
}
func (s *retryCheckpointApnStore) Save(ctx context.Context, id, channel, hash, body string) error {
if !s.failed {
s.failed = true
return errors.New("模拟首次checkpoint磁盘故障")
}
return s.memoryApnOperationStore.Save(ctx, id, channel, hash, body)
}
func TestApnOperationFailedCheckpointRetriesKnownJobNotUnknown(t *testing.T) {
store := &retryCheckpointApnStore{memoryApnOperationStore: memoryApnOperationStore{rows: map[string][2]string{}}}
input := operationTestInput()
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err == nil {
t.Fatal("应模拟checkpoint失败")
}
return nil, errors.New("受理后响应断开")
})
if err != nil || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
t.Fatalf("保存重试丢掉已取得jobId:%+v %v", output, err)
}
_, saved, _ := store.Load(context.Background(), input.ReqData.OperationID, "channel")
if !strings.Contains(saved, "saved-job") {
t.Fatalf("重试未可靠保存jobId:%s", saved)
}
}
func TestApnOperationCheckpointCannotBeErased(t *testing.T) {
for _, failed := range []bool{false, true} {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
output, err := durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err != nil {
t.Fatal(err)
}
if failed {
return nil, errors.New("模拟受理后请求失败")
}
return nil, nil
})
if err != nil || len(output) != 1 || output[0].ResData.OrderNum != "saved-job" || !output[0].ResData.Pending {
t.Fatalf("后续空响应或错误抹掉jobId:%+v %v", output, err)
}
input.ReqData.RecoverOnly = true
output, err = durableApnOperation(context.Background(), store, "channel", input, nil)
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("持久化映射已被回退:%+v %v", output, err)
}
}
}
func TestApnOperationMergePreservesTerminalAndCardAssociation(t *testing.T) {
input := operationTestInput()
terminal := acceptedOperation(input)
terminal[0].ResData.Pending = false
terminal[0].Message = "明确成功"
body, _ := json.Marshal(terminal)
unknown, _ := json.Marshal(unknownApnOperation(input, "后来的未知结果"))
merged, err := mergeApnOperationJSON(string(body), string(unknown))
if err != nil || merged != string(body) {
t.Fatalf("逐卡终态被未知覆盖:%s %v", merged, err)
}
second := *input.SimKeys[0]
second.ICCID = "second"
other, _ := json.Marshal(model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: &second, ResData: model.SimCardOperateChangeApnItem{Pending: true, OrderNum: "second-job"}}})
merged, err = mergeApnOperationJSON(merged, string(other))
var results model.SimCardOperateChangeApnOutput
_ = json.Unmarshal([]byte(merged), &results)
if err != nil || len(results) != 2 || results[0].SimKey.ICCID != "test" || results[1].SimKey.ICCID != "second" {
t.Fatalf("逐卡合并丢失卡关联:%s %v", merged, err)
}
}
type failingClaimApnStore struct{ memoryApnOperationStore }
func (s *failingClaimApnStore) Claim(context.Context, string, string, string) (bool, error) {
return false, errors.New("模拟INSERT失败")
}
func TestApnOperationClaimFailureNeverCallsCarrier(t *testing.T) {
called := false
_, err := durableApnOperation(context.Background(), &failingClaimApnStore{}, "channel", operationTestInput(), func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
called = true
return nil, nil
})
if err == nil || called {
t.Fatalf("意图INSERT失败仍调用运营商:called=%v err=%v", called, err)
}
}
func TestApnOperationExplicitFailureWinsPendingAndUnknown(t *testing.T) {
input := operationTestInput()
pending, _ := json.Marshal(acceptedOperation(input))
failure := model.SimCardOperateChangeApnOutput{&model.SimBaseRes[model.SimCardOperateChangeApnItem]{SimKey: input.SimKeys[0], Code: 9999, Message: "运营商明确拒绝"}}
failed, _ := json.Marshal(failure)
merged, err := mergeApnOperationJSON(string(pending), string(failed))
if err != nil {
t.Fatal(err)
}
unknown, _ := json.Marshal(unknownApnOperation(input, "后到的通信错误"))
merged, err = mergeApnOperationJSON(merged, string(unknown))
var output model.SimCardOperateChangeApnOutput
if err != nil || json.Unmarshal([]byte(merged), &output) != nil || len(output) != 1 || output[0].Code != 9999 || output[0].Message != "运营商明确拒绝" || output[0].ResData.Pending || output[0].ResData.Uncertain || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("明确失败被pending或未知覆盖:%s %v", merged, err)
}
}
func TestApnOperationCheckpointSurvivesCrashBeforeReturn(t *testing.T) {
store := &memoryApnOperationStore{rows: map[string][2]string{}}
input := operationTestInput()
func() {
defer func() { _ = recover() }()
_, _ = durableApnOperation(context.Background(), store, "channel", input, func(ctx context.Context) (model.SimCardOperateChangeApnOutput, error) {
checkpoint := ctx.Value(model.ApnOperationCheckpointKey{}).(func(model.SimCardOperateChangeApnOutput) error)
if err := checkpoint(acceptedOperation(input)); err != nil {
t.Fatal(err)
}
panic("模拟拿jobId后进程中断")
})
}()
input.ReqData.RecoverOnly = true
output, err := durableApnOperation(context.Background(), store, "channel", input, func(context.Context) (model.SimCardOperateChangeApnOutput, error) {
t.Fatal("恢复不得重新办理")
return nil, nil
})
if err != nil || output[0].ResData.OrderNum != "saved-job" {
t.Fatalf("checkpoint未恢复:%+v %v", output, err)
}
}