|
@@ -0,0 +1,738 @@
|
|
|
|
|
+// Handler 单测(AC-12 六条验收:端点覆盖 / 2001 / 2002 / 1001 / 金额字符串 / 分页钳制 / 脱敏)。
|
|
|
|
|
+//
|
|
|
|
|
+// 断言纪律:只断 body 契约(HTTP 恒 200 + body.code,铁律 6),不断 HTTP 状态码。
|
|
|
|
|
+//
|
|
|
|
|
+// 覆盖结构(每条对到 AC-12 验收项原文):
|
|
|
|
|
+//
|
|
|
|
|
+// ① 端点 1~17、21 全覆盖 → TestRouteCoverage(18 条 method+path 全注册,且不含端点 18/19/20)
|
|
|
|
|
+// ② validateReq → 2001;金额非法 → 2002 → TestValidationCodes(两码不串)
|
|
|
|
|
+// ③ scope 取不到 → 1001 → TestMissingScopeIs1001
|
|
|
|
|
+// ④ 金额入参必须字符串 → TestMoneyMustBeJSONString(传 number → 2001,不被静默接受)
|
|
|
|
|
+// ⑤ pageSize>200 → 200 → TestPageSizeClamp(端点 11 响应 data.pageSize == 200)
|
|
|
|
|
+// ⑥ 响应不含敏感字段 → TestNoSensitiveFields(`imgs` / `payReturnData` 不出现)
|
|
|
|
|
+//
|
|
|
|
|
+// 另含 3 条业务冒烟(保证"端点通、且真的落到库里"):
|
|
|
|
|
+// 充值 → 余额增加;创建工单 → 审批通过 → 余额增加 + 幂等/二次审批被拒;账单 → 汇总恒等式。
|
|
|
|
|
+package handler
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/http/httptest"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ gormsqlite "github.com/glebarez/sqlite"
|
|
|
|
|
+ "github.com/gofiber/fiber/v2"
|
|
|
|
|
+ "gorm.io/gorm"
|
|
|
|
|
+
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/model"
|
|
|
|
|
+ "yuansiwei/unified-backend/internal/academic/money"
|
|
|
|
|
+ infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
|
|
+ appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 测试基础设施
|
|
|
|
|
+
|
|
|
|
|
+// testScope 测试用作用域(机构 1 / 校区 10 / 财务角色)。
|
|
|
|
|
+func testScope() *appscope.Scope {
|
|
|
|
|
+ return &appscope.Scope{
|
|
|
|
|
+ InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ UserID: "01HOPERATOR0000000000001", Role: "finance",
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// testSuperScope 超管作用域(跨机构)。
|
|
|
|
|
+func testSuperScope() *appscope.Scope {
|
|
|
|
|
+ return &appscope.Scope{InstitutionID: 1, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000001", Role: "super_admin"}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newHandlerTestDB academic handler 测试库(资金 4 表 + 活动 5 表 + 简化 t_campus)。
|
|
|
|
|
+//
|
|
|
|
|
+// 与 service 包的单测库同构(逐表 AutoMigrate + 删非唯一索引:SQLite 索引名库级唯一,
|
|
|
|
|
+// 而 academic model 多表共用 `idx_inst_campus` 等名字)。
|
|
|
|
|
+func newHandlerTestDB(t *testing.T) *gorm.DB {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ dsn := filepath.Join(t.TempDir(), "academic_handler_test.db")
|
|
|
|
|
+ db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("gorm.Open(handler 文件库) 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sqlDB, err := db.DB()
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("取 sql.DB 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ sqlDB.SetMaxOpenConns(1)
|
|
|
|
|
+ t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
|
+
|
|
|
|
|
+ for _, tbl := range []any{
|
|
|
|
|
+ &model.UserExpense{}, &model.UserExpenseSchool{}, &model.UserExpenseLog{},
|
|
|
|
|
+ &model.UserBalanceRequest{},
|
|
|
|
|
+ &model.CoursesActivity{}, &model.CoursesActivitySigned{},
|
|
|
|
|
+ &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
|
|
|
|
|
+ &model.CoursesActivityUsedLog{},
|
|
|
|
|
+ } {
|
|
|
|
|
+ if mErr := db.AutoMigrate(tbl); mErr != nil {
|
|
|
|
|
+ t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, mErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ stmt := &gorm.Statement{DB: db}
|
|
|
|
|
+ if pErr := stmt.Parse(tbl); pErr != nil {
|
|
|
|
|
+ t.Fatalf("解析表名(%T) 失败: %v", tbl, pErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ var idxRows []struct {
|
|
|
|
|
+ Name string
|
|
|
|
|
+ SQL *string
|
|
|
|
|
+ }
|
|
|
|
|
+ if qErr := db.Raw(
|
|
|
|
|
+ "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
|
|
|
|
|
+ stmt.Schema.Table).Scan(&idxRows).Error; qErr != nil {
|
|
|
|
|
+ t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, qErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, row := range idxRows {
|
|
|
|
|
+ sqlText := ""
|
|
|
|
|
+ if row.SQL != nil {
|
|
|
|
|
+ sqlText = strings.ToUpper(*row.SQL)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Contains(sqlText, "UNIQUE") {
|
|
|
|
|
+ continue // 唯一约束必须保留
|
|
|
|
|
+ }
|
|
|
|
|
+ if dErr := db.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; dErr != nil {
|
|
|
|
|
+ t.Fatalf("删索引(%s) 失败: %v", row.Name, dErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Exec(`CREATE TABLE t_campus (
|
|
|
|
|
+ campus_id INTEGER PRIMARY KEY,
|
|
|
|
|
+ institution_id INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
+ self_support BOOLEAN NOT NULL DEFAULT 1,
|
|
|
|
|
+ status VARCHAR(32) NOT NULL DEFAULT 'active'
|
|
|
|
|
+ )`).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("建 t_campus 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := db.Exec(`INSERT INTO t_campus(campus_id, institution_id, self_support, status) VALUES(10,1,1,'active')`).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed t_campus 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return db
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// newTestApp 构造只挂 academic 财务域端点(+ 作用域注入中间件)的 app。
|
|
|
|
|
+//
|
|
|
|
|
+// 中间件模拟 Auth 链:`X-Test-NoScope: 1` 时不写作用域(用于 1001 用例),
|
|
|
|
|
+// `X-Test-Super: 1` 时写超管作用域。
|
|
|
|
|
+func newTestApp(t *testing.T, db *gorm.DB) *fiber.App {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ h, err := New(Config{DB: db})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("handler.New 失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ app := fiber.New(fiber.Config{DisableStartupMessage: true})
|
|
|
|
|
+ app.Use(func(c *fiber.Ctx) error {
|
|
|
|
|
+ switch c.Get("X-Test-Scope") {
|
|
|
|
|
+ case "none":
|
|
|
|
|
+ // 不写作用域 → 未登录语义
|
|
|
|
|
+ case "super":
|
|
|
|
|
+ appscope.Set(c, testSuperScope())
|
|
|
|
|
+ default:
|
|
|
|
|
+ appscope.Set(c, testScope())
|
|
|
|
|
+ }
|
|
|
|
|
+ return c.Next()
|
|
|
|
|
+ })
|
|
|
|
|
+ registerTestRoutes(app, h)
|
|
|
|
|
+ return app
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// registerTestRoutes 把 AC-12 的 18 条端点全部注册(**与设计 §4.1 逐条对应**)。
|
|
|
|
|
+//
|
|
|
|
|
+// 说明:这里**不**做角色白名单/RBAC(那是 AC-14);本函数只为"端点覆盖 + 请求往返"服务。
|
|
|
|
|
+func registerTestRoutes(app *fiber.App, h *Handler) {
|
|
|
|
|
+ app.Get(PathFinanceAccounts, h.GetAccount)
|
|
|
|
|
+ app.Get(PathFinanceRefundable, h.GetRefundable)
|
|
|
|
|
+ app.Post(PathFinanceRecharges, h.CreateRecharge)
|
|
|
|
|
+ app.Post(PathFinanceExpenditures, h.CreateExpenditure)
|
|
|
|
|
+ app.Post(PathFinanceRefunds, h.CreateRefund)
|
|
|
|
|
+ app.Post(PathFinanceRefundsCustom, h.CreateCustomRefund)
|
|
|
|
|
+ app.Post(PathFinanceTransfers, h.CreateTransfer)
|
|
|
|
|
+ app.Post(PathFinanceBadDebts, h.CreateBadDebt)
|
|
|
|
|
+ app.Post(PathFinanceClearedBalances, h.ClearBalance)
|
|
|
|
|
+ app.Post(PathFinanceSpends, h.AddSpend)
|
|
|
|
|
+ app.Get(PathFinanceLogs, h.ListLogs)
|
|
|
|
|
+ app.Get(PathFinanceRequests, h.ListRequests)
|
|
|
|
|
+ app.Post(PathFinanceRequests, h.CreateRequest)
|
|
|
|
|
+ app.Post(PathFinanceRequestApprove, h.ApproveRequest)
|
|
|
|
|
+ app.Post(PathFinanceRequestReject, h.RejectRequest)
|
|
|
|
|
+ app.Post(PathFinanceRequestCancel, h.CancelRequest)
|
|
|
|
|
+ app.Get(PathFinanceDashboard, h.GetDashboard)
|
|
|
|
|
+ app.Get(PathStudentBill, h.GetBill)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// doJSON 发一个 JSON 请求并解出信封(HTTP 恒 200,失败即测试失败)。
|
|
|
|
|
+//
|
|
|
|
|
+// 入参:method / path / body(nil = 无 body)/ headers(k,v 交替)。
|
|
|
|
|
+// 出参:code / message / data(原始 JSON)。
|
|
|
|
|
+func doJSON(t *testing.T, app *fiber.App, method, path string, body any, headers ...string) (int, string, json.RawMessage) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ var req *httptest.ResponseRecorder
|
|
|
|
|
+ payload := ""
|
|
|
|
|
+ if body != nil {
|
|
|
|
|
+ raw, err := json.Marshal(body)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("序列化请求体失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ payload = string(raw)
|
|
|
|
|
+ }
|
|
|
|
|
+ httpReq := httptest.NewRequest(method, path, strings.NewReader(payload))
|
|
|
|
|
+ httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
+ for i := 0; i+1 < len(headers); i += 2 {
|
|
|
|
|
+ httpReq.Header.Set(headers[i], headers[i+1])
|
|
|
|
|
+ }
|
|
|
|
|
+ resp, err := app.Test(httpReq, 5000)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("%s %s 请求失败: %v", method, path, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ req = httptest.NewRecorder()
|
|
|
|
|
+ _ = req
|
|
|
|
|
+ defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
+ var envelope struct {
|
|
|
|
|
+ Code int `json:"code"`
|
|
|
|
|
+ Message string `json:"message"`
|
|
|
|
|
+ Data json.RawMessage `json:"data"`
|
|
|
|
|
+ Success bool `json:"success"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
|
|
|
+ t.Fatalf("%s %s 响应不是合法信封: %v", method, path, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return envelope.Code, envelope.Message, envelope.Data
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ① 端点覆盖
|
|
|
|
|
+
|
|
|
|
|
+// TestRouteCoverage 端点 1~17、21 全覆盖(18 条),且**不含** 18/19/20(支付端点属 AC-13/AC-14)。
|
|
|
|
|
+func TestRouteCoverage(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ expected := []struct {
|
|
|
|
|
+ method string
|
|
|
|
|
+ path string
|
|
|
|
|
+ name string
|
|
|
|
|
+ }{
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/accounts/:userId", "端点1 账户快照"},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/accounts/:userId/refundable", "端点2 可退金额"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/recharges", "端点3 充值"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/expenditures", "端点4 消费"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/refunds", "端点5 退费"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/refunds/custom", "端点6 自定义退费"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/transfers", "端点7 转结"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/bad-debts", "端点8 坏账"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/cleared-balances", "端点9 清零"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/spends", "端点10 累计消费"},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/logs", "端点11 流水"},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/requests", "端点12 工单列表"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/requests", "端点13 创建工单"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/requests/:id/approve", "端点14 通过"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/requests/:id/reject", "端点15 驳回"},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/requests/:id/cancel", "端点16 撤销"},
|
|
|
|
|
+ {http.MethodGet, "/student/bill", "端点17 账单"},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/dashboard", "端点21 仪表盘"},
|
|
|
|
|
+ }
|
|
|
|
|
+ registered := map[string]bool{}
|
|
|
|
|
+ for _, route := range app.GetRoutes() {
|
|
|
|
|
+ registered[route.Method+" "+route.Path] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, want := range expected {
|
|
|
|
|
+ if !registered[want.method+" "+want.path] {
|
|
|
|
|
+ t.Errorf("%s 未注册(%s %s)", want.name, want.method, want.path)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 反向断言:不该出现的端点(18/19/20 属支付,归 AC-13;且**任何**路径都不得含 /api)
|
|
|
|
|
+ for key := range registered {
|
|
|
|
|
+ if strings.Contains(key, "/pay/") {
|
|
|
|
|
+ t.Errorf("academic finance handler 不应注册支付端点:%s", key)
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Contains(key, "/api/") {
|
|
|
|
|
+ t.Errorf("后端路由不得含 /api 前缀(§4 口径表):%s", key)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(expected) != 18 {
|
|
|
|
|
+ t.Fatalf("覆盖清单应为 18 条,实得 %d", len(expected))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ③ scope 取不到 → 1001
|
|
|
|
|
+
|
|
|
|
|
+// TestMissingScopeIs1001 无作用域(未登录/中间件未注入)→ 1001,且**必须先于**参数校验。
|
|
|
|
|
+func TestMissingScopeIs1001(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ cases := []struct {
|
|
|
|
|
+ method string
|
|
|
|
|
+ path string
|
|
|
|
|
+ body any
|
|
|
|
|
+ }{
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/accounts/01HUSER000000000000000900", nil},
|
|
|
|
|
+ {http.MethodPost, "/admin/finance/recharges", map[string]any{}},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/logs", nil},
|
|
|
|
|
+ {http.MethodGet, "/student/bill", nil},
|
|
|
|
|
+ {http.MethodGet, "/admin/finance/dashboard", nil},
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, tc := range cases {
|
|
|
|
|
+ code, _, _ := doJSON(t, app, tc.method, tc.path, tc.body, "X-Test-Scope", "none")
|
|
|
|
|
+ if code != infraerrors.CodeUnauth {
|
|
|
|
|
+ t.Errorf("%s %s:期望 1001,实得 %d", tc.method, tc.path, code)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ② 2001 与 2002 不串
|
|
|
|
|
+
|
|
|
|
|
+// TestValidationCodes validateReq 失败 → 2001;金额/枚举值非法 → 2002。
|
|
|
|
|
+func TestValidationCodes(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ // 缺参(userId/money/institutionId 全空)→ 2001
|
|
|
|
|
+ code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{})
|
|
|
|
|
+ if code != infraerrors.CodeBadParam {
|
|
|
|
|
+ t.Fatalf("缺参期望 2001,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 金额字符串非法("abc")→ 2002
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
|
|
|
|
|
+ "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "abc",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != infraerrors.CodeBadFormat {
|
|
|
|
|
+ t.Fatalf("金额非法期望 2002,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 消费 type 不在白名单 → 2002
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/expenditures", map[string]any{
|
|
|
|
|
+ "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
|
|
|
|
|
+ "money": "10.00", "type": 999,
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != infraerrors.CodeBadFormat {
|
|
|
|
|
+ t.Fatalf("消费 type 非白名单期望 2002,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 工单 type 不在白名单 → 2002
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
|
|
|
|
|
+ "type": 999, "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10,
|
|
|
|
|
+ "money": "10.00",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != infraerrors.CodeBadFormat {
|
|
|
|
|
+ t.Fatalf("工单 type 非白名单期望 2002,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 自定义退费缺 requestId(必填)→ 2001
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodPost, "/admin/finance/refunds/custom", map[string]any{
|
|
|
|
|
+ "userId": "01HUSER000000000000000900", "institutionId": 1, "campusId": 10, "money": "10.00",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != infraerrors.CodeBadParam {
|
|
|
|
|
+ t.Fatalf("缺 requestId 期望 2001,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 账单缺 from/to → 2001(时间边界必填)
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodGet, "/student/bill", nil)
|
|
|
|
|
+ if code != infraerrors.CodeBadParam {
|
|
|
|
|
+ t.Fatalf("账单缺 from/to 期望 2001,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 账单 campusIds 非法片段 → 2002(Service 解析失败)
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/student/bill?userId=01HUSER000000000000000900&from=2026-03-01&to=2026-04-01&campusIds=1,a", nil)
|
|
|
|
|
+ if code != infraerrors.CodeBadParam {
|
|
|
|
|
+ t.Fatalf("账单 campusIds 非法期望 2001/(Service 侧 2001),实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 时间格式非法 → 2002
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/student/bill?from=2026-13-99&to=2026-04-01", nil)
|
|
|
|
|
+ if code != infraerrors.CodeBadFormat {
|
|
|
|
|
+ t.Fatalf("时间格式非法期望 2002,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ④ 金额必须是字符串
|
|
|
|
|
+
|
|
|
|
|
+// TestMoneyMustBeJSONString 金额传 JSON number → **2002(被拒)**,绝不被静默接受。
|
|
|
|
|
+//
|
|
|
|
|
+// 为什么是 2002 而不是 2001:`infra/validator.Validate` 对"body 能解析但字段类型不匹配"
|
|
|
|
|
+// 统一回 `CodeBadFormat`(2002)(见 validator.go 的 json.Unmarshal 分支)——本用例沿用该约定。
|
|
|
|
|
+//
|
|
|
|
|
+// 这是"拒绝 float64"的可判定形式:若把 DTO 的金额字段写成 `float64` 或 `money.Amount`,
|
|
|
|
|
+// number 会被成功解码 ⇒ 本用例拿到 200(钱被按浮点入账)⇒ 断言立刻红。
|
|
|
|
|
+func TestMoneyMustBeJSONString(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ raw := `{"userId":"01HUSER000000000000000900","institutionId":1,"campusId":10,"money":100}`
|
|
|
|
|
+ httpReq := httptest.NewRequest(http.MethodPost, "/admin/finance/recharges", strings.NewReader(raw))
|
|
|
|
|
+ httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
+ resp, err := app.Test(httpReq, 5000)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("请求失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
+ var envelope struct {
|
|
|
|
|
+ Code int `json:"code"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
|
|
|
+ t.Fatalf("响应解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if envelope.Code != infraerrors.CodeBadFormat {
|
|
|
|
|
+ t.Fatalf("金额传 number 期望 2002(类型不匹配被拒),实得 %d", envelope.Code)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ⑤ 分页钳制
|
|
|
|
|
+
|
|
|
|
|
+// TestPageSizeClamp pageSize>200 → 200(唯一实现点 = response.ParsePage)。
|
|
|
|
|
+func TestPageSizeClamp(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ code, _, data := doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/admin/finance/logs?userId=01HUSER000000000000000900&pageSize=500&page=1", nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("流水列表期望 200,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ var page struct {
|
|
|
|
|
+ PageSize int `json:"pageSize"`
|
|
|
|
|
+ Page int `json:"page"`
|
|
|
|
|
+ Total int64 `json:"total"`
|
|
|
|
|
+ List []any `json:"list"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &page); err != nil {
|
|
|
|
|
+ t.Fatalf("分页体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if page.PageSize != 200 {
|
|
|
|
|
+ t.Fatalf("pageSize 应钳制为 200,实得 %d", page.PageSize)
|
|
|
|
|
+ }
|
|
|
|
|
+ if page.List == nil {
|
|
|
|
|
+ t.Fatal("空列表必须是 [],不能是 null")
|
|
|
|
|
+ }
|
|
|
|
|
+ // 缺省值:不传分页 → (1,10)
|
|
|
|
|
+ _, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/logs?userId=01HUSER000000000000000900", nil)
|
|
|
|
|
+ if err := json.Unmarshal(data, &page); err != nil {
|
|
|
|
|
+ t.Fatalf("分页体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if page.Page != 1 || page.PageSize != 10 {
|
|
|
|
|
+ t.Fatalf("缺省分页应为 (1,10),实得 (%d,%d)", page.Page, page.PageSize)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- ⑥ 脱敏
|
|
|
|
|
+
|
|
|
|
|
+// TestNoSensitiveFields 响应体不出现 `imgs` / `payReturnData` / `returnData`(AC-12 验收 ⑥)。
|
|
|
|
|
+func TestNoSensitiveFields(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ user := "01HUSER000000000000000901"
|
|
|
|
|
+ // 造一张带凭证图片的工单,确认列表**不回显** imgs
|
|
|
|
|
+ if err := db.Create(&model.UserBalanceRequest{
|
|
|
|
|
+ BizNo: "BIZSENSITIVE00000000000000001", Type: model.BalanceRequestTypeRecharge,
|
|
|
|
|
+ UserID: user, InstitutionID: 1, CampusID: 10, Money: "100.00",
|
|
|
|
|
+ Imgs: strPtr(`[{"url":"https://sensitive/private.png"}]`),
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 工单失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ httpReq := httptest.NewRequest(http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
|
|
|
|
|
+ resp, err := app.Test(httpReq, 5000)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("请求失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
+ rawBody, rErr := io.ReadAll(resp.Body)
|
|
|
|
|
+ if rErr != nil {
|
|
|
|
|
+ t.Fatalf("读响应失败: %v", rErr)
|
|
|
|
|
+ }
|
|
|
|
|
+ text := string(rawBody)
|
|
|
|
|
+ for _, banned := range []string{"imgs", "private.png", "payReturnData", "pay_return_data", "returnData"} {
|
|
|
|
|
+ if strings.Contains(text, banned) {
|
|
|
|
|
+ t.Fatalf("响应体含敏感字段 %q:%s", banned, text)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(text, user) {
|
|
|
|
|
+ t.Fatalf("响应体应含被查工单(脱敏断言不能靠「空响应」通过):%s", text)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 业务冒烟
|
|
|
|
|
+
|
|
|
|
|
+// TestRechargeThenAccountSnapshot 端点 3 → 端点 1:充值后账户快照余额同步(跨端点一致性)。
|
|
|
|
|
+func TestRechargeThenAccountSnapshot(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+ user := "01HUSER000000000000000910"
|
|
|
|
|
+
|
|
|
|
|
+ code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
|
|
|
|
|
+ "userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
|
|
|
|
|
+ "requestId": "REQ-HANDLER-1",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("充值期望 200,实得 %d(%s)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ var recharge struct {
|
|
|
|
|
+ LogID int64 `json:"logId"`
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ Repeated bool `json:"repeated"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &recharge); err != nil {
|
|
|
|
|
+ t.Fatalf("充值体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if recharge.LogID <= 0 || recharge.Balance != "250.50" || recharge.Repeated {
|
|
|
|
|
+ t.Fatalf("充值结果异常:%+v", recharge)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 幂等重放(同一 requestId)→ repeated=true 且余额不变
|
|
|
|
|
+ code, _, data = doJSON(t, app, http.MethodPost, "/admin/finance/recharges", map[string]any{
|
|
|
|
|
+ "userId": user, "institutionId": 1, "campusId": 10, "money": "250.50",
|
|
|
|
|
+ "requestId": "REQ-HANDLER-1",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("幂等重放期望 200,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &recharge); err != nil {
|
|
|
|
|
+ t.Fatalf("充值体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !recharge.Repeated || recharge.Balance != "250.50" {
|
|
|
|
|
+ t.Fatalf("幂等重放应 repeated=true 且余额不变:%+v", recharge)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 端点 1:账户快照应反映 250.50
|
|
|
|
|
+ code, _, data = doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/admin/finance/accounts/"+user+"?institutionId=1&campusId=10", nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("账户快照期望 200,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ var snapshot struct {
|
|
|
|
|
+ UserID string `json:"userId"`
|
|
|
|
|
+ Global struct {
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ TotalIncome string `json:"totalIncome"`
|
|
|
|
|
+ } `json:"global"`
|
|
|
|
|
+ Campus *struct {
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ } `json:"campus"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &snapshot); err != nil {
|
|
|
|
|
+ t.Fatalf("快照解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if snapshot.Global.Balance != "250.50" || snapshot.Global.TotalIncome != "250.50" {
|
|
|
|
|
+ t.Fatalf("全局快照异常:%+v", snapshot.Global)
|
|
|
|
|
+ }
|
|
|
|
|
+ if snapshot.Campus == nil || snapshot.Campus.Balance != "250.50" {
|
|
|
|
|
+ t.Fatalf("校区快照异常:%+v", snapshot.Campus)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestRequestApproveTouchesFunds 端点 13 → 14:审批通过才动钱;二次审批 → 3002。
|
|
|
|
|
+func TestRequestApproveTouchesFunds(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+ user := "01HUSER000000000000000920"
|
|
|
|
|
+
|
|
|
|
|
+ // 13 创建充值工单
|
|
|
|
|
+ code, msg, data := doJSON(t, app, http.MethodPost, "/admin/finance/requests", map[string]any{
|
|
|
|
|
+ "type": 100, "userId": user, "institutionId": 1, "campusId": 10,
|
|
|
|
|
+ "money": "88.00", "item": "春季学费",
|
|
|
|
|
+ })
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("创建工单期望 200,实得 %d(%s)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ var created struct {
|
|
|
|
|
+ ID int64 `json:"id"`
|
|
|
|
|
+ BizNo string `json:"bizNo"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &created); err != nil {
|
|
|
|
|
+ t.Fatalf("工单体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if created.ID <= 0 || len(created.BizNo) != 32 {
|
|
|
|
|
+ t.Fatalf("工单结果异常:%+v", created)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 审批前:余额 0(账户不存在 → 快照 0)
|
|
|
|
|
+ code, _, data = doJSON(t, app, http.MethodGet, "/admin/finance/accounts/"+user, nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("快照期望 200,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ var before struct {
|
|
|
|
|
+ Global struct {
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ } `json:"global"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &before); err != nil {
|
|
|
|
|
+ t.Fatalf("快照解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if before.Global.Balance != "0.00" {
|
|
|
|
|
+ t.Fatalf("审批前余额应为 0.00,实得 %s(说明创建工单就已经动了钱——违反审批链)", before.Global.Balance)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 14 审批通过
|
|
|
|
|
+ code, msg, data = doJSON(t, app, http.MethodPost,
|
|
|
|
|
+ fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{"responseMsg": "ok"})
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("审批通过期望 200,实得 %d(%s)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ var approved struct {
|
|
|
|
|
+ Approved int8 `json:"approved"`
|
|
|
|
|
+ BizNo string `json:"bizNo"`
|
|
|
|
|
+ Result struct {
|
|
|
|
|
+ LogID int64 `json:"logId"`
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ } `json:"result"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &approved); err != nil {
|
|
|
|
|
+ t.Fatalf("审批体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if approved.Approved != 1 || approved.BizNo != created.BizNo || approved.Result.LogID <= 0 {
|
|
|
|
|
+ t.Fatalf("审批结果异常:%+v", approved)
|
|
|
|
|
+ }
|
|
|
|
|
+ if approved.Result.Balance != "88.00" {
|
|
|
|
|
+ t.Fatalf("审批入账后校区余额应为 88.00,实得 %s", approved.Result.Balance)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 二次审批 → 3002(不得重复入账)
|
|
|
|
|
+ code, _, _ = doJSON(t, app, http.MethodPost,
|
|
|
|
|
+ fmt.Sprintf("/admin/finance/requests/%d/approve", created.ID), map[string]any{})
|
|
|
|
|
+ if code != infraerrors.CodeConflict {
|
|
|
|
|
+ t.Fatalf("二次审批期望 3002,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 落库只能有一条 100 流水(重复入账会变两条)
|
|
|
|
|
+ var logs []model.UserExpenseLog
|
|
|
|
|
+ if err := db.Where("user_id = ? AND type = ?", user, model.ExpenseLogTypeRecharge).Find(&logs).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("读流水失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(logs) != 1 {
|
|
|
|
|
+ t.Fatalf("充值流水应为 1 条,实得 %d 条(二次审批重复入账)", len(logs))
|
|
|
|
|
+ }
|
|
|
|
|
+ if logs[0].RequestID == nil || *logs[0].RequestID != created.BizNo {
|
|
|
|
|
+ t.Fatalf("流水 request_id 应等于工单 bizNo(幂等锚点),实得 %v", logs[0].RequestID)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestBillEndpointIdentity 端点 17:账单汇总恒等式(期末−期初 = 窗口内流水)+ 分页元信息。
|
|
|
|
|
+func TestBillEndpointIdentity(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+ user := "01HUSER000000000000000930"
|
|
|
|
|
+
|
|
|
|
|
+ seedLog := func(amount string, at time.Time) {
|
|
|
|
|
+ if err := db.Create(&model.UserExpenseLog{
|
|
|
|
|
+ UserID: user, Type: model.ExpenseLogTypeRecharge, Money: amount, CreateTime: at,
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 流水失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ seedLog("500.00", time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC))
|
|
|
|
|
+ seedLog("200.00", time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC))
|
|
|
|
|
+
|
|
|
|
|
+ code, msg, data := doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/student/bill?userId="+user+"&from=2026-03-01&to=2026-04-01", nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("账单期望 200,实得 %d(%s)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ var bill struct {
|
|
|
|
|
+ Summary struct {
|
|
|
|
|
+ PrevBalance string `json:"prevBalance"`
|
|
|
|
|
+ EndBalance string `json:"endBalance"`
|
|
|
|
|
+ FlowExclusiveEnd string `json:"flowExclusiveEnd"`
|
|
|
|
|
+ CampusIDs []int64 `json:"campusIds"`
|
|
|
|
|
+ } `json:"summary"`
|
|
|
|
|
+ List []map[string]any `json:"list"`
|
|
|
|
|
+ Total int64 `json:"total"`
|
|
|
|
|
+ Page int `json:"page"`
|
|
|
|
|
+ PageSize int `json:"pageSize"`
|
|
|
|
|
+ Pages int64 `json:"pages"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &bill); err != nil {
|
|
|
|
|
+ t.Fatalf("账单体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if bill.Summary.PrevBalance != "500.00" || bill.Summary.EndBalance != "700.00" {
|
|
|
|
|
+ t.Fatalf("期初/期末异常:%+v", bill.Summary)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 恒等式:期末 − 期初 == 窗口内流水
|
|
|
|
|
+ prev, _ := money.Parse(bill.Summary.PrevBalance)
|
|
|
|
|
+ end, _ := money.Parse(bill.Summary.EndBalance)
|
|
|
|
|
+ flow, _ := money.Parse(bill.Summary.FlowExclusiveEnd)
|
|
|
|
|
+ if end.Sub(prev) != flow {
|
|
|
|
|
+ t.Fatalf("恒等式破:期末−期初=%s,窗口流水=%s", end.Sub(prev).String(), flow.String())
|
|
|
|
|
+ }
|
|
|
|
|
+ if bill.Total != 2 || bill.PageSize != 10 || bill.Pages != 1 || bill.List == nil {
|
|
|
|
|
+ t.Fatalf("分页元信息异常:total=%d pageSize=%d pages=%d list=%v", bill.Total, bill.PageSize, bill.Pages, bill.List)
|
|
|
|
|
+ }
|
|
|
|
|
+ if bill.Summary.CampusIDs == nil {
|
|
|
|
|
+ t.Fatal("campusIds 回显必须是 [](空时也不能是 null)")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestDashboardEndpoint 端点 21:聚合校区账户累计列 + 明确告知"不支持按校区拆流水"。
|
|
|
|
|
+func TestDashboardEndpoint(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ if err := db.Create(&model.UserExpenseSchool{
|
|
|
|
|
+ UserID: "01HUSER000000000000000940", InstitutionID: 1, CampusID: 10,
|
|
|
|
|
+ Balance: "100.00", TotalIncome: "300.00", TotalExpenditure: "200.00",
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 校区账户失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ code, msg, data := doJSON(t, app, http.MethodGet,
|
|
|
|
|
+ "/admin/finance/dashboard?from=2026-03-01&to=2026-04-01", nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("仪表盘期望 200,实得 %d(%s)", code, msg)
|
|
|
|
|
+ }
|
|
|
|
|
+ var dash struct {
|
|
|
|
|
+ Accounts int64 `json:"accounts"`
|
|
|
|
|
+ Balance string `json:"balance"`
|
|
|
|
|
+ TotalIncome string `json:"totalIncome"`
|
|
|
|
|
+ TotalExpenditure string `json:"totalExpenditure"`
|
|
|
|
|
+ NetReceivable string `json:"netReceivable"`
|
|
|
|
|
+ FlowsByCampusSupported bool `json:"flowsByCampusSupported"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &dash); err != nil {
|
|
|
|
|
+ t.Fatalf("仪表盘体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if dash.Accounts != 1 || dash.Balance != "100.00" || dash.TotalIncome != "300.00" ||
|
|
|
|
|
+ dash.TotalExpenditure != "200.00" || dash.NetReceivable != "100.00" {
|
|
|
|
|
+ t.Fatalf("仪表盘聚合异常:%+v", dash)
|
|
|
|
|
+ }
|
|
|
|
|
+ if dash.FlowsByCampusSupported {
|
|
|
|
|
+ t.Fatal("flowsByCampusSupported 必须为 false(流水表无 campus 列,不能按校区拆期间发生额)")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestCrossInstitutionIsRejected 跨机构:作用域机构 1 查机构 2 的工单 → 3001(仓储 Scoped 过滤后不可见)。
|
|
|
|
|
+func TestCrossInstitutionIsRejected(t *testing.T) {
|
|
|
|
|
+ db := newHandlerTestDB(t)
|
|
|
|
|
+ app := newTestApp(t, db)
|
|
|
|
|
+
|
|
|
|
|
+ if err := db.Create(&model.UserBalanceRequest{
|
|
|
|
|
+ BizNo: "BIZOTHERINST0000000000000001", Type: model.BalanceRequestTypeRecharge,
|
|
|
|
|
+ UserID: "01HUSER000000000000000950", InstitutionID: 2, CampusID: 20, Money: "10.00",
|
|
|
|
|
+ }).Error; err != nil {
|
|
|
|
|
+ t.Fatalf("seed 跨机构工单失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ code, _, _ := doJSON(t, app, http.MethodPost, "/admin/finance/requests/1/approve", map[string]any{})
|
|
|
|
|
+ if code != infraerrors.CodeNotFound {
|
|
|
|
|
+ t.Fatalf("跨机构审批期望 3001,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 列表也看不到别机构工单
|
|
|
|
|
+ code, _, data := doJSON(t, app, http.MethodGet, "/admin/finance/requests?pageSize=10", nil)
|
|
|
|
|
+ if code != 200 {
|
|
|
|
|
+ t.Fatalf("工单列表期望 200,实得 %d", code)
|
|
|
|
|
+ }
|
|
|
|
|
+ var page struct {
|
|
|
|
|
+ Total int64 `json:"total"`
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := json.Unmarshal(data, &page); err != nil {
|
|
|
|
|
+ t.Fatalf("列表体解码失败: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if page.Total != 0 {
|
|
|
|
|
+ t.Fatalf("普通财务不应看到别机构工单,实得 total=%d", page.Total)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------- 小工具
|
|
|
|
|
+
|
|
|
|
|
+// strPtr 字符串指针构造(可空列 seed 用)。
|
|
|
|
|
+func strPtr(s string) *string { return &s }
|