package httpapi_test import ( "database/sql" "encoding/json" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) // 一份最小可用的日程配置:含中文、含 note / done 缺省、含单屏覆盖。 // 字段名与 web/src/shared/types.ts 的 ScheduleSidebarConfig 逐字对应。 func schedulePayload() map[string]any { return map[string]any{ "items": []map[string]any{ {"time": "08:30", "title": "男子 100 米预赛", "note": "检录 08:00", "done": true}, {"time": "上午", "title": "开幕式"}, {"time": "待定", "title": "闭幕式", "done": false}, }, "visible": true, "side": "left", "widthPercent": 22, "showNote": true, "instanceOverrides": map[string]any{"browser-1": "on", "browser-2": "off"}, } } // postSchedule 下发一条 schedule 指令;csrf 只在已登录请求上带(未登录请求没有 CSRF Cookie)。 func postSchedule(t *testing.T, env *testEnv, client *http.Client, payload any, csrf bool) (int, envelope) { t.Helper() return env.do(client, http.MethodPost, "/api/display/command", map[string]any{"action": "schedule", "payload": payload}, csrf) } // 日程保存:**先转发、再落库**,并留下 settings 变更(大屏靠它 resync 才看得到)。 // 落库的 JSON 形状要与前端类型逐字对上:小驼峰键、note/done 可缺、中文原样。 func TestDisplayCommandScheduleForwardsThenPersists(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) before := changeLogCount(t, env, "settings", "upsert") status, env2 := postSchedule(t, env, client, schedulePayload(), true) if status != http.StatusOK || !env2.OK { t.Fatalf("保存日程应 200,得到 %d %+v", status, env2.Error) } // 转发:大屏要立刻看到侧边栏(热开关),不能只落库等下次快照。 if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule { t.Fatalf("日程必须下发给实时层,得到 %+v", sender.sent) } if len(sender.targets) != 1 || sender.targets[0] != nil { t.Fatalf("不带 targets 时应按广播下发(nil),得到 %+v", sender.targets) } // 落库:只写 settings 行不会有广播,必须有这条 change_log,且它的载荷要带上日程 // ——大屏就是靠这条变更重拉设置、把侧边栏显示出来的。 if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 { t.Fatalf("保存日程应新增 1 条 settings change_log,得到 %d → %d", before, got) } var payload string if err := env.ev.QueryRow( `SELECT payload_json FROM change_log WHERE entity_type = 'settings' AND action = 'upsert' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil { t.Fatalf("读 change_log 载荷: %v", err) } if !strings.Contains(payload, `"schedule"`) || !strings.Contains(payload, "男子 100 米预赛") { t.Fatalf("settings 变更的载荷应带上整份日程,得到 %s", payload) } raw := settingValue(t, env, "display.schedule") var stored map[string]any if err := json.Unmarshal([]byte(raw), &stored); err != nil { t.Fatalf("落库值应是 JSON: %v (%s)", err, raw) } for _, key := range []string{"items", "visible", "side", "widthPercent", "showNote", "instanceOverrides"} { if _, ok := stored[key]; !ok { t.Fatalf("落库 JSON 缺少小驼峰键 %s: %s", key, raw) } } if stored["visible"] != true || stored["side"] != "left" || stored["showNote"] != true { t.Fatalf("开关与左右应原样落库: %s", raw) } if stored["widthPercent"].(float64) != 22 { t.Fatalf("宽度应落库为 22: %s", raw) } items := stored["items"].([]any) if len(items) != 3 { t.Fatalf("应落库 3 条日程,得到 %d: %s", len(items), raw) } first := items[0].(map[string]any) if first["time"] != "08:30" || first["title"] != "男子 100 米预赛" || first["note"] != "检录 08:00" || first["done"] != true { t.Fatalf("第一条日程应原样落库: %+v", first) } // note / done 是可缺字段:没填的那条不该被补成空串,前端按可选字段解析。 second := items[1].(map[string]any) if _, ok := second["note"]; ok { t.Fatalf("没填备注的条目不该带 note 键: %+v", second) } if _, ok := second["done"]; ok { t.Fatalf("未勾选的条目不该带 done 键: %+v", second) } overrides := stored["instanceOverrides"].(map[string]any) if overrides["browser-1"] != "on" || overrides["browser-2"] != "off" { t.Fatalf("单屏覆盖应原样落库: %+v", overrides) } audit := waitDisplayAudit(t, env) for _, want := range []string{`"action":"schedule"`, `"matched":1`} { if !strings.Contains(audit, want) { t.Fatalf("审计应含 %s,得到 %s", want, audit) } } } // 保存 → 读回一致:管理端与快照走的是同一个 ParseEventSettings,读回来的形状必须与写进去的一样。 func TestDisplayCommandScheduleRoundTrip(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) env.createUser("boss", "boss-pass", "admin") boss, _ := env.client() env.login(boss, "boss", "boss-pass") if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { t.Fatalf("保存日程应 200,得到 %d %+v", status, e.Error) } _, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false) settings := decodeData[map[string]any](t, env2) schedule, ok := settings["display"].(map[string]any)["schedule"].(map[string]any) if !ok { t.Fatalf("读回的 settings 应带 display.schedule: %+v", settings["display"]) } if schedule["visible"] != true || schedule["side"] != "left" || schedule["showNote"] != true { t.Fatalf("读回的开关 / 左右 / 备注开关应与写进去的一致: %+v", schedule) } if schedule["widthPercent"].(float64) != 22 { t.Fatalf("读回的宽度应为 22: %+v", schedule) } items := schedule["items"].([]any) if len(items) != 3 { t.Fatalf("读回 3 条日程,得到 %d: %+v", len(items), items) } last := items[2].(map[string]any) if last["time"] != "待定" || last["title"] != "闭幕式" { t.Fatalf("手填的自由时间文本(「待定」)应原样读回: %+v", last) } // done 是可缺字段:false 不下线,前端按「没这个键 = 未完成」解析(这也是「当前项」的判据)。 if v, ok := last["done"]; ok { t.Fatalf("未完成项不该带 done 键,得到 %+v", v) } if overrides := schedule["instanceOverrides"].(map[string]any); overrides["browser-2"] != "off" { t.Fatalf("单屏覆盖应原样读回: %+v", schedule) } } // 上限与非法输入:一律 400 + 中文提示,且**不转发、不落库**。 // 静默截断会让操作员以为配置生效了,所以这里必须拒绝而不是存一半。 func TestDisplayCommandScheduleRejectsOverLimit(t *testing.T) { items := func(n int) []map[string]any { out := make([]map[string]any, 0, n) for i := 0; i < n; i++ { out = append(out, map[string]any{"time": "08:30", "title": "第 " + strings.Repeat("x", i%3) + " 项"}) } return out } payloadWith := func(mutate func(map[string]any)) map[string]any { p := schedulePayload() mutate(p) return p } for _, tc := range []struct { name string payload any want string }{ {"条目超过 40 条", payloadWith(func(p map[string]any) { p["items"] = items(41) }), "日程条目最多 40 条"}, {"标题为空", payloadWith(func(p map[string]any) { p["items"] = []map[string]any{{"time": "08:30", "title": "\n\x00"}} }), "第 1 条日程的标题不能为空"}, {"时间超过 20 字", payloadWith(func(p map[string]any) { p["items"] = []map[string]any{{"time": strings.Repeat("时", 21), "title": "开幕式"}} }), "时间最长 20 个字符"}, {"标题超过 40 字", payloadWith(func(p map[string]any) { p["items"] = []map[string]any{{"time": "08:30", "title": strings.Repeat("赛", 41)}} }), "标题最长 40 个字符"}, {"备注超过 60 字", payloadWith(func(p map[string]any) { p["items"] = []map[string]any{{"time": "08:30", "title": "开幕式", "note": strings.Repeat("备", 61)}} }), "备注最长 60 个字符"}, {"第二项越界也要指出是第几条", payloadWith(func(p map[string]any) { p["items"] = []map[string]any{ {"time": "08:30", "title": "开幕式"}, {"time": "09:00", "title": strings.Repeat("赛", 41)}, } }), "第 2 条日程的标题最长 40 个字符"}, {"单屏覆盖超过 50 条", payloadWith(func(p map[string]any) { overrides := map[string]any{} for i := 0; i < 51; i++ { overrides["screen-"+string(rune('a'+i%26))+strings.Repeat("x", i/26)] = "on" } p["instanceOverrides"] = overrides }), "单屏覆盖最多 50 条"}, {"单屏覆盖取值不是三态", payloadWith(func(p map[string]any) { p["instanceOverrides"] = map[string]any{"browser-1": "maybe"} }), "取值只能是 inherit / on / off"}, {"单屏覆盖的实例 id 超长", payloadWith(func(p map[string]any) { p["instanceOverrides"] = map[string]any{strings.Repeat("i", 121): "on"} }), "实例 id 最长 120 个字符"}, {"payload 不是对象", []any{1, 2, 3}, "格式不正确"}, } { t.Run(tc.name, func(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) before := changeLogCount(t, env, "settings", "upsert") status, env2 := postSchedule(t, env, client, tc.payload, true) if status != http.StatusBadRequest || env2.Error == nil { t.Fatalf("非法日程应 400,得到 %d %+v", status, env2.Error) } if env2.Error.Code != "VALIDATION_FAILED" { t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error) } if !strings.Contains(env2.Error.Message, tc.want) { t.Fatalf("提示应含 %q(说清哪一项超限),得到 %q", tc.want, env2.Error.Message) } if len(sender.sent) != 0 { t.Fatalf("非法日程不该下发(否则大屏先画一帧错的),得到 %+v", sender.sent) } if got := settingOrEmpty(t, env, "display.schedule"); got != "" { t.Fatalf("非法日程不该落库,得到 %q", got) } if got := changeLogCount(t, env, "settings", "upsert"); got != before { t.Fatalf("非法日程不该产生 settings 变更,得到 %d → %d", before, got) } }) } } // 时间**允许为空**:管理端的 validateSchedule 只卡时间长度、不卡必填(「先把标题排上、 // 时间回头补」是现场的常见流程),服务端必须同口径 —— 否则「校验不过时一个请求都不发」 // 这条承诺在这一格上不成立,操作员会先看到请求发出去、再被退一句 400。 // 空串 / 全空白 / 只含控制字符都算空,落库就是空串(不是特殊占位,也不是整条丢掉)。 func TestDisplayCommandScheduleAllowsEmptyTime(t *testing.T) { for _, tc := range []struct { name string time string }{ {"空串", ""}, {"全空白", " "}, {"只含控制字符", "\n\t\x00"}, } { t.Run(tc.name, func(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) status, env2 := postSchedule(t, env, client, map[string]any{ "items": []map[string]any{{"time": tc.time, "title": "闭幕式"}}, }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("时间为空应保存成功,得到 %d %+v", status, env2.Error) } if len(sender.sent) != 1 || sender.sent[0] != realtime.CmdSchedule { t.Fatalf("时间为空的日程也要下发给大屏,得到 %+v", sender.sent) } var stored struct { Items []map[string]any `json:"items"` } if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { t.Fatalf("解析落库值: %v", err) } if len(stored.Items) != 1 { t.Fatalf("条目应保留,得到 %+v", stored.Items) } if got, ok := stored.Items[0]["time"]; !ok || got != "" { t.Fatalf("时间应原样落库为空串(键要在、值要是空串),得到 %+v", stored.Items[0]) } if stored.Items[0]["title"] != "闭幕式" { t.Fatalf("标题应原样落库: %+v", stored.Items[0]) } }) } } // showNote 缺省 = **true**(备注默认上屏):没带这个字段的客户端不能把备注悄悄关掉。 // bool 零值分不出「键没带」与「显式 false」,所以这条用例两种都要钉。 func TestDisplayCommandScheduleShowNoteDefaultsTrue(t *testing.T) { for _, tc := range []struct { name string body map[string]any want bool }{ {"不带 showNote", map[string]any{}, true}, {"显式 true", map[string]any{"showNote": true}, true}, {"显式 false", map[string]any{"showNote": false}, false}, } { t.Run(tc.name, func(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) env.createUser("boss", "boss-pass", "admin") boss, _ := env.client() env.login(boss, "boss", "boss-pass") body := map[string]any{"items": []map[string]any{{"time": "08:30", "title": "开幕式"}}} for k, v := range tc.body { body[k] = v } if status, e := postSchedule(t, env, client, body, true); status != http.StatusOK { t.Fatalf("应 200,得到 %d %+v", status, e.Error) } // 落库的 JSON 里必须是显式的布尔(不吃零值),再看读回的口径。 var stored struct { ShowNote *bool `json:"showNote"` } if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { t.Fatalf("解析落库值: %v", err) } if stored.ShowNote == nil || *stored.ShowNote != tc.want { t.Fatalf("落库的 showNote 应为 %v,得到 %v", tc.want, stored.ShowNote) } _, env2 := env.do(boss, http.MethodGet, "/api/settings", nil, false) schedule := decodeData[map[string]any](t, env2)["display"].(map[string]any)["schedule"].(map[string]any) if schedule["showNote"] != tc.want { t.Fatalf("读回的 showNote 应为 %v,得到 %v", tc.want, schedule["showNote"]) } }) } // 类型不对(不是布尔)按「格式不正确」挡在门外,而不是悄悄当开: // 写入口是人在操作,含糊放过一个坏 payload 只会让现场更难看懂。 // (读侧没有出口,遇到这种值就回落 true,见 dto.ScheduleShowNote。) sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) status, env2 := postSchedule(t, env, client, map[string]any{ "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "showNote": "no", }, true) if status != http.StatusBadRequest || env2.Error == nil { t.Fatalf("showNote 不是布尔应 400,得到 %d %+v", status, env2.Error) } if !strings.Contains(env2.Error.Message, "格式不正确") { t.Fatalf("提示应说清是格式问题,得到 %q", env2.Error.Message) } } // `inherit` 是「没有这条设置」(缺省就是它):写侧接受但不落库,绝不白占 50 条预算。 func TestDisplayCommandScheduleDropsInheritOverrides(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) status, env2 := postSchedule(t, env, client, map[string]any{ "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "instanceOverrides": map[string]any{"browser-1": "inherit", "browser-2": "on"}, }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("inherit 应被接受(不算非法),得到 %d %+v", status, env2.Error) } var stored struct { InstanceOverrides map[string]string `json:"instanceOverrides"` } if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { t.Fatalf("解析落库值: %v", err) } if len(stored.InstanceOverrides) != 1 || stored.InstanceOverrides["browser-2"] != "on" { t.Fatalf("只该留下 browser-2=on,得到 %+v", stored.InstanceOverrides) } // 全是 inherit 时整块键都不该出现(前端按可选字段解析)。 if status, e := postSchedule(t, env, client, map[string]any{ "items": []map[string]any{{"time": "08:30", "title": "开幕式"}}, "instanceOverrides": map[string]any{"browser-1": "inherit"}, }, true); status != http.StatusOK { t.Fatalf("应 200,得到 %d %+v", status, e.Error) } if raw := settingValue(t, env, "display.schedule"); strings.Contains(raw, "instanceOverrides") { t.Fatalf("全是 inherit 时不该落库这个键: %s", raw) } } // 清洗:控制字符一律剥掉(复用 realtime.CleanUserText),首尾空白去掉; // 归一:widthPercent 夹到 15–40、缺省回落默认宽度,side 认不出时回落 right。 func TestDisplayCommandScheduleCleansAndClamps(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) status, env2 := postSchedule(t, env, client, map[string]any{ "items": []map[string]any{ {"time": " 08:30\t", "title": "男子\n100\x00米预赛", "note": "检录\r\n08:00", "done": true}, }, "visible": true, "side": "middle", "widthPercent": 100, "showNote": true, }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("保存在上限内的日程应 200,得到 %d %+v", status, env2.Error) } var stored struct { Items []struct { Time string `json:"time"` Title string `json:"title"` Note string `json:"note"` Done bool `json:"done"` } `json:"items"` Side string `json:"side"` WidthPercent float64 `json:"widthPercent"` } if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &stored); err != nil { t.Fatalf("解析落库值: %v", err) } if stored.Items[0].Time != "08:30" || stored.Items[0].Title != "男子100米预赛" || stored.Items[0].Note != "检录08:00" { t.Fatalf("控制字符应被剥掉、首尾空白应去掉: %+v", stored.Items[0]) } if !stored.Items[0].Done { t.Fatalf("已完成应原样保留: %+v", stored.Items[0]) } if stored.WidthPercent != 40 { t.Fatalf("线上限 40:widthPercent 100 应夹到 40,得到 %v", stored.WidthPercent) } if stored.Side != "right" { t.Fatalf("认不出的 side 应回落 right,得到 %q", stored.Side) } // 转发的也是归一后的值(否则大屏会先按 100% 画一帧)。 if audit := waitDisplayAudit(t, env); !strings.Contains(audit, `"widthPercent":40`) { t.Fatalf("审计里的配置应是归一后的值,得到 %s", audit) } // 下上限与缺省。 for _, tc := range []struct { name string body map[string]any side string width float64 }{ {"过窄夹到 15", map[string]any{"items": []any{}, "side": "left", "widthPercent": 5}, "left", 15}, {"缺省回落 22", map[string]any{"items": []any{}}, "right", 22}, {"负数回落 22", map[string]any{"items": []any{}, "widthPercent": -3}, "right", 22}, {"大小写不敏感", map[string]any{"items": []any{}, "side": "LEFT"}, "left", 22}, } { t.Run(tc.name, func(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) if status, e := postSchedule(t, env, client, tc.body, true); status != http.StatusOK { t.Fatalf("应 200,得到 %d %+v", status, e.Error) } var got struct { Side string `json:"side"` WidthPercent float64 `json:"widthPercent"` } if err := json.Unmarshal([]byte(settingValue(t, env, "display.schedule")), &got); err != nil { t.Fatalf("解析落库值: %v", err) } if got.Side != tc.side || got.WidthPercent != tc.width { t.Fatalf("归一结果应为 side=%s width=%v,得到 side=%s width=%v", tc.side, tc.width, got.Side, got.WidthPercent) } }) } } // 权限:与大屏公告(同一条 display:command 通道)同档 —— 未登录 401、录入员 200、管理员 200。 // 两种 action 各发一次,用同一次请求比对状态码,避免「档位被悄悄改动」看不出来。 func TestDisplayCommandSchedulePermissionMatchesAnnounce(t *testing.T) { sender := &strictDisplaySender{} env := newTestEnvWith(t, func(o *httpapi.Options) { o.Display = sender }) env.createUser("recorder", "recorder-pass", "recorder") env.createUser("boss", "boss-pass", "admin") anon, _ := env.client() // 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。 scheduleStatus, _ := postSchedule(t, env, anon, schedulePayload(), false) announceStatus, _ := env.do(anon, http.MethodPost, "/api/display/command", map[string]any{"action": "announce", "payload": map[string]any{"text": "决赛推迟"}}, false) if scheduleStatus != announceStatus { t.Fatalf("日程与公告应同档:未登录日程 %d,公告 %d", scheduleStatus, announceStatus) } if scheduleStatus != http.StatusUnauthorized { t.Fatalf("未登录应被挡下(写接口未登录一律 401),得到 %d", scheduleStatus) } for _, who := range []struct{ user, pass string }{{"recorder", "recorder-pass"}, {"boss", "boss-pass"}} { client, _ := env.client() env.login(client, who.user, who.pass) if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { t.Fatalf("%s 保存日程应 200,得到 %d %+v", who.user, status, e.Error) } } } // 定点点名的屏全离线:与既有 action 同样的 404,且日程不落库、不产生 settings 变更。 func TestDisplayCommandScheduleOfflineTargetReturns404(t *testing.T) { matched := 0 sender := &strictDisplaySender{matched: &matched} env, client := newDisplayEnv(t, sender) before := changeLogCount(t, env, "settings", "upsert") status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{ "action": "schedule", "payload": schedulePayload(), "targets": []string{"gone-screen"}, }, true) if status != http.StatusNotFound || env2.Error == nil { t.Fatalf("目标离线应 404,得到 %d %+v", status, env2.Error) } if env2.Error.Message != "目标大屏已离线,请刷新在线列表" { t.Fatalf("404 消息应与既有 action 一致,得到 %q", env2.Error.Message) } if got := settingOrEmpty(t, env, "display.schedule"); got != "" { t.Fatalf("没命中任何屏时日程不该落库,得到 %q", got) } if got := changeLogCount(t, env, "settings", "upsert"); got != before { t.Fatalf("定向失败不该产生 settings 变更,得到 %d → %d", before, got) } } // 读取侧容错:库里那个值被改坏(手工改库 / 写到一半断电)时,settings 照常 200、 // display.schedule 当空,大屏按「没有这个功能」处理 —— 一条脏设置绝不能把大屏打不开。 func TestSettingsTolerantToBrokenScheduleValue(t *testing.T) { env := newTestEnv(t) env.createUser("boss", "boss-pass", "admin") client, _ := env.client() env.login(client, "boss", "boss-pass") writeRaw := func(raw string) { t.Helper() if _, err := env.ev.Exec( `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, "display.schedule", raw); err != nil { t.Fatalf("写入配置: %v", err) } } readSchedule := func() (map[string]any, bool) { t.Helper() status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false) if status != http.StatusOK || !env2.OK { t.Fatalf("坏数据下 settings 仍应 200,得到 %d %+v", status, env2.Error) } display := decodeData[map[string]any](t, env2)["display"].(map[string]any) v, ok := display["schedule"].(map[string]any) return v, ok } for _, tc := range []struct{ name, raw string }{ {"坏 JSON", `{items: [`}, {"不是对象", `"日程"`}, {"截断的 JSON", `{"items":[{"time":"08:30","title":"开幕`}, } { writeRaw(tc.raw) if _, ok := readSchedule(); ok { t.Fatalf("%s:坏数据应读成 null(当空),得到非空值", tc.name) } } // 结构上不成立的部分丢掉:**标题**缺失的条目、非三态的覆盖,越界的条数取前 40。 // 时间为空的条目是合法的,必须留着(与写入口、管理端同口径),`time` 读成空串。 writeRaw(`{"items":[{"time":"08:30","title":"开幕式"},{"time":"","title":"时间待定"},` + `{"time":"09:00","title":""},{"title":"连 time 键都没有"}],` + `"visible":true,"side":"up","widthPercent":999,"showNote":false,` + `"instanceOverrides":{"browser-1":"on","browser-2":"maybe","":"on"}}`) got, ok := readSchedule() if !ok { t.Fatal("结构合法的部分应照样读出来") } items := got["items"].([]any) if len(items) != 3 { t.Fatalf("只有标题为空的条目该丢掉,应留 3 条,得到 %+v", items) } if empty := items[1].(map[string]any); empty["title"] != "时间待定" || empty["time"] != "" { t.Fatalf("时间为空的条目应保留且 time 读成空串,得到 %+v", empty) } if noKey := items[2].(map[string]any); noKey["title"] != "连 time 键都没有" || noKey["time"] != "" { t.Fatalf("连 time 键都没有的条目也该保留(当空串),得到 %+v", noKey) } if got["side"] != "right" { t.Fatalf("认不出的 side 读侧也应回落 right,得到 %v", got["side"]) } if got["widthPercent"].(float64) != 40 { t.Fatalf("越界宽度读侧应夹到 40,得到 %v", got["widthPercent"]) } if got["visible"] != true { t.Fatalf("总开关应照样读出来: %+v", got) } overrides := got["instanceOverrides"].(map[string]any) if len(overrides) != 1 || overrides["browser-1"] != "on" { t.Fatalf("非三态与空 id 的覆盖应丢掉,只留 browser-1: %+v", overrides) } // 条数越界:读侧只取前 40 条,不报错。 var sb strings.Builder sb.WriteString(`{"items":[`) for i := 0; i < 41; i++ { if i > 0 { sb.WriteString(",") } sb.WriteString(`{"time":"08:30","title":"项"}`) } sb.WriteString(`],"visible":true}`) writeRaw(sb.String()) got, ok = readSchedule() if !ok { t.Fatal("条数越界时其余字段仍应读出来") } if got := got["items"].([]any); len(got) != 40 { t.Fatalf("读侧应只取前 40 条,得到 %d", len(got)) } } // 未接入实时层时(s.display == nil)日程照样落库:大屏后连上来靠的是 settings + 快照。 func TestDisplayCommandSchedulePersistsWithoutRealtime(t *testing.T) { env := newTestEnv(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") if status, e := postSchedule(t, env, client, schedulePayload(), true); status != http.StatusOK { t.Fatalf("没有实时层时应落库并 200,得到 %d %+v", status, e.Error) } var n int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'display.schedule'`).Scan(&n); err != nil { t.Fatalf("查配置: %v", err) } if n != 1 { t.Fatalf("日程应落库,得到 %d 行", n) } } // 漏了 payload(客户端 bug)时给中文 400,而不是把大屏切成一个空日程。 func TestDisplayCommandScheduleRequiresPayload(t *testing.T) { sender := &strictDisplaySender{} env, client := newDisplayEnv(t, sender) status, env2 := env.do(client, http.MethodPost, "/api/display/command", map[string]any{"action": "schedule"}, true) if status != http.StatusBadRequest || env2.Error == nil { t.Fatalf("缺 payload 应 400,得到 %d %+v", status, env2.Error) } if !strings.Contains(env2.Error.Message, "缺少 payload") { t.Fatalf("提示应说清缺什么,得到 %q", env2.Error.Message) } if len(sender.sent) != 0 { t.Fatalf("缺 payload 不该下发,得到 %+v", sender.sent) } var v sql.NullString if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.schedule'`).Scan(&v); err == nil { t.Fatalf("缺 payload 不该落库,得到 %q", v.String) } }