219 lines
7.6 KiB
Go
219 lines
7.6 KiB
Go
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)
|
||
}
|
||
}
|