package httpapi_test import ( "encoding/json" "fmt" "net/http" "strings" "testing" ) // detailOf 把错误响应的 detail 还原成字符串,用来断言「detail 是原始入参」。 func detailOf(t *testing.T, raw json.RawMessage) string { t.Helper() var s string if err := json.Unmarshal(raw, &s); err != nil { t.Fatalf("detail 应是原始值字符串: %v (%s)", err, raw) } return s } // 枚举入参非法时必须是 400 VALIDATION_FAILED,且 message 带字段名、detail 带原始值; // 而不是一路落到 SQLite 的 CHECK 上变成 500 INTERNAL。 func TestEventEnumValidation(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client := recorderClient(t, env) for i, tc := range []struct{ field, value string }{ {"eventType", "bogus"}, {"resultMode", "bogus"}, {"resultType", "bogus"}, {"compare", "bogus"}, {"status", "bogus"}, } { status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{ "sportId": ids.sport, "code": fmt.Sprintf("E-BAD-%d", i), "name": "非法枚举", tc.field: tc.value, }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error) } if !strings.Contains(resp.Error.Message, tc.field) { t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message) } if got := detailOf(t, resp.Error.Detail); got != tc.value { t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got) } } // 合法值原样落库(含 0016 追加的 individual_team)。 status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{ "sportId": ids.sport, "code": "E-OK", "name": "合法枚举", "eventType": "individual_team", "resultMode": "manual", "resultType": "score", "compare": "desc", "status": "published", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error) } created := decodeData[map[string]any](t, resp) for field, want := range map[string]string{ "eventType": "individual_team", "resultMode": "manual", "resultType": "score", "compare": "desc", "status": "published", } { if created[field] != want { t.Fatalf("%s 应落库为 %q,得到 %v", field, want, created[field]) } } // 空值走建表默认值,不能报错。 status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{ "sportId": ids.sport, "code": "E-EMPTY", "name": "空枚举", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error) } got := decodeData[map[string]any](t, resp) for field, want := range map[string]string{ "eventType": "individual", "resultMode": "auto", "resultType": "none", "compare": "none", "status": "pending", } { if got[field] != want { t.Fatalf("空 %s 应默认 %q,得到 %v", field, want, got[field]) } } } func TestUpdateEventEnumValidation(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client := recorderClient(t, env) // 非法值在事务前就被拦下,不该碰库。 // 请求体带上必填字段:更新路径已与新建同口径(缺 sportId / code / name 先报「必填」), // 这条用例钉的是枚举的拦截点,别让它被更早的必填校验挡在前面。 status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{ "sportId": ids.sport, "code": "M100", "name": "100米跑", "eventType": "bogus", }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("更新非法 eventType 应 400,得到 %d %+v", status, resp.Error) } if got := detailOf(t, resp.Error.Detail); got != "bogus" { t.Fatalf("detail 应是原始值,得到 %q", got) } // 空值走默认值。 status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{ "sportId": ids.sport, "code": "M100", "name": "100米跑", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("更新空枚举应走默认值并 200,得到 %d %+v", status, resp.Error) } got := decodeData[map[string]any](t, resp) if got["eventType"] != "individual" || got["status"] != "pending" || got["resultMode"] != "auto" { t.Fatalf("更新空枚举应回落默认值,得到 %+v", got) } } func TestProfileEnumValidation(t *testing.T) { env := newTestEnv(t) client := recorderClient(t, env) for i, tc := range []struct{ field, value string }{ {"tieMode", "bogus"}, {"recordBonusMode", "bogus"}, } { status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ "code": fmt.Sprintf("P-BAD-%d", i), "name": "非法模板", tc.field: tc.value, }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error) } if !strings.Contains(resp.Error.Message, tc.field) { t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message) } if got := detailOf(t, resp.Error.Detail); got != tc.value { t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got) } } status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ "code": "P-OK", "name": "合法模板", "tieMode": "average", "recordBonusMode": "sum", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error) } created := decodeData[map[string]any](t, resp) if created["tieMode"] != "average" || created["recordBonusMode"] != "sum" { t.Fatalf("合法枚举应原样落库,得到 %+v", created) } profileID := int64(created["id"].(float64)) status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ "code": "P-EMPTY", "name": "空枚举", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error) } got := decodeData[map[string]any](t, resp) if got["tieMode"] != "skip" || got["recordBonusMode"] != "max" { t.Fatalf("空枚举应默认 skip / max,得到 %+v", got) } status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), map[string]any{ "code": "P-OK", "name": "合法模板", "tieMode": "nope", }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("更新非法 tieMode 应 400,得到 %d %+v", status, resp.Error) } } func TestResultStatusEnumValidation(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client := recorderClient(t, env) status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "results": []map[string]any{{"entryId": ids.entryA, "status": "bogus"}}, }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法 results[].status 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) } if !strings.Contains(resp.Error.Message, "status") { t.Fatalf("错误文案应带字段名,得到 %q", resp.Error.Message) } if got := detailOf(t, resp.Error.Detail); got != "bogus" { t.Fatalf("detail 应是原始值,得到 %q", got) } status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "results": []map[string]any{{"entryId": ids.entryA, "status": "dq"}}, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("合法 status 应 200,得到 %d %+v", status, resp.Error) } items := decodeData[[]map[string]any](t, resp) if len(items) != 1 || items[0]["status"] != "dq" { t.Fatalf("合法 status 应落库为 dq,得到 %+v", items) } // 空值走建表默认值 ok。 status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "results": []map[string]any{{"entryId": ids.entryA}}, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("空 status 应走默认值并 200,得到 %d %+v", status, resp.Error) } items = decodeData[[]map[string]any](t, resp) if len(items) != 1 || items[0]["status"] != "ok" { t.Fatalf("空 status 应默认 ok,得到 %+v", items) } } // 原本就有校验的枚举入参(通道 scope/basis、纪录 level、用户 role)一并钉住, // 确认它们在这次改动里没有被绕过、也仍然返回 400 而不是撞 CHECK 的 500。 func TestExistingEnumValidationGuards(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client := recorderClient(t, env) var profileID int64 if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles LIMIT 1`).Scan(&profileID); err != nil { t.Fatalf("读取积分模板: %v", err) } for _, ch := range []map[string]any{ {"scope": "bogus", "basis": "result", "profileId": profileID}, {"scope": "athlete", "basis": "bogus", "profileId": profileID}, } { status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event), map[string]any{"channels": []map[string]any{ch}}, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法通道 %+v 应 400 VALIDATION_FAILED,得到 %d %+v", ch, status, resp.Error) } field := "scope" if ch["basis"] == "bogus" { field = "basis" } if !strings.Contains(resp.Error.Message, field) { t.Fatalf("错误文案应带 %s,得到 %q", field, resp.Error.Message) } } status, resp := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{ "eventId": ids.event, "entryId": ids.entryA, "level": "bogus", }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法纪录 level 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) } if !strings.Contains(resp.Error.Message, "level") { t.Fatalf("错误文案应带 level,得到 %q", resp.Error.Message) } } func TestUserRoleEnumValidation(t *testing.T) { env := newTestEnv(t) env.createUser("admin", "admin-pass-1", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass-1") status, resp := env.do(client, http.MethodPost, "/api/users", map[string]any{ "username": "someone", "password": "password-1", "role": "bogus", }, true) if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { t.Fatalf("非法 role 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) } if !strings.Contains(resp.Error.Message, "role") { t.Fatalf("错误文案应带 role,得到 %q", resp.Error.Message) } } // 拦截点必须指向 enums.go 里的校验函数,而不是 response.go 的构造函数。 func TestEnumInterceptLogPointsAtEnumsValidator(t *testing.T) { env, buf := logEnv(t) ids := env.seed(t) client := recorderClient(t, env) status, _ := env.do(client, http.MethodPost, "/api/events", map[string]any{ "sportId": ids.sport, "code": "E-LOG", "name": "非法枚举", "eventType": "bogus", }, true) if status != http.StatusBadRequest { t.Fatalf("非法 eventType 应 400,得到 %d", status) } logs := waitLog(t, buf, "请求被拦截") if !strings.Contains(logs, `"origin":"internal/httpapi/enums.go:`) || !strings.Contains(logs, "normalizeEventType") { t.Fatalf("WARN 行应指出拦截点是 normalizeEventType,得到:\n%s", logs) } for _, forbidden := range []string{"response.go", "badRequest", "newAPIError", "/home/"} { if strings.Contains(logs, forbidden) { t.Fatalf("拦截点不该出现 %q,得到:\n%s", forbidden, logs) } } }