Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/leave_xsgz_submit_test.go
T
becomer 568329890b 回退 f1f79df:抓取线上前端源码证实数组下标确为方括号 fields[0]
上一版 f1f79df 把 xsgzFlatten 的数组下标从 fields[0] 改成 fields.0,
依据是函数注释/提交说明/「needCurlyBraces 语义」三处都写点号。
本次从 sdau 线上静态资源(无需登录)拿到真实前端源码,证明该判断错误。

证据(xsgz.sdau.edu.cn/assets/,免登录可取):
  index-B6fhXjS6.js      10.7MB 主包,含 1354 个 chunk 清单
  studentForm-Dx5Fpjfs.js       序列化与提交实现
  LeaveApplyDrawer-BeaVSGRQ.js  请假提交调用点

前端真实链路(LeaveApplyDrawer 的 P()):
  c = ee(表单值, 字段定义)
  L = ae(c, 字段定义)                      // fields 数组
  T = {projectId, requestType:"LEAVE", startTime, endTime,
       fields:L, address, longitude, latitude}
  f = le(T, ext)                          // le → Ne → v → pe,转 FormData
  await te(f)                             // POST application_submit (multipart)

键名规则(studentForm-Dx5Fpjfs.js 原文):
  function pe(e,t,a){return e? a? `${e}[${t}]` : `${e}.${t}` : t}
  v(): if(Array.isArray(s)) s.forEach((l,p)=>{
         const c=`${i}[${p}]`;            // 数组下标永远方括号,与 a 无关
         ... v(l,r,c,o)
       })
  Ne(e, t={}, a=false) → v(..., a=false)

即:对象键用点号,数组下标硬编码方括号。实际提交形如
fields[0].fieldId / fields[0].value。原实现正确,f1f79df 改错了。

另外查明:注释里长期引用的 toFormatFormData / needCurlyBraces
在整个 bundle 中并不存在(grep 计数为 0),是早期臆造的符号,
现已在注释中改为真实函数名 v()/pe()/Ne() 并附调用链。

改动:
- xsgzFlatten 数组分支还原为 "%s[%d]",注释给出源码依据;
- XsgzSubmitApplication 注释改为真实前端链路与规则;
- TestXsgzFlatten 期望还原为 fields[0].*;
- 契约测试期望同步为 fields[0].*,并加断言:括号需配对、
  不得出现 fields[0][fieldId] 这类二维下标形态。
2026-10-17 18:15:35 +08:00

301 lines
9.6 KiB
Go

package cpdaily
import (
"context"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-resty/resty/v2"
)
// 端到端契约回归:用假学工服务器接收真实请求,断言我们发出的 multipart 表单
// 与前端 studentForm-Dx5Fpjfs.js 的 v()/pe() 序列化 + application_submit 行为一致。
//
// 前端真实链路(LeaveApplyDrawer-BeaVSGRQ.js 的 P()):
//
// c = ee(l.value, r.value) // 表单值
// L = ae(c, r.value) // fields 数组
// T = {projectId, requestType:"LEAVE", startTime, endTime, fields:L, address, longitude, latitude}
// f = le(T, D.value) // → Ne→v→pe,转 FormData
// await te(f) // POST application_submit (multipart)
//
// 真实学校接口无法在未登录场景下验证,但请求体的**形状**是纯客户端产物,
// 可以用本地假服务器精确断言,覆盖「参数名、点号摊平、空值跳过、数组下标」。
// newXsgzTestSession 构造一个指向假服务器的会话。
// XsgzSession 的 baseURL/client 为非导出字段,测试同包可直接设置。
func newXsgzTestSession(t *testing.T, srv *httptest.Server) *XsgzSession {
t.Helper()
return &XsgzSession{
Token: "test-access-token",
RoleID: "role-123",
baseURL: srv.URL,
client: resty.New(),
}
}
// captureMultipart 记录一次提交请求的表单元数据与键值。
type captureMultipart struct {
contentType string
authHeader string
roleHeader string
path string
form map[string]string
// 出现顺序,用于确认 fields[] 按下标展开
keys []string
}
// readMultipart 把请求体解析为字符串键值(忽略文件部分)。
func (c *captureMultipart) readMultipart(t *testing.T, r *http.Request) {
t.Helper()
c.path = r.URL.Path
c.contentType = r.Header.Get("Content-Type")
c.authHeader = r.Header.Get("Authorization")
c.roleHeader = r.Header.Get("roleId")
c.form = map[string]string{}
mediaType, params, err := mime.ParseMediaType(c.contentType)
if err != nil {
t.Fatalf("解析 Content-Type 失败: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("Content-Type 应为 multipart/form-data,实际 %q", mediaType)
}
boundary := params["boundary"]
if boundary == "" {
t.Fatal("缺少 multipart boundary")
}
mr := multipart.NewReader(r.Body, boundary)
for {
part, err := mr.NextPart()
if err != nil {
break
}
name := part.FormName()
if name == "" {
continue // 文件部分
}
var sb strings.Builder
buf := make([]byte, 512)
for {
n, rerr := part.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if rerr != nil {
break
}
}
c.form[name] = sb.String()
c.keys = append(c.keys, name)
}
}
// TestXsgzSubmitLeaveMultipartContract 请假提交:断言表单键名与摊平规则。
func TestXsgzSubmitLeaveMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"ok"}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
fields := []map[string]interface{}{
{"fieldId": "f1", "value": "身体不适", "componentType": "TEXT"},
{"fieldId": "f2", "value": "回家休息", "componentType": "TEXTAREA"},
// 空值字段:前端 toFormatFormData 会跳过空字符串
{"fieldId": "f3", "value": "", "componentType": "TEXT"},
}
body, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-1",
StartTime: "2026-10-17 08:00:00",
EndTime: "2026-10-18 18:00:00",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
Fields: fields,
})
if err != nil {
t.Fatalf("提交失败: %v", err)
}
if !strings.Contains(string(body), `"code":0`) {
t.Fatalf("响应解析异常: %s", body)
}
// 路径与鉴权头
if cap.path != "/api/leave/student_action/application_submit" {
t.Fatalf("路径错误: %s", cap.path)
}
if cap.authHeader != "test-access-token" {
t.Fatalf("Authorization 头错误: %q", cap.authHeader)
}
if cap.roleHeader != "role-123" {
t.Fatalf("roleId 头错误: %q", cap.roleHeader)
}
// 顶层字段名必须与前端一致
wantTop := map[string]string{
"projectId": "proj-uuid-1",
"requestType": "LEAVE",
"startTime": "2026-10-17 08:00:00",
"endTime": "2026-10-18 18:00:00",
"address": "山东农业大学",
"longitude": "117.12",
"latitude": "36.19",
}
for k, want := range wantTop {
if got := cap.form[k]; got != want {
t.Fatalf("表单键 %q = %q, want %q", k, got, want)
}
}
// fields 必须是「数组下标方括号 + 对象键点号」:
// studentForm-Dx5Fpjfs.js: const c = `${i}[${p}]`(数组下标硬编码方括号)
// pe(e,t,a) → a? `${e}[${t}]` : `${e}.${t}`
if got := cap.form["fields[0].fieldId"]; got != "f1" {
t.Fatalf("fields[0].fieldId = %q, want f1", got)
}
if got := cap.form["fields[0].value"]; got != "身体不适" {
t.Fatalf("fields[0].value = %q", got)
}
if got := cap.form["fields[1].value"]; got != "回家休息" {
t.Fatalf("fields[1].value = %q", got)
}
// 空值字段的 value 不应出现
if _, ok := cap.form["fields[2].value"]; ok {
t.Fatal("空字符串 value 应被跳过,但出现了 fields[2].value")
}
// 但该字段的非空属性仍应提交
if got := cap.form["fields[2].fieldId"]; got != "f3" {
t.Fatalf("fields[2].fieldId = %q", got)
}
// 数组下标之后必须紧跟点号再接对象键,不能出现 fields[0][fieldId] 之类
for k := range cap.form {
if strings.Count(k, "[") != strings.Count(k, "]") {
t.Fatalf("键 %q 括号不配对", k)
}
if strings.Contains(k, "][") && !strings.HasPrefix(k, "fields[") {
t.Fatalf("键 %q 形如二维数组下标,前端不会这样提交", k)
}
}
// 请假请求不应带销假专有字段
if _, ok := cap.form["originalId"]; ok {
t.Fatal("请假不应携带 originalId")
}
if _, ok := cap.form["inArea"]; ok {
t.Fatal("请假不应携带 inArea")
}
}
// TestXsgzSubmitCancelMultipartContract 销假提交:断言 originalId 与在范围标记。
func TestXsgzSubmitCancelMultipartContract(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{
ProjectID: "proj-uuid-2",
OriginalID: "orig-apply-99",
Address: "山东农业大学",
Longitude: "117.12",
Latitude: "36.19",
InArea: true,
CancellationApplyInArea: true,
Fields: []map[string]interface{}{
{"fieldId": "c1", "value": "已返校"},
},
})
if err != nil {
t.Fatalf("销假提交失败: %v", err)
}
if got := cap.form["requestType"]; got != "CANCELLATION" {
t.Fatalf("requestType = %q, want CANCELLATION", got)
}
if got := cap.form["originalId"]; got != "orig-apply-99" {
t.Fatalf("originalId = %q, want orig-apply-99", got)
}
// bool 必须序列化为 true/false 字面量(前端 JS 行为)
if got := cap.form["inArea"]; got != "true" {
t.Fatalf("inArea = %q, want true", got)
}
if got := cap.form["cancellationApplyInArea"]; got != "true" {
t.Fatalf("cancellationApplyInArea = %q, want true", got)
}
// 销假不应带 startTime/endTime(未提供时)
if _, ok := cap.form["startTime"]; ok {
t.Fatal("销假未提供 startTime 时不应提交该键")
}
if got := cap.form["fields[0].value"]; got != "已返校" {
t.Fatalf("fields[0].value = %q", got)
}
}
// TestXsgzSubmitCancelRequiresOriginalID 销假缺少原单号时必须本地拦截。
func TestXsgzSubmitCancelRequiresOriginalID(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
_, err := sess.XsgzSubmitCancel(context.Background(), XsgzSubmitPayload{ProjectID: "p"})
if err == nil {
t.Fatal("缺少 originalId 应报错")
}
if !strings.Contains(err.Error(), "originalId") {
t.Fatalf("错误信息应提到 originalId,实际: %v", err)
}
if called {
t.Fatal("参数校验失败时不应发起请求")
}
}
// TestXsgzSubmitRequiresProjectID 请假缺少 projectId 时必须本地拦截。
func TestXsgzSubmitRequiresProjectID(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("不应发起请求")
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{Address: "x"}); err == nil {
t.Fatal("缺少 projectId 应报错")
}
}
// TestXsgzSubmitEmptyFieldsOmitted fields 为空时不应提交 fields 相关键。
func TestXsgzSubmitEmptyFieldsOmitted(t *testing.T) {
cap := &captureMultipart{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cap.readMultipart(t, r)
_, _ = w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
sess := newXsgzTestSession(t, srv)
if _, err := sess.XsgzSubmitLeave(context.Background(), XsgzSubmitPayload{
ProjectID: "p1", Address: "a", Longitude: "1", Latitude: "2",
}); err != nil {
t.Fatalf("提交失败: %v", err)
}
for k := range cap.form {
if strings.HasPrefix(k, "fields") {
t.Fatalf("fields 为空时不应提交 %q", k)
}
}
}