package httpapi_test import ( "database/sql" "encoding/json" "net/http" "testing" ) type seedIDs struct { teamA, teamB int64 athleteA, athB int64 sport, event int64 category, entryA int64 entryB int64 } func (e *testEnv) seed(t *testing.T) seedIDs { t.Helper() var ids seedIDs ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`) ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`) ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA) ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB) ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`) ids.event = e.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event) var profileID int64 if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { t.Fatalf("读取积分模板: %v", err) } e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID) ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA) ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB) return ids } func (e *testEnv) exec(t *testing.T, query string, args ...any) { t.Helper() if _, err := e.ev.Exec(query, args...); err != nil { t.Fatalf("执行 SQL 失败: %v\n%s", err, query) } } func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 { t.Helper() res, err := e.ev.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 } // 未登录 = guest:赛事数据可读,系统配置一律 403。 func TestGuestReadsEventDataButNotConfig(t *testing.T) { env := newTestEnv(t) env.seed(t) client, _ := env.client() if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK { t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK) } if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK { t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK) } for _, path := range []string{"/api/settings", "/api/audit-logs"} { status, env2 := env.do(client, http.MethodGet, path, nil, false) if status != http.StatusForbidden { t.Fatalf("guest 读 %s 应 403,得到 %d", path, status) } if env2.Error == nil || env2.Error.Code != "FORBIDDEN" { t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error) } } // 未登录写操作要求先登录。 status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{ "code": "C", "name": "第三分公司", "active": true, }, false) if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" { t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error) } } // recorder 拥有录入权限,但没有系统配置权限。 func TestRecorderForbiddenOnAdminRoutes(t *testing.T) { env := newTestEnv(t) env.seed(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{ "event": map[string]any{"name": "改名"}, }, true) if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" { t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error) } if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden { t.Fatalf("recorder 读审计应 403,得到 %d", status) } // recorder 的合法写操作仍然可用。 status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{ "code": "C", "name": "第三分公司", "active": true, }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("recorder 建单位应 200,得到 %d %+v", status, env2.Error) } } // 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。 func TestLoginCreatesSession(t *testing.T) { env := newTestEnv(t) userID := env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") var count int if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil { t.Fatalf("查询会话: %v", err) } if count != 1 { t.Fatalf("登录后应有 1 条会话,得到 %d", count) } status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false) if status != http.StatusOK { t.Fatalf("me 应 200,得到 %d", status) } me := decodeData[map[string]any](t, env2) if me["username"] != "recorder" || me["role"] != "recorder" { t.Fatalf("me 返回身份不对: %+v", me) } if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK { t.Fatalf("登出应 200,得到 %d", status) } status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false) me = decodeData[map[string]any](t, env2) if status != http.StatusOK || me["role"] != "guest" { t.Fatalf("登出后应为 guest,得到 %d %+v", status, me) } } // 首次启动创建默认管理员并打印随机初始密码。 func TestEnsureDefaultAdmin(t *testing.T) { env := newTestEnv(t) username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context()) if err != nil { t.Fatalf("创建默认管理员: %v", err) } if !created || username == "" || len(password) < 12 { t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password)) } client, _ := env.client() env.login(client, username, password) } // 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。 func TestPublishWritesLedgerAndAudit(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) userID := env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "requestId": "draft-1", "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, }, }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error) } status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "requestId": "publish-1", }, true) if status != http.StatusOK || !env2.OK { t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) } results := decodeData[[]map[string]any](t, env2) if len(results) != 2 { t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results)) } for _, r := range results { if r["state"] != "published" { t.Fatalf("成绩应为 published: %+v", r) } } // 流水:A 10 分、B 8 分。 rows, err := env.ev.Query( `SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`) if err != nil { t.Fatalf("查询流水: %v", err) } defer func() { _ = rows.Close() }() points := map[int64]float64{} for rows.Next() { var team int64 var p float64 if err := rows.Scan(&team, &p); err != nil { t.Fatalf("扫描流水: %v", err) } points[team] = p } if points[ids.teamA] != 10 || points[ids.teamB] != 8 { t.Fatalf("流水分值不对: %+v", points) } // 积分榜:A 第一。 _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false) standings := decodeData[[]map[string]any](t, env2) if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 { t.Fatalf("积分榜不对: %+v", standings) } if standings[0]["gold"].(float64) != 1 { t.Fatalf("A 应记 1 金: %+v", standings[0]) } // 审计:操作人、IP、request_id、before/after 都要有。 var ( action string auditUser sql.NullInt64 ip, requestID sql.NullString before, after sql.NullString ) err = env.ev.QueryRow( `SELECT action, user_id, ip, request_id, before_json, after_json FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`). Scan(&action, &auditUser, &ip, &requestID, &before, &after) if err != nil { t.Fatalf("查询审计: %v", err) } if !auditUser.Valid || auditUser.Int64 != userID { t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser) } if !ip.Valid || ip.String == "" { t.Fatalf("审计应记录 IP") } if !requestID.Valid || requestID.String == "" { t.Fatalf("审计应记录 request_id") } if !before.Valid || !after.Valid { t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after) } var parsed []map[string]any if err := json.Unmarshal([]byte(after.String), &parsed); err != nil { t.Fatalf("审计 after_json 应为成绩数组: %v", err) } } // 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400; // 同一个 X-Request-Id 重复提交应回放首次结果。 func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) { env := newTestEnv(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"} status, first := env.do(client, http.MethodPost, "/api/teams", body, true) if status != http.StatusOK || !first.OK { t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error) } headers := map[string]string{"X-Request-Id": "replay-key-1"} status, second := env.doH(client, http.MethodPost, "/api/teams", map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers) if status != http.StatusOK { t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status) } status, replay := env.doH(client, http.MethodPost, "/api/teams", map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers) if status != http.StatusOK || string(replay.Data) != string(second.Data) { t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data) } var teams int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil { t.Fatalf("统计单位: %v", err) } if teams != 2 { t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams) } } // 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。 func TestPublishIdempotentReplay(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.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "requestId": "draft-1", "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, }, }, true) body := map[string]any{"eventId": ids.event, "requestId": "publish-same"} _, first := env.do(client, http.MethodPost, "/api/results/publish", body, true) _, second := env.do(client, http.MethodPost, "/api/results/publish", body, true) if string(first.Data) != string(second.Data) { t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data) } var ledgerRows int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil { t.Fatalf("统计流水: %v", err) } if ledgerRows != 2 { t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows) } }