package httpapi import ( "context" "database/sql" "errors" "io" "log/slog" "net/http" "path/filepath" "strings" "testing" _ "modernc.org/sqlite" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。 func newEntryTestServer(t *testing.T) (*server, *sql.DB) { t.Helper() db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") if err != nil { t.Fatalf("打开赛事库: %v", err) } db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) if err := store.MigrateEvent(db); err != nil { t.Fatalf("迁移赛事库: %v", err) } return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db } type entryFixture struct { eventA, eventB int64 catA, catB int64 team int64 } func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture { t.Helper() exec := func(query string, args ...any) int64 { res, err := db.Exec(query, args...) if err != nil { t.Fatalf("执行 SQL 失败: %v\n%s", err, query) } id, err := res.LastInsertId() if err != nil { t.Fatalf("取 id 失败: %v", err) } return id } sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`) var f entryFixture f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport) f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport) f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA) f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB) f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`) return f } func requireAPIStatus(t *testing.T, err error, status int) *apiError { t.Helper() if err == nil { t.Fatalf("期望错误,得到 nil") } var api *apiError if !errors.As(err, &api) { t.Fatalf("期望 *apiError,得到 %T: %v", err, err) } if api.status != status { t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message) } return api } func TestNormalizeEntryStatus(t *testing.T) { for _, tc := range []struct{ raw, want string }{ {"", "registered"}, {"dns", "dns"}, {" scratched ", "scratched"}, } { got, err := normalizeEntryStatus(tc.raw) if err != nil || got != tc.want { t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want) } } got, err := normalizeEntryStatus("bogus") if got != "" || err == nil { t.Fatalf("非法 status 应报错,得到 %q, %v", got, err) } api := requireAPIStatus(t, err, http.StatusBadRequest) if api.code != "VALIDATION_FAILED" { t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code) } if !strings.Contains(api.message, "status") { t.Fatalf("错误文案应带字段名,得到 %q", api.message) } if api.detail != "bogus" { t.Fatalf("detail 应是原始值,得到 %v", api.detail) } } func TestCreateEntryRejectsForeignCategory(t *testing.T) { s, db := newEntryTestServer(t) f := seedEntryFixture(t, db) ctx := context.Background() _, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"}) api := requireAPIStatus(t, err, http.StatusBadRequest) if api.message != "categoryId 不属于该小项" { t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message) } if api.detail != f.catB { t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail) } var n int if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil { t.Fatalf("统计报名项: %v", err) } if n != 0 { t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n) } created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"}) if err != nil { t.Fatalf("同小项组别应创建成功: %v", err) } if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" { t.Fatalf("报名项字段不对: %+v", created) } } func TestCreateEntryRejectsUnknownStatus(t *testing.T) { s, db := newEntryTestServer(t) f := seedEntryFixture(t, db) _, err := s.createEntry(context.Background(), entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"}) api := requireAPIStatus(t, err, http.StatusBadRequest) if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") { t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api) } } func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) { s, db := newEntryTestServer(t) f := seedEntryFixture(t, db) ctx := context.Background() created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"}) if err != nil { t.Fatalf("准备报名项失败: %v", err) } // 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。 _, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"}) api := requireAPIStatus(t, err, http.StatusBadRequest) if api.message != "categoryId 不属于该小项" || api.detail != f.catB { t.Fatalf("非法组别应被拦下,得到 %+v", api) } updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"}) if err != nil { t.Fatalf("同小项组别应更新成功: %v", err) } if updated.CategoryID != f.catA || updated.Status != "scratched" { t.Fatalf("更新结果不对: %+v", updated) } // 报名项不存在仍是 404,不能被组别校验降级成 400。 _, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"}) api = requireAPIStatus(t, err, http.StatusNotFound) if api.message != "报名项不存在" { t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api) } }