package httpapi_test import ( "net/http" "os" "path/filepath" "strings" "testing" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) // projectItem 对应 GET /api/projects 的一项(字段名是前后端共用的契约)。 type projectItem struct { Name string `json:"name"` Active bool `json:"active"` HasEvent bool `json:"hasEvent"` SizeBytes int64 `json:"sizeBytes"` UpdatedAt string `json:"updatedAt"` } type projectList struct { Projects []projectItem `json:"projects"` } type projectActivateResult struct { Name string `json:"name"` Restarting bool `json:"restarting"` } // newProjectEnv 建一个「当前打开 active 项目」的测试环境,并返回数据目录与关闭回调的通知通道。 func newProjectEnv(t *testing.T, active string) (*testEnv, string, chan struct{}) { t.Helper() var dataDir string shutdown := make(chan struct{}, 1) env := newTestEnvWith(t, func(o *httpapi.Options) { dataDir = o.DataDir o.ProjectDir = filepath.Join(o.DataDir, "event_projs", active) o.Shutdown = func() { shutdown <- struct{}{} } }) return env, dataDir, shutdown } // writeProjectDir 造一个赛事项目目录;hasEvent=false 时只有目录,模拟导入了一半的项目。 func writeProjectDir(t *testing.T, dataDir, name string, hasEvent bool) { t.Helper() dir := filepath.Join(dataDir, "event_projs", name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("创建项目目录 %s: %v", name, err) } if !hasEvent { return } if err := os.WriteFile(filepath.Join(dir, "event.db"), []byte("sqlite-bytes"), 0o644); err != nil { t.Fatalf("写项目 %s 的赛事库: %v", name, err) } } func readActiveProjectFile(t *testing.T, dataDir string) string { t.Helper() raw, err := os.ReadFile(filepath.Join(dataDir, httpapi.ActiveProjectFile)) if err != nil { t.Fatalf("读取记忆文件: %v", err) } return strings.TrimSpace(string(raw)) } func projectByName(t *testing.T, env *testEnv, client *http.Client) map[string]projectItem { t.Helper() status, body := env.do(client, http.MethodGet, "/api/projects", nil, false) if status != http.StatusOK { t.Fatalf("列出赛事项目应 200,得到 %d %+v", status, body.Error) } out := map[string]projectItem{} for _, p := range decodeData[projectList](t, body).Projects { out[p.Name] = p } return out } // 导入的赛事包落在 event_projs/<名字>/,管理端必须先看得到这些项目才可能切过去(P0:导入后页面空白)。 func TestListProjectsReportsAvailability(t *testing.T) { env, dataDir, _ := newProjectEnv(t, "test") writeProjectDir(t, dataDir, "秋季赛", true) writeProjectDir(t, dataDir, "缺库的赛", false) // 导入中途的临时目录不该出现在列表里。 if err := os.MkdirAll(filepath.Join(dataDir, "event_projs", ".import-abc"), 0o755); err != nil { t.Fatalf("创建导入临时目录: %v", err) } // 与赛事无关的文件也不该被当成项目。 if err := os.WriteFile(filepath.Join(dataDir, "event_projs", "README.txt"), []byte("x"), 0o644); err != nil { t.Fatalf("写无关文件: %v", err) } env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") projects := projectByName(t, env, client) if len(projects) != 3 { t.Fatalf("应列出 test / 秋季赛 / 缺库的赛 三个项目,得到 %+v", projects) } if !projects["test"].Active || !projects["test"].HasEvent { t.Errorf("test 应是当前项目且有赛事库,得到 %+v", projects["test"]) } if projects["秋季赛"].Active { t.Errorf("秋季赛 不是当前项目,不应标 active:%+v", projects["秋季赛"]) } if !projects["秋季赛"].HasEvent || projects["秋季赛"].UpdatedAt == "" { t.Errorf("秋季赛 有 event.db,hasEvent/updatedAt 应有效:%+v", projects["秋季赛"]) } if _, err := time.Parse(time.RFC3339, projects["秋季赛"].UpdatedAt); err != nil { t.Errorf("updatedAt 应是 RFC3339,得到 %q", projects["秋季赛"].UpdatedAt) } if projects["秋季赛"].SizeBytes < int64(len("sqlite-bytes")) { t.Errorf("sizeBytes 应统计目录内文件,得到 %d", projects["秋季赛"].SizeBytes) } if projects["缺库的赛"].HasEvent || projects["缺库的赛"].UpdatedAt != "" { t.Errorf("没有 event.db 的项目 hasEvent=false、updatedAt 为空:%+v", projects["缺库的赛"]) } } // event_projs 还不存在(全新安装)时回空列表而不是 500。 func TestListProjectsMissingRoot(t *testing.T) { env, dataDir, _ := newProjectEnv(t, "test") if err := os.RemoveAll(filepath.Join(dataDir, "event_projs")); err != nil { t.Fatalf("删除项目根目录: %v", err) } env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") if projects := projectByName(t, env, client); len(projects) != 0 { t.Fatalf("项目根目录不存在时应回空列表,得到 %+v", projects) } } // 列表是 admin 接口:没登录 403、低权限角色也 403。 func TestListProjectsRequiresAdmin(t *testing.T) { env := newTestEnv(t) env.createUser("rec", "rec-pass", "recorder") client, _ := env.client() if status, _ := env.do(client, http.MethodGet, "/api/projects", nil, false); status != http.StatusForbidden { t.Fatalf("未登录应 403,得到 %d", status) } env.login(client, "rec", "rec-pass") if status, _ := env.do(client, http.MethodGet, "/api/projects", nil, false); status != http.StatusForbidden { t.Fatalf("recorder 应 403,得到 %d", status) } } // 切换赛事:写记忆文件 + 返回 restarting=true + 触发优雅关闭(Console 据此自动拉起新赛事)。 func TestActivateProjectWritesMemoryAndRestarts(t *testing.T) { env, dataDir, shutdown := newProjectEnv(t, "test") writeProjectDir(t, dataDir, "秋季赛", true) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") status, body := env.do(client, http.MethodPost, "/api/projects/activate", map[string]any{"name": "秋季赛"}, true) if status != http.StatusOK { t.Fatalf("切换赛事应 200,得到 %d %+v", status, body.Error) } res := decodeData[projectActivateResult](t, body) if res.Name != "秋季赛" || !res.Restarting { t.Fatalf("应返回 {name:秋季赛, restarting:true},得到 %+v", res) } if got := readActiveProjectFile(t, dataDir); got != "秋季赛" { t.Fatalf("记忆文件应记住新项目,得到 %q", got) } select { case <-shutdown: case <-time.After(3 * time.Second): t.Fatal("切换赛事后应触发优雅关闭,等 Console 自动重启") } } // 切到自己身上不能重启:管理端重复点击会把现场反复断服。 func TestActivateCurrentProjectDoesNotRestart(t *testing.T) { env, dataDir, shutdown := newProjectEnv(t, "test") env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") status, body := env.do(client, http.MethodPost, "/api/projects/activate", map[string]any{"name": "test"}, true) if status != http.StatusOK { t.Fatalf("切到当前项目应 200,得到 %d %+v", status, body.Error) } if res := decodeData[projectActivateResult](t, body); res.Restarting { t.Fatalf("当前项目不该返回 restarting=true:%+v", res) } if _, err := os.Stat(filepath.Join(dataDir, httpapi.ActiveProjectFile)); !os.IsNotExist(err) { t.Fatalf("切到当前项目不该写记忆文件: %v", err) } select { case <-shutdown: t.Fatal("切到当前项目不该触发关闭") case <-time.After(200 * time.Millisecond): } } // 校验失败一律 400 + 中文提示,且不能留下记忆文件、更不能重启(写文件的目录是脏的)。 func TestActivateProjectValidation(t *testing.T) { env, dataDir, shutdown := newProjectEnv(t, "test") writeProjectDir(t, dataDir, "缺库的赛", false) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") for _, tc := range []struct{ name, want, exact string }{ {"../逃逸", "非法", `赛事项目名非法:"../逃逸"`}, {"", "非法", "赛事项目名非法:名称为空"}, {"没导入过", "不存在", ""}, {"缺库的赛", "event.db", ""}, } { status, body := env.do(client, http.MethodPost, "/api/projects/activate", map[string]any{"name": tc.name}, true) if status != http.StatusBadRequest { t.Fatalf("名字 %q 应 400,得到 %d %+v", tc.name, status, body.Error) } if body.Error == nil || !strings.Contains(body.Error.Message, tc.want) { t.Fatalf("名字 %q 的提示应含 %q,得到 %+v", tc.name, tc.want, body.Error) } if tc.exact != "" && body.Error.Message != tc.exact { t.Fatalf("名字 %q 的提示应是 %q,得到 %q", tc.name, tc.exact, body.Error.Message) } } if _, err := os.Stat(filepath.Join(dataDir, httpapi.ActiveProjectFile)); !os.IsNotExist(err) { t.Fatalf("校验失败不该写记忆文件: %v", err) } select { case <-shutdown: t.Fatal("校验失败不该触发关闭") case <-time.After(200 * time.Millisecond): } } // recorder 能看到的项目也不能自己切:切换是 admin 操作。 func TestActivateProjectRequiresAdmin(t *testing.T) { env := newTestEnv(t) env.createUser("rec", "rec-pass", "recorder") client, _ := env.client() env.login(client, "rec", "rec-pass") status, _ := env.do(client, http.MethodPost, "/api/projects/activate", map[string]any{"name": "test"}, true) if status != http.StatusForbidden { t.Fatalf("recorder 切换赛事应 403,得到 %d", status) } } // 没接入优雅关闭(手工跑的裸服务端)时也不能 500:记忆文件写好,下次重启生效。 func TestActivateProjectWithoutShutdownHook(t *testing.T) { var dataDir string env := newTestEnvWith(t, func(o *httpapi.Options) { dataDir = o.DataDir o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "test") o.Shutdown = nil }) writeProjectDir(t, dataDir, "秋季赛", true) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") status, body := env.do(client, http.MethodPost, "/api/projects/activate", map[string]any{"name": "秋季赛"}, true) if status != http.StatusOK { t.Fatalf("未接入关闭回调也应 200,得到 %d %+v", status, body.Error) } if res := decodeData[projectActivateResult](t, body); !res.Restarting { t.Fatalf("记忆文件已写好,仍应返回 restarting=true:%+v", res) } }