Files
qitongxue-api/internal/logic/event_query_test.go
2026-09-29 10:57:01 +08:00

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