package httpapi_test import ( "net/http" "strconv" "testing" "time" ) type liveEventsBody struct { Source string `json:"source"` EventIDs []int64 `json:"eventIds"` Events []struct { Event struct { ID int64 `json:"id"` Name string `json:"name"` } `json:"event"` Reason string `json:"reason"` Groups []struct { EventID int64 `json:"eventId"` CategoryID int64 `json:"categoryId"` CategoryName string Rows []struct { EntryID int64 `json:"entryId"` Rank *int `json:"rank"` ResultText string `json:"resultText"` } `json:"rows"` } `json:"groups"` } `json:"events"` } type settingsBody struct { Display struct { LiveEvents []int64 `json:"liveEvents"` LiveSource string `json:"liveSource"` } `json:"display"` } // 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。 func TestSettingsLiveEventsRoundTrip(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"}, }, true); status != http.StatusOK { t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error) } status, e := env.do(client, http.MethodGet, "/api/settings", nil, false) if status != http.StatusOK { t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error) } got := decodeData[settingsBody](t, e) if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event { t.Fatalf("设置应原样读回,得到 %+v", got.Display) } // 库里以逗号分隔的 id 文本存储,随赛事包一起走。 var raw string if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil { t.Fatalf("读取存储值: %v", err) } if raw != strconv.FormatInt(ids.event, 10) { t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw) } // 清空人工指定:传空数组。 if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "display": map[string]any{"liveEvents": []int64{}}, }, true); status != http.StatusOK { t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error) } _, e = env.do(client, http.MethodGet, "/api/settings", nil, false) if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 { t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents) } // 非法取值全部拒绝。 for _, patch := range []map[string]any{ {"display": map[string]any{"liveSource": "sometimes"}}, {"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}}, {"display": map[string]any{"liveEvents": []int64{999999}}}, } { status, e := env.do(client, http.MethodPut, "/api/settings", patch, true) if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch) } } } // 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。 func TestDisplayLiveEndpoint(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") env.createUser("admin", "admin-pass", "admin") adminClient, _ := env.client() env.login(adminClient, "admin", "admin-pass") // 第二、三个小项:只用来验证人工指定(最多 2 个)。 other := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) other2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) guest, _ := env.client() live := func() liveEventsBody { t.Helper() status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false) if status != http.StatusOK || !e.OK { t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error) } return decodeData[liveEventsBody](t, e) } setDisplay := func(patch map[string]any) { t.Helper() if status, e := env.do(adminClient, http.MethodPut, "/api/settings", map[string]any{"display": patch}, true); status != http.StatusOK { t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error) } } // 1) manual 只认人工指定(两个)。 setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}}) got := live() if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 { t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got) } if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other { t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events) } if len(got.Events[0].Groups) != 0 { t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups) } // 2) auto:排期落在当前窗口内的小项命中。 scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339) env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event) setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}}) got = live() if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got) } if got.Events[0].Reason != "scheduled" { t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0]) } // 3) auto:排期都不命中时回退到最近发布过成绩的小项。 env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event) if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "status": "ok"}, }, }, true); status != http.StatusOK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) } got = live() if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got) } if got.Events[0].Reason != "recent" { t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0]) } if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 { t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups) } if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 { t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0]) } } // 全新赛事(一个小项都没有)时不能报错。 func TestDisplayLiveEmptyEvent(t *testing.T) { env := newTestEnv(t) client, _ := env.client() status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false) if status != http.StatusOK || !e.OK { t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error) } got := decodeData[liveEventsBody](t, e) if len(got.EventIDs) != 0 || len(got.Events) != 0 { t.Fatalf("空赛事应回空列表,得到 %+v", got) } if got.Source != "auto" { t.Fatalf("未配置时默认 auto,得到 %q", got.Source) } }