package query import ( "context" "encoding/json" "testing" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "git.5iot.com/iotlcm-public/iotlcm-openapi/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model" "github.com/gogf/gf/v2/util/gconv" // 触发 service.RegisterIotTSimCardInfo,否则 loadSimBatchCards 会 panic _ "git.5iot.com/iotlcm-public/iotlcm-openapi/internal/logic/openapiiot" ) // setupImeiBatchDB 建一张最小可用的卡表并灌入覆盖各分支的样本数据。 // // 样本设计(对齐 §7 测试场景): // CARD-A 有 imei → 正常命中 // CARD-B imei 为空 → 卡存在但上游未上报值 // CARD-C 有 imei → 正常命中(入库带的值) // CARD-D 属于另一个部门 → 数据权限过滤后视为 notFound func setupImeiBatchDB(t *testing.T, ctx context.Context) { t.Helper() if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = g.DB().Close(ctx) }) if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { t.Fatal(err) } statements := []string{ `CREATE TABLE t_sim_card_info ( id INTEGER PRIMARY KEY, iccid TEXT, msisdn TEXT, imsi TEXT, imei TEXT, boss_card_status INTEGER, used_flow_mb REAL, imei_sync_time DATETIME, imei_update_time DATETIME, dept_id INTEGER, tenant_id TEXT )`, // FilterAuth 的 GetDeptAndSub 会查 sys_dept(本部门及以下数据权限) `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, ancestors TEXT)`, `INSERT INTO t_sim_card_info (id, iccid, msisdn, imsi, imei, boss_card_status, used_flow_mb, imei_sync_time, imei_update_time, dept_id, tenant_id) VALUES (1,'CARD-A','13800000001','460000000000001','865466070817587',2,10.5,'2026-10-12 04:31:07','2026-10-12 04:31:07',7,'A'), (2,'CARD-B','13800000002','460000000000002','', 2,20.0,'2026-10-12 04:31:09',NULL, 7,'A'), (3,'CARD-C','13800000003','460000000000003','123456789012345',2,30.0,NULL, '2026-09-01 08:00:00',7,'A'), (4,'CARD-D','13800000004','460000000000004','999888777666555',2,40.0,'2026-10-12 04:31:11','2026-10-12 04:31:11',8,'A')`, } for _, statement := range statements { if _, err := g.DB().Exec(ctx, statement); err != nil { t.Fatal(err) } } } // apiDataScopeRoleId 对齐 middleware.go:28 的 apiDataScopeRoleId(包私有,此处复刻值)。 const apiDataScopeRoleId int64 = 17 // imeiBatchCtx 复现 ApiCtx 中间件注入的身份:API 客户固定 RoleId=17 + // SysRoleDataScopeDeptAndBelow(对齐 middleware.go:99),使数据权限过滤真实生效。 func imeiBatchCtx() context.Context { ctx := contexts.Set(context.Background(), &model.Context{ User: &model.Identity{ BaseClaims: model.BaseClaims{ ID: 0, TenantId: "A", DeptId: 7, Roles: []model.Role{ {RoleId: apiDataScopeRoleId, DataScope: consts.SysRoleDataScopeDeptAndBelow}, }, }, }, AgentUser: &model.BaseClaimsApi{DeptId: 7, TenantId: "A"}, }) contexts.SetDataValue(ctx, "currentRoleId", gconv.String(apiDataScopeRoleId)) contexts.SetDataValue(ctx, "currentRoleDataScope", consts.SysRoleDataScopeDeptAndBelow) return ctx } // TestQuerySimImeiBatchSemantics 覆盖 §7 T11/T12/T16 的核心语义: // - 有值的卡返回 imei // - imei 为空但卡存在 → matched=true 且 imei="",不算 notFound(T16) // - 不存在的卡 → matched=false + CARD_NOT_FOUND(T12) // - 跨部门的卡 → 数据权限过滤后视为 notFound(T13) func TestQuerySimImeiBatchSemantics(t *testing.T) { ctx := imeiBatchCtx() setupImeiBatchDB(t, ctx) output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{ {Iccid: "CARD-A"}, {Iccid: "CARD-B"}, {Iccid: "CARD-C"}, {Iccid: "CARD-D"}, {Iccid: "CARD-MISSING"}, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if output.Total != 5 { t.Fatalf("total = %d, want 5", output.Total) } // A/B/C 命中,D(跨部门)与 MISSING 未命中 if output.MatchedCount != 3 || output.NotFoundCount != 2 { t.Fatalf("matched=%d notFound=%d, want 3/2", output.MatchedCount, output.NotFoundCount) } if len(output.Items) != 5 { t.Fatalf("items = %d, want 5 (按输入顺序逐卡返回)", len(output.Items)) } // 顺序必须与输入一致(契约:按输入顺序逐卡返回)。 // 注意:未命中的 item 不返回 iccid(omitempty),所以必须用下标访问, // 不能用 iccid 建索引,否则未命中项会取到零值。 ordered := []string{"CARD-A", "CARD-B", "CARD-C", "CARD-D", "CARD-MISSING"} for i, wantIccid := range ordered { if i == 3 || i == 4 { continue // D 与 MISSING 未命中,不返回 iccid } if output.Items[i].Iccid != wantIccid { t.Fatalf("items[%d].iccid = %q, want %q(必须保持输入顺序)", i, output.Items[i].Iccid, wantIccid) } } a, b, c := output.Items[0], output.Items[1], output.Items[2] d, missing := output.Items[3], output.Items[4] if !a.Matched || a.Imei != "865466070817587" { t.Fatalf("CARD-A: %+v", a) } // CARD-B:imei 为空但 matched=true(关键语义,不算 notFound) if !b.Matched { t.Fatalf("CARD-B 应为 matched=true(卡存在,只是上游没给值): %+v", b) } if b.Imei != "" { t.Fatalf("CARD-B imei 应为空串: %q", b.Imei) } // CARD-C:入库就带值 if !c.Matched || c.Imei != "123456789012345" { t.Fatalf("CARD-C: %+v", c) } // CARD-D / MISSING:未命中,且不泄露 imei for _, tc := range []struct { name string item model.QuerySimImeiBatchItem }{{"CARD-D(跳部门)", d}, {"CARD-MISSING(不存在)", missing}} { item := tc.item if item.Matched { t.Fatalf("%s 不应命中: %+v", tc.name, item) } if item.ErrorCode != "CARD_NOT_FOUND" { t.Fatalf("%s errorCode = %q, want CARD_NOT_FOUND", tc.name, item.ErrorCode) } if item.Imei != "" { t.Fatalf("%s 未命中时不应返回 imei: %q", tc.name, item.Imei) } } } // TestQuerySimImeiBatchValidation 覆盖 §7 T14/T15 的参数校验。 func TestQuerySimImeiBatchValidation(t *testing.T) { ctx := imeiBatchCtx() setupImeiBatchDB(t, ctx) svc := NewQuerySim() cases := []struct { name string in *model.QuerySimImeiBatchParam }{ {"nil 参数", nil}, {"appId 为空", &model.QuerySimImeiBatchParam{Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}}}}, {"cards 为空", &model.QuerySimImeiBatchParam{AppId: "app"}}, } for _, tc := range cases { if _, err := svc.QuerySimImeiBatch(ctx, tc.in); err == nil { t.Fatalf("%s: 应返回错误", tc.name) } } // 超过 1000 张(T14) tooMany := make([]model.QuerySimBatchCard, 0, querySimBatchMaxCards+1) for i := 0; i <= querySimBatchMaxCards; i++ { tooMany = append(tooMany, model.QuerySimBatchCard{Iccid: "CARD-" + string(rune('0'+i%10)) + string(rune('0'+i/10%10)) + string(rune('0'+i/100%10)) + string(rune('0'+i/1000%10))}) } if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{AppId: "app", Cards: tooMany}); err == nil { t.Fatal("超过 1000 张应被拒绝") } // 重复标识(parseSimBatchIdentifiers 既有逻辑) if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}, {Iccid: "CARD-A"}}, }); err == nil { t.Fatal("重复标识应被拒绝") } // 单标识超长(simBatchIdentifierMaxLen) longIccid := make([]byte, simBatchIdentifierMaxLen+1) for i := range longIccid { longIccid[i] = '9' } if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{{Iccid: string(longIccid)}}, }); err == nil { t.Fatal("超长标识应被拒绝") } // 多标识互相矛盾:ICCID 属于 CARD-A 但 MSISDN 属于 CARD-B(既有校验逻辑) if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A", Msisdn: "13800000002"}}, }); err != nil { t.Fatalf("多标识查询本身应合法: %v", err) } // 矛盾组合应返回 notFound 而非错误(simBatchCardMatchesIdentifier 拦下) conflict, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A", Msisdn: "13800000002"}}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if conflict.NotFoundCount != 1 || conflict.Items[0].Matched { t.Fatalf("矛盾的 iccid+msisdn 组合应判为 notFound: %+v", conflict.Items[0]) } } // TestQuerySimImeiBatchByMsisdnAndImsi 验证三种标识都能定位到卡。 func TestQuerySimImeiBatchByMsisdnAndImsi(t *testing.T) { ctx := imeiBatchCtx() setupImeiBatchDB(t, ctx) output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{ {Msisdn: "13800000001"}, {Imsi: "460000000000003"}, }, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if output.MatchedCount != 2 { t.Fatalf("matched = %d, want 2", output.MatchedCount) } for _, item := range output.Items { if !item.Matched || item.Iccid == "" { t.Fatalf("应通过 msisdn/imsi 定位到卡并回填 iccid: %+v", item) } } } // TestQuerySimImeiBatchItemJSONShape 锁死对外字段集:只允许 // matched / iccid / imei / errorCode / errorMessage,任何额外字段(例如 // imeiSyncTime、imeiUpdateTime)都会让该用例失败,避免内部字段泄露给客户。 func TestQuerySimImeiBatchItemJSONShape(t *testing.T) { ctx := imeiBatchCtx() setupImeiBatchDB(t, ctx) output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{ AppId: "app", Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}, {Iccid: "CARD-MISSING"}}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } allowed := map[string]bool{ "matched": true, "iccid": true, "imei": true, "errorCode": true, "errorMessage": true, } for i, item := range output.Items { raw, marshalErr := json.Marshal(item) if marshalErr != nil { t.Fatalf("items[%d] 序列化失败: %v", i, marshalErr) } var fields map[string]any if err := json.Unmarshal(raw, &fields); err != nil { t.Fatalf("items[%d] 反序列化失败: %v", i, err) } for name := range fields { if !allowed[name] { t.Fatalf("items[%d] 出现非预期对外字段 %q: %s", i, name, raw) } } } }