feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入

This commit is contained in:
AskaEth committed 2026-09-09 19:51:50 +08:00
1 parent f3813171d3
commit 72462792ec
39 files changed
+10001 -28

No files matched your search

@@ -0,0 +1,288 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 用户管理是系统配置:guest 读 403、写 401,recorder 一律 403(见 devdocs/08 第 2 节)。
func TestUsersAdminOnly(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
guest, _ := env.client()
status, body := env.do(guest, http.MethodGet, "/api/users", nil, false)
if status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读用户列表应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
}
status, body = env.do(guest, http.MethodPost, "/api/users", map[string]any{
"username": "hacker", "role": "admin", "password": "password-1",
}, false)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 建用户应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(guest, http.MethodPost, "/api/users/1/password", map[string]any{}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 重置密码应 401,得到 %d", status)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, body := env.do(client, http.MethodGet, "/api/users", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 读用户列表应 403,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(client, http.MethodPost, "/api/users", map[string]any{
"username": "someone", "role": "recorder", "password": "password-1",
}, true); status != http.StatusForbidden {
t.Fatalf("recorder 建用户应 403,得到 %d", status)
}
if status, _ := env.do(client, http.MethodPut, "/api/users/1", map[string]any{"active": false}, true); status != http.StatusForbidden {
t.Fatalf("recorder 停用用户应 403,得到 %d", status)
}
}
// admin 建用户、改资料、重置密码、停用;并拒绝把自己降级或停用。
func TestAdminManagesUsers(t *testing.T) {
env := newTestEnv(t)
adminID := env.createUser("admin", "admin-pass", "admin")
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
status, body := env.do(admin, http.MethodGet, "/api/users", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("admin 读用户列表应 200,得到 %d %+v", status, body.Error)
}
users := decodeData[[]map[string]any](t, body)
if len(users) != 1 || users[0]["username"] != "admin" || users[0]["role"] != "admin" {
t.Fatalf("用户列表内容不对: %+v", users)
}
if users[0]["lastLoginAt"] == nil {
t.Fatalf("登录后应回填 lastLoginAt: %+v", users[0])
}
status, body = env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "zhang", "displayName": "张三", "role": "recorder", "password": "init-pass-1",
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建用户应 200,得到 %d %+v", status, body.Error)
}
created := decodeData[map[string]any](t, body)
newID := int64(created["id"].(float64))
if created["username"] != "zhang" || created["displayName"] != "张三" ||
created["role"] != "recorder" || created["active"] != true || created["mustChangePassword"] != true {
t.Fatalf("新建用户返回不对: %+v", created)
}
if status, body := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "zhang", "role": "recorder", "password": "init-pass-1",
}, true); status != http.StatusConflict || body.Error == nil || body.Error.Code != "CONFLICT" {
t.Fatalf("重名用户应 409 CONFLICT,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "weak", "role": "recorder", "password": "123",
}, true); status != http.StatusBadRequest {
t.Fatalf("弱密码应 400,得到 %d", status)
}
if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{
"username": "guestish", "role": "guest", "password": "password-1",
}, true); status != http.StatusBadRequest {
t.Fatalf("role=guest 应 400,得到 %d", status)
}
// 新用户拿到初始密码即可登录。
sub, _ := env.client()
env.login(sub, "zhang", "init-pass-1")
// 改资料:displayName + role。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{
"displayName": "张三丰", "role": "admin",
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改用户应 200,得到 %d %+v", status, body.Error)
}
updated := decodeData[map[string]any](t, body)
if updated["displayName"] != "张三丰" || updated["role"] != "admin" || updated["active"] != true {
t.Fatalf("改用户返回不对: %+v", updated)
}
// 管理员重置密码:留空随机生成,返回一次性明文。
status, body = env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", newID), map[string]any{}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
}
reset := decodeData[map[string]any](t, body)
password, _ := reset["password"].(string)
if reset["generated"] != true || len(password) < 12 || reset["mustChangePassword"] != true {
t.Fatalf("随机密码返回不对: %+v", reset)
}
// 旧密码立即失效,新密码可用;被重置者的会话已被作废。
if status, _ := env.do(sub, http.MethodPost, "/api/auth/login", map[string]any{
"username": "zhang", "password": "init-pass-1",
}, false); status != http.StatusUnauthorized {
t.Fatalf("旧密码应失效(401),得到 %d", status)
}
sub2, _ := env.client()
env.login(sub2, "zhang", password)
// 停用后不能登录。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{"active": false}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error)
}
if disabled := decodeData[map[string]any](t, body); disabled["active"] != false {
t.Fatalf("停用后 active 应为 false: %+v", disabled)
}
sub3, _ := env.client()
if status, _ := env.do(sub3, http.MethodPost, "/api/auth/login", map[string]any{
"username": "zhang", "password": password,
}, false); status != http.StatusUnauthorized {
t.Fatalf("停用用户登录应 401,得到 %d", status)
}
// 不能把自己降级或停用。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"role": "recorder"}, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("自己降级应 400 VALIDATION_FAILED,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"active": false}, true); status != http.StatusBadRequest {
t.Fatalf("自己停用应 400,得到 %d", status)
}
// 只改自己的显示名仍然允许。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"displayName": "总管理员"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改自己显示名应 200,得到 %d %+v", status, body.Error)
}
}
// 变更历史:recorder 可读列表与详情,guest 403;详情含快照与成绩/分数差异。
func TestRevisionsListAndDetail(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")
guest, _ := env.client()
if status, body := env.do(guest, http.MethodGet, "/api/revisions", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读变更历史应 403,得到 %d %+v", status, body.Error)
}
if status, _ := env.do(guest, http.MethodGet, "/api/revisions/1", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读变更详情应 403,得到 %d", status)
}
// 第一次发布:A 10 分、B 8 分。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"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)
status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读变更列表应 200,得到 %d %+v", status, body.Error)
}
page := decodeData[map[string]any](t, body)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("首次发布后应有 1 条变更记录,得到 %d", len(items))
}
first := items[0].(map[string]any)
if first["action"] != "publish" || first["eventName"] != "100米跑" || first["actorName"] != "recorder" {
t.Fatalf("变更列表字段不对: %+v", first)
}
if first["changedCount"].(float64) != 2 {
t.Fatalf("首次发布应记 2 条成绩变化,得到 %v", first["changedCount"])
}
if first["parentId"] != nil {
t.Fatalf("首条记录没有父节点: %+v", first)
}
firstID := int64(first["id"].(float64))
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", firstID), nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读变更详情应 200,得到 %d %+v", status, body.Error)
}
detail := decodeData[map[string]any](t, body)
if snapshot := detail["snapshot"].([]any); len(snapshot) != 2 {
t.Fatalf("快照应含 2 条成绩,得到 %d", len(snapshot))
}
diff := detail["diff"].(map[string]any)
changes := diff["results"].([]any)
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "added" {
t.Fatalf("首次发布的差异应为 2 条 added: %+v", changes)
}
teams := diff["teams"].([]any)
if len(teams) != 2 {
t.Fatalf("应涉及 2 个单位,得到 %d", len(teams))
}
got := map[string]map[string]any{}
for _, raw := range teams {
m := raw.(map[string]any)
got[m["teamCode"].(string)] = m
}
if got["A"]["pointsAfter"].(float64) != 10 || got["A"]["pointsDelta"].(float64) != 10 {
t.Fatalf("A 单位分数不对: %+v", got["A"])
}
if got["B"]["pointsAfter"].(float64) != 8 || got["B"]["pointsDelta"].(float64) != 8 {
t.Fatalf("B 单位分数不对: %+v", got["B"])
}
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
status, body = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("第二次发布应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false)
items = decodeData[map[string]any](t, body)["items"].([]any)
if len(items) != 2 {
t.Fatalf("两次发布后应有 2 条变更记录,得到 %d", len(items))
}
second := items[0].(map[string]any)
if second["changedCount"].(float64) != 2 {
t.Fatalf("第二次发布应记 2 条成绩变化,得到 %v", second["changedCount"])
}
secondID := int64(second["id"].(float64))
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", secondID), nil, false)
detail = decodeData[map[string]any](t, body)
diff = detail["diff"].(map[string]any)
changes = diff["results"].([]any)
if len(changes) != 2 || changes[0].(map[string]any)["change"] != "changed" {
t.Fatalf("第二次发布的差异应为 changed: %+v", changes)
}
if fields := changes[0].(map[string]any)["fields"].([]any); len(fields) == 0 {
t.Fatalf("changed 记录应给出变化字段: %+v", changes[0])
}
teams = diff["teams"].([]any)
got = map[string]map[string]any{}
for _, raw := range teams {
m := raw.(map[string]any)
got[m["teamCode"].(string)] = m
}
if got["A"]["pointsDelta"].(float64) != -2 || got["B"]["pointsDelta"].(float64) != 2 {
t.Fatalf("第二次发布的分数增减不对: A=%+v B=%+v", got["A"], got["B"])
}
// 分页:limit/offset 生效。
status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=1&offset=1", ids.event), nil, false)
page = decodeData[map[string]any](t, body)
if page["total"].(float64) != 2 || page["limit"].(float64) != 1 || len(page["items"].([]any)) != 1 {
t.Fatalf("分页参数未生效: %+v", page)
}
}