package simcardquery import ( "context" "strings" "testing" "time" "github.com/xuri/excelize/v2" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" ) func TestTemplateRoundTrip(t *testing.T) { ctx := context.Background() f, _, err := New().BaseTemplate(ctx) if err != nil { t.Fatal(err) } defer f.Close() sheet := f.GetSheetName(0) if err := f.SetCellStr(sheet, "A2", "89860000000000000001"); err != nil { t.Fatal(err) } b, err := f.WriteToBuffer() if err != nil { t.Fatal(err) } lines, err := streamExcelInputs(ctx, b) if err != nil { t.Fatal(err) } if len(lines) != 1 || lines[0].Values[0] != "89860000000000000001" { t.Fatalf("unexpected lines: %+v", lines) } } func TestExcelInputLimit(t *testing.T) { f := excelize.NewFile() defer f.Close() sheet := f.GetSheetName(0) if err := f.SetCellStr(sheet, "A1", "ICCID"); err != nil { t.Fatal(err) } for i := 2; i <= model.SimCardQueryMaxRows+2; i++ { cell, _ := excelize.CoordinatesToCellName(1, i) if err := f.SetCellStr(sheet, cell, "89860000000000000001"); err != nil { t.Fatal(err) } } b, err := f.WriteToBuffer() if err != nil { t.Fatal(err) } if _, err := streamExcelInputs(context.Background(), b); err == nil { t.Fatal("expected over-limit error") } } func TestMatchCardAmbiguity(t *testing.T) { cards := []*cardBrief{ {Id: 1, Iccid: "89860001", Msisdn: "10001", Imsi: "46000001"}, {Id: 2, Iccid: "89860002", Msisdn: "10002", Imsi: "46000001"}, } a := indexCards(cards, func(c *cardBrief) string { return c.Iccid }) b := indexCards(cards, func(c *cardBrief) string { return c.Msisdn }) c := indexCards(cards, func(c *cardBrief) string { return c.Imsi }) if card, _ := matchCard(inputLine{Values: []string{"46000001"}}, a, b, c); card != nil { t.Fatal("ambiguous IMSI must fail") } if card, reason := matchCard(inputLine{Values: []string{"89860002", "46000001"}}, a, b, c); card == nil || card.Id != 2 { t.Fatalf("expected second card, got %+v %s", card, reason) } if card, _ := matchCard(inputLine{Values: []string{"89860001", "10002"}}, a, b, c); card != nil { t.Fatal("mixed card values must fail") } } func TestRangeValidation(t *testing.T) { for _, pair := range [][3]string{{"1", "22", "iccid"}, {"aa", "bb", "iccid"}, {"22", "11", "iccid"}, {"11", "22", "imei"}} { if parseRangeInputs(&model.SimCardQueryExportParam{StartNum: pair[0], EndNum: pair[1], Dimension: pair[2]}) == nil { t.Fatalf("expected error: %v", pair) } } if err := parseRangeInputs(&model.SimCardQueryExportParam{StartNum: "0011", EndNum: "0022", Dimension: "iccid"}); err != nil { t.Fatal(err) } } func TestIsSafeIccid(t *testing.T) { ok := []string{"89860000000000000001", "8986abc001", "ABC123"} for _, v := range ok { if !isSafeIccid(v) { t.Fatalf("expected safe: %s", v) } } // 单引号、空格、分号、括号都必须拒绝,否则拼进 SQL 会破坏语句结构。 bad := []string{"", "8986'001", "8986 001", "8986;001", "8986)001", strings.Repeat("1", 33)} for _, v := range bad { if isSafeIccid(v) { t.Fatalf("expected unsafe: %q", v) } } } func TestFormatMonthValuesThreeStates(t *testing.T) { months := []string{"2026-09", "2026-08", "2026-07"} // 查询成功:9 月有量、8 月记录为 0、7 月无记录 → 数值 / 0 / "-" ok := &model.SimCardUsageHistoryRow{ MonthValues: map[string]float64{"2026-09": 12.3456, "2026-08": 0}, } got := formatMonthValues(ok, months) if got[0] != 12.346 { t.Fatalf("9 月应导出数值,实际 %v", got[0]) } if got[1] != float64(0) { t.Fatalf("8 月零用量应导出 0,实际 %v", got[1]) } if got[2] != usageNoDataText { t.Fatalf("7 月无记录应导出 %q,实际 %v", usageNoDataText, got[2]) } // 整卡查询失败:月份列一律留空(保证该列是纯数值),原因由「失败原因」列承载 failed := &model.SimCardUsageHistoryRow{ MonthValues: map[string]float64{"2026-09": 99}, UsageQueryFailed: true, } got = formatMonthValues(failed, months) for i, v := range got { if v != "" { t.Fatalf("第 %d 列应留空,实际 %v", i+1, v) } } if !strings.Contains(usageQueryFailedNote, "查询失败") { t.Fatalf("失败原因文案应说明查询失败:%s", usageQueryFailedNote) } } // TestFormatMonthValuesPartialHistory 覆盖「平台只同步了几个月」和「新卡没历史」两种常见情况: // 这类卡不是查询失败,缺的月份必须显示 "-",不能整行留空或判失败。 func TestFormatMonthValuesPartialHistory(t *testing.T) { months := []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-05", "2026-04"} // 只有最近 3 个月有数据(新卡 / 平台同步只跑了 3 个月) partial := &model.SimCardUsageHistoryRow{ MonthValues: map[string]float64{ "2026-09": 120.5, "2026-08": 98, "2026-07": 0, }, } got := formatMonthValues(partial, months) if len(got) != len(months) { t.Fatalf("列数应与月份数一致,实际 %d", len(got)) } if got[0] != 120.5 { t.Fatalf("有数据的月份应导出数值,实际 %v", got[0]) } if got[2] != float64(0) { t.Fatalf("有记录但用量为 0 应导出 0,实际 %v", got[2]) } for i := 3; i < len(got); i++ { if got[i] != usageNoDataText { t.Fatalf("第 %d 列无记录应为 %q,实际 %v", i+1, usageNoDataText, got[i]) } } // 部分缺失不能被当成查询失败 if partial.UsageQueryFailed { t.Fatal("缺月份不等于查询失败") } // 全新卡:12 个月一条记录都没有 → 全部 "-",仍然算查询成功 brandNew := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}} got = formatMonthValues(brandNew, months) for i, v := range got { if v != usageNoDataText { t.Fatalf("新卡第 %d 列应为 %q,实际 %v", i+1, usageNoDataText, v) } } } // TestBuildRangeRowsKeepsOneRowPerNumber 号段里只有部分号码可见时, // 导出必须仍然每个号码一行:可见的出行数据,不可见的出「未查询到数据」, // 总条数等于号段大小,客户才能按号对账。 func TestBuildRangeRowsKeepsOneRowPerNumber(t *testing.T) { in := &model.SimCardQueryExportParam{ Dimension: "iccid", StartNum: "89860000000000000001", EndNum: "89860000000000000010", } // 10 个号里只有第 3、5、8 个在可见范围内 cards := []*cardBrief{ {Id: 1, Iccid: "89860000000000000003"}, {Id: 2, Iccid: "89860000000000000005"}, {Id: 3, Iccid: "89860000000000000008"}, } stats := &resolveStats{} rows := buildRangeRows(context.Background(), in, cards, stats) if len(rows) != 10 { t.Fatalf("应输出 10 行(与号段大小一致),实际 %d", len(rows)) } hit, miss := 0, 0 seen := make(map[string]struct{}, len(rows)) for _, row := range rows { if _, dup := seen[row.Iccid]; dup { t.Fatalf("号码重复输出:%s", row.Iccid) } seen[row.Iccid] = struct{}{} switch row.QueryResult { case queryResultHit: hit++ case queryResultMiss: miss++ // 不能透露是因为权限查不到 if row.FailReason != rangeMissReason { t.Fatalf("失败原因应为 %q,实际 %q", rangeMissReason, row.FailReason) } if strings.Contains(row.FailReason, "权限") { t.Fatalf("失败原因不能提及权限:%s", row.FailReason) } } } if hit != 3 || miss != 7 { t.Fatalf("成功/失败应为 3/7,实际 %d/%d", hit, miss) } if stats.hit != 3 || stats.miss != 7 { t.Fatalf("统计应为 3/7,实际 %d/%d", stats.hit, stats.miss) } // 首尾号码要对得上,且保持等长(前导零不丢) if _, ok := seen["89860000000000000001"]; !ok { t.Fatal("缺少起始号码") } if _, ok := seen["89860000000000000010"]; !ok { t.Fatal("缺少结束号码") } } func TestRangeSizeAndLimit(t *testing.T) { in := &model.SimCardQueryExportParam{StartNum: "89860000000000000001", EndNum: "89860000000000000010"} size, err := rangeSize(in) if err != nil || size != 10 { t.Fatalf("号段大小应为 10,实际 %d err=%v", size, err) } // 超过单次上限的号段要在提交阶段直接拒绝,否则无法完整展开 big := &model.SimCardQueryExportParam{ Dimension: "iccid", StartNum: "89860000000000000001", EndNum: "89860000000000099999", } if err := parseRangeInputs(big); err == nil { t.Fatal("超限号段应被拒绝") } } func TestMonthsAndStats(t *testing.T) { months := LastCompleteMonths(time.Date(2026, 10, 23, 14, 0, 0, 0, time.FixedZone("CST", 8*3600)), 12) if len(months) != 12 || months[0] != "2026-09" || months[11] != "2025-10" { t.Fatalf("unexpected months: %v", months) } stats := &resolveStats{} stats.add(true) if result, summary := stats.resultSummary(1); result != consts.BatchRunResultSuccess || !strings.Contains(summary, "成功 1 条") { t.Fatalf("bad success: %d %s", result, summary) } stats.add(false) if result, _ := stats.resultSummary(2); result != consts.BatchRunResultPartialSuccess { t.Fatal("expected partial success") } }