package logic import ( "encoding/json" "reflect" "strings" "testing" "time" v1 "tool-api/api/user/v1" "tool-api/internal/consts" ) // TestJudgeSeatGuardRate 覆盖判据④:≥5 次/分钟 或 ≥20 次/小时 → 4006。 func TestJudgeSeatGuardRate(t *testing.T) { now := time.Now() if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 6, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { t.Fatalf("1 分钟第 6 次应 4006,got %d", code) } if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 5, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { t.Fatalf("1 分钟第 5 次(≥阈值)应 4006,got %d", code) } if code := judgeSeatGuard(seatGuardInput{CountLastHour: 20, PerMinute: 5, PerHour: 20, Now: now}); code != consts.CodeSeatRateLimited { t.Fatalf("1 小时第 20 次应 4006,got %d", code) } if code := judgeSeatGuard(seatGuardInput{CountLastMinute: 4, CountLastHour: 19, PerMinute: 5, PerHour: 20, Now: now}); code != 0 { t.Fatalf("未达阈值应放行,got %d", code) } } // TestJudgeSeatGuardCooling 覆盖判据⑤:连续失败 5 次 → 4007 冷却 10 分钟;冷却结束恢复。 func TestJudgeSeatGuardCooling(t *testing.T) { now := time.Now() in := seatGuardInput{ CountLastMinute: 1, CountLastHour: 3, ConsecutiveFails: 5, Threshold: 5, CooldownMinutes: 10, PerMinute: 5, PerHour: 20, Now: now, } // 最近一次失败在冷却窗口内 → 4007(即使输入正确也应冷却) recentFail := now.Add(-1 * time.Minute) in.LastFailAt = &recentFail if code := judgeSeatGuard(in); code != consts.CodeSeatCooling { t.Fatalf("冷却期内应 4007,got %d", code) } // 冷却已过 → 放行 oldFail := now.Add(-11 * time.Minute) in.LastFailAt = &oldFail if code := judgeSeatGuard(in); code != 0 { t.Fatalf("冷却结束应放行,got %d", code) } // 连续失败不足阈值 → 放行 in.ConsecutiveFails = 4 in.LastFailAt = &recentFail if code := judgeSeatGuard(in); code != 0 { t.Fatalf("失败次数不足阈值应放行,got %d", code) } } // TestConsecutiveFails 最近连续失败计数(倒序)。 func TestConsecutiveFails(t *testing.T) { if n := consecutiveFails([]int{0, 0, 0, 1, 0}); n != 3 { t.Fatalf("got %d want 3", n) } if n := consecutiveFails([]int{1, 0}); n != 0 { t.Fatalf("got %d want 0", n) } if n := consecutiveFails(nil); n != 0 { t.Fatalf("got %d want 0", n) } } // TestMaskPhone 覆盖判据⑦的脱敏:中间 4 位打码。 func TestMaskPhone(t *testing.T) { cases := map[string]string{ "13800000000": "138****0000", "13912345678": "139****5678", "": "", "1234": "****", } for in, want := range cases { if got := maskPhone(in); got != want { t.Fatalf("maskPhone(%q)=%q want %q", in, got, want) } } } // TestParseStage 舞台解析:无舞台/非法 JSON 返回 nil,不阻断查座。 func TestParseStage(t *testing.T) { layout := `{"canvas":{"w":1600,"h":1200},"stage":{"x":10,"y":20,"w":300,"h":80,"shape":"rect","rotation":0,"color":"#fff"}}` s := parseStage(layout) if s == nil || s.X != 10 || s.W != 300 || s.Shape != "rect" { t.Fatalf("解析舞台失败: %+v", s) } if parseStage("") != nil { t.Fatalf("空布局应返回 nil") } if parseStage("{not json") != nil { t.Fatalf("非法 JSON 应返回 nil") } if parseStage(`{"canvas":{"w":1600,"h":1200}}`) != nil { t.Fatalf("无 stage 应返回 nil") } } // TestEventSeatQueryParseCanvas 大厅尺寸解析:取 canvas.w/h;空串/非法/≤0 一律回退 1600/1200。 func TestEventSeatQueryParseCanvas(t *testing.T) { // 设计器/测试样本:canvas:{w:1600,h:1200} if w, h := parseCanvas(`{"canvas":{"w":1600,"h":1200},"stage":{"x":10,"y":20,"w":300,"h":80,"shape":"rect"}}`); w != 1600 || h != 1200 { t.Fatalf("标准样本应解析为 1600x1200,got %dx%d", w, h) } // 空串 → 回退缺省 if w, h := parseCanvas(""); w != 1600 || h != 1200 { t.Fatalf("空布局应回退 1600x1200,got %dx%d", w, h) } // 非法 JSON → 回退缺省(不阻断) if w, h := parseCanvas("{not json"); w != 1600 || h != 1200 { t.Fatalf("非法 JSON 应回退 1600x1200,got %dx%d", w, h) } // 值为 0/负数 → 回退缺省 if w, h := parseCanvas(`{"canvas":{"w":0,"h":0}}`); w != 1600 || h != 1200 { t.Fatalf("非正值应回退缺省,got %dx%d", w, h) } // 自定义尺寸 → 原样返回 if w, h := parseCanvas(`{"canvas":{"w":800,"h":600}}`); w != 800 || h != 600 { t.Fatalf("自定义尺寸应原样返回,got %dx%d", w, h) } } // TestEventSeatQueryResTableCoordTags 固化与前端约定一致的下划线字段名与取值 // (json tag 写错会静默变 undefined → 前端永远走降级;此测试防回归)。 func TestEventSeatQueryResTableCoordTags(t *testing.T) { b, err := json.Marshal(v1.EventSeatQueryRes{Found: true, TableX: 1100, TableY: 850, HallW: 1600, HallH: 1200}) if err != nil { t.Fatalf("marshal 失败: %v", err) } var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal 失败: %v", err) } for _, k := range []string{"table_x", "table_y", "hall_w", "hall_h"} { if _, ok := m[k]; !ok { t.Fatalf("缺字段 %q(json tag 可能写错): %s", k, string(b)) } } if v, _ := m["table_x"].(float64); v != 1100 { t.Fatalf("table_x=%v want 1100", m["table_x"]) } if v, _ := m["hall_h"].(float64); v != 1200 { t.Fatalf("hall_h=%v want 1200", m["hall_h"]) } // 未命中(零值)时 4 字段应为 0(前端据此降级) b2, _ := json.Marshal(v1.EventSeatQueryRes{Found: false, Message: seatNotFoundMessage()}) var m2 map[string]any if err := json.Unmarshal(b2, &m2); err != nil { t.Fatalf("unmarshal 失败: %v", err) } for _, k := range []string{"table_x", "table_y", "hall_w", "hall_h"} { if v, _ := m2[k].(float64); v != 0 { t.Fatalf("未命中 %s 应为 0,got %v", k, m2[k]) } } } // TestBuildSeatCells 桌面示意图:只含座位号/是否本人/是否有人。 func TestBuildSeatCells(t *testing.T) { seats := []seatBrief{ {Id: 1, TableId: 1, SeatNo: 1}, {Id: 2, TableId: 1, SeatNo: 2, EmployeeId: 99}, {Id: 3, TableId: 1, SeatNo: 3}, } cells := buildSeatCells(seats, 2) if len(cells) != 3 { t.Fatalf("len=%d want 3", len(cells)) } if cells[0].SeatNo != 1 || cells[0].IsSelf || cells[0].Filled { t.Fatalf("1 号座应有/无人标记正确: %+v", cells[0]) } if cells[1].SeatNo != 2 || !cells[1].IsSelf || !cells[1].Filled { t.Fatalf("2 号座应为本人且有人: %+v", cells[1]) } } // TestSeatQueryResultMinimized 覆盖判据⑥:返回结构不含任何他人/隐私字段。 func TestSeatQueryResultMinimized(t *testing.T) { forbidden := map[string]bool{ "name": true, "phone": true, "mobile": true, "nickname": true, "realname": true, "username": true, "employee_name": true, "employeename": true, } seen := map[reflect.Type]bool{} var walk func(rt reflect.Type, path string) walk = func(rt reflect.Type, path string) { if rt.Kind() == reflect.Ptr { rt = rt.Elem() } if rt.Kind() != reflect.Struct || seen[rt] { return } seen[rt] = true for i := 0; i < rt.NumField(); i++ { f := rt.Field(i) tag := strings.Split(f.Tag.Get("json"), ",")[0] if tag == "" || tag == "-" { tag = f.Name } low := strings.ToLower(tag) if forbidden[low] { t.Fatalf("EventSeatQueryRes 含潜在他人/隐私字段: %s.%s", path, tag) } walk(f.Type, path+"."+tag) } } walk(reflect.TypeOf(v1.EventSeatQueryRes{}), "res") // SeatCell 必须恰好 3 个字段(seat_no / is_self / filled) if n := reflect.TypeOf(v1.SeatCell{}).NumField(); n != 3 { t.Fatalf("SeatCell 字段数=%d want 3", n) } }