expenditure_handler_test.go 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. // 收支端点单测(端点 60:记录列表 / 记账 / 类型字典)——AC-13d 第三批。
  2. //
  3. // 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。
  4. //
  5. // 覆盖:
  6. //
  7. // 60 GET 记录 平台通用字典(institution_id=0)**必须**补齐 type/name;契约删掉的
  8. // `direction`/`userId` **不得**出现;`money` 恒 2 位;`createUserId` 可空;
  9. // `type` 过滤不得漏掉"平台字典类型的记录"
  10. // 60 POST 记账 写库列(institution/campus/cost/记账人/bizNo);year/month 与 create_time 同月;
  11. // 金额 ≤0 → 2002;类型不存在 / 他机构私有类型 → 3001
  12. // 60 GET types 平台字典 + 本机构字典都在;他机构私有类型不在;超管不指定机构只看到平台字典
  13. // 作用域 跨机构/跨校区一律 **1004**(不是"空列表")
  14. package handler
  15. import (
  16. "strings"
  17. "testing"
  18. "time"
  19. "github.com/gofiber/fiber/v2"
  20. "gorm.io/gorm"
  21. "yuansiwei/unified-backend/internal/academic/model"
  22. "yuansiwei/unified-backend/internal/academic/money"
  23. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  24. appscope "yuansiwei/unified-backend/internal/infra/scope"
  25. )
  26. // newExpenditureTestDB 端点 60 测试库(资金/活动基座 + 收支 2 表)。
  27. //
  28. // 索引处理与 `newCourseTestDB` 同源:SQLite 的索引名是**库级唯一**(MySQL 是表级),
  29. // `uk_biz_no` 在多张表上同名,不清会直接建表失败。**测试库放弃唯一约束,生产 MySQL 不受影响**。
  30. func newExpenditureTestDB(t *testing.T) *gorm.DB {
  31. t.Helper()
  32. base := newHandlerTestDB(t)
  33. extra := []any{&model.Expenditure{}, &model.ExpenditureLog{}}
  34. var leftovers []string
  35. if err := base.Raw(
  36. "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'").Scan(&leftovers).Error; err != nil {
  37. t.Fatalf("读取遗留索引失败: %v", err)
  38. }
  39. for _, name := range leftovers {
  40. if err := base.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
  41. t.Fatalf("删除遗留索引(%s) 失败: %v", name, err)
  42. }
  43. }
  44. for _, tbl := range extra {
  45. if err := base.AutoMigrate(tbl); err != nil {
  46. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  47. }
  48. stmt := &gorm.Statement{DB: base}
  49. if err := stmt.Parse(tbl); err != nil {
  50. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  51. }
  52. var idxRows []struct {
  53. Name string
  54. SQL *string
  55. }
  56. if err := base.Raw(
  57. "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
  58. stmt.Schema.Table).Scan(&idxRows).Error; err != nil {
  59. t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  60. }
  61. for _, row := range idxRows {
  62. if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
  63. t.Fatalf("删索引(%s) 失败: %v", row.Name, err)
  64. }
  65. }
  66. }
  67. return base
  68. }
  69. // newExpenditureTestApp 挂端点 60 的 3 条路由(**不做角色白名单**:那是 e2e 的职责)。
  70. //
  71. // 作用域由 `X-Test-Scope` 头选择:
  72. //
  73. // finance(缺省)机构 1 / 校区 10;finance-noCampus 机构 1 / 校区 0;
  74. // finance-other 机构 2 / 校区 20;super 超管。
  75. func newExpenditureTestApp(t *testing.T, db *gorm.DB) *fiber.App {
  76. t.Helper()
  77. h, err := New(Config{DB: db})
  78. if err != nil {
  79. t.Fatalf("handler.New 失败: %v", err)
  80. }
  81. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  82. app.Use(func(c *fiber.Ctx) error {
  83. switch c.Get("X-Test-Scope") {
  84. case "none":
  85. // 无作用域 → 1001
  86. case "finance-noCampus":
  87. appscope.Set(c, &appscope.Scope{
  88. InstitutionID: 1, CampusID: 0, UserID: testFinanceUser, Role: RoleFinance,
  89. })
  90. case "finance-other":
  91. appscope.Set(c, &appscope.Scope{
  92. InstitutionID: 2, CampusID: 20, UserID: "01HFINANCE0000000000000002", Role: RoleFinance,
  93. })
  94. case "super":
  95. appscope.Set(c, testSuperScope())
  96. case "super-noInst":
  97. // 平台超管但**没有机构上下文**(InstitutionID=0):字典只看平台通用那批、
  98. // 记账必须显式给 institutionId(服务端不猜)。
  99. appscope.Set(c, &appscope.Scope{
  100. InstitutionID: 0, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000002",
  101. Role: RoleSuperAdmin,
  102. })
  103. default:
  104. appscope.Set(c, &appscope.Scope{
  105. InstitutionID: 1, CampusID: 10, UserID: testFinanceUser, Role: RoleFinance,
  106. })
  107. }
  108. return c.Next()
  109. })
  110. app.Get(PathExpenditureTypes, h.ListExpenditureTypes)
  111. app.Get(PathExpenditures, h.ListExpenditureLogs)
  112. app.Post(PathExpenditures, h.CreateExpenditureLog)
  113. return app
  114. }
  115. // parseCostCents 把库里的 `cost` 字符串解析成"分"(跨方言比较用)。
  116. //
  117. // 入参:text 库值(SQLite 可能回 "100"、MySQL 回 "100.00")。出参:(分, error)。
  118. //
  119. // 为什么不直接比字符串:SQLite 的 numeric 亲和性会吃掉尾部 0,断言字面量必红;
  120. // 而"按数值比"并不放松口径——真正的 2 位小数断言在**响应**上(`money` 由 `money.Amount.String()`
  121. // 产出,与方言无关)。两处各测各的:库里存得对、出口格式化得对。
  122. func parseCostCents(text string) (int64, error) {
  123. amount, err := money.Parse(strings.TrimSpace(text))
  124. if err != nil {
  125. return 0, err
  126. }
  127. return amount.Cents(), nil
  128. }
  129. // testFinanceUser 端点 60 测试用的记账人(财务,机构 1)。
  130. const testFinanceUser = "01HFINANCE0000000000000001"
  131. // seedExpenditureType 造一条收支字典(institutionID = 0 即**平台通用字典**)。
  132. func seedExpenditureType(t *testing.T, db *gorm.DB, institutionID int64, typ int32, name string) *model.Expenditure {
  133. t.Helper()
  134. row := &model.Expenditure{InstitutionID: institutionID, Type: typ, Name: name, ParentID: 0}
  135. if err := db.Create(row).Error; err != nil {
  136. t.Fatalf("造收支字典(%s) 失败: %v", name, err)
  137. }
  138. return row
  139. }
  140. // TestExpenditureCreateAndListUsesPlatformDictionary 端点 60 主链路:记账 + 列表(字典补齐)。
  141. //
  142. // 这条用例是整个端点的防线,**四个断言各自对应一个真实可犯的错**:
  143. //
  144. // ① `name == "房租"` / `type == 1`:字典是**平台通用**(`institution_id = 0`)。
  145. // 若给字典查询套了作用域(`WHERE institution_id = 1`),平台字典 JOIN 不到 ⇒
  146. // `name=""`、`type=0` —— 记录还在、金额分页都对,**只有类型这一列静默变空**。
  147. // (团队裁决原文:"0 是平台通用字典,JOIN 时别被作用域过滤掉"。)
  148. // ② 行里**不能有** `direction` / `userId`:契约 v1.9 删掉了它们(两表都没有这两列)。
  149. // 留着 `direction` 会让人拿它跟 `type` 比对,而两者永远一致(本来就是一件事)。
  150. // ③ `money == "100.00"`:金额走定点,恒 2 位小数;"100" 说明没走 `money.Amount`。
  151. // ④ 库里的 `year`/`month` 必须等于 `create_time` 的年月:分桶列若按应用时钟算,
  152. // 跨月那一秒的账会落到相邻月份,报表表现为"某月少一条"。
  153. func TestExpenditureCreateAndListUsesPlatformDictionary(t *testing.T) {
  154. db := newExpenditureTestDB(t)
  155. app := newExpenditureTestApp(t, db)
  156. // 平台通用字典(institution_id = 0):机构视角下**必须**可见。
  157. platformType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  158. code, msg, data := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  159. "institutionId": 1,
  160. "campusId": 10,
  161. "expenditureId": platformType.ID,
  162. "money": "100.00",
  163. "remark": "2026-11 房租",
  164. }, "X-Test-Token", "t")
  165. if code != 200 {
  166. t.Fatalf("记账 = %d(%s),期望 200", code, msg)
  167. }
  168. created := jsonMap(t, data)
  169. logID, _ := created["id"].(float64)
  170. if logID <= 0 {
  171. t.Fatalf("记账返回 id = %v,期望 >0", created["id"])
  172. }
  173. bizNo, _ := created["bizNo"].(string)
  174. if len(bizNo) != 26 {
  175. t.Fatalf("bizNo = %q(长度 %d),期望 26 位 ULID(后端生成,前端不可指定)", bizNo, len(bizNo))
  176. }
  177. // 库值:作用域列 / 金额 / 记账人 / 分桶列
  178. var row model.ExpenditureLog
  179. if err := db.Where("id = ?", int64(logID)).Take(&row).Error; err != nil {
  180. t.Fatalf("回读收支记录失败: %v", err)
  181. }
  182. if row.InstitutionID != 1 || row.CampusID != 10 {
  183. t.Fatalf("库中 institution/campus = %d/%d,期望 1/10", row.InstitutionID, row.CampusID)
  184. }
  185. if row.ExpenditureID != platformType.ID {
  186. t.Fatalf("库中 expenditure_id = %d,期望 %d", row.ExpenditureID, platformType.ID)
  187. }
  188. // ⚠ 库值按**数值**比:SQLite 的 numeric 亲和性会把 "100.00" 存成 100.0、读回 "100"
  189. // (MySQL 的 decimal(12,2) 会原样返回 "100.00")。字节级的"2 位小数"由响应 `money` 断言兜
  190. //(出口经 `money.Amount.String()`,与方言无关)。
  191. if cents, perr := parseCostCents(row.Cost); perr != nil || cents != 10000 {
  192. t.Fatalf("库中 cost = %q(%d 分,错 %v),期望 10000 分 = 100 元", row.Cost, cents, perr)
  193. }
  194. if row.CreateUserID == nil || *row.CreateUserID != testFinanceUser {
  195. t.Fatalf("库中 create_user_id = %v,期望 %q(记账人 = 当前登录用户,不接受前端指定)",
  196. row.CreateUserID, testFinanceUser)
  197. }
  198. if row.Year == nil || row.Month == nil {
  199. t.Fatalf("库中 year/month = %v/%v,期望非空(报表分桶列,NULL 会让月报少掉新记录)",
  200. row.Year, row.Month)
  201. }
  202. if int(*row.Year) != row.CreateTime.Year() || int(*row.Month) != int(row.CreateTime.Month()) {
  203. t.Fatalf("库中分桶 = %d-%d,而 create_time = %s:分桶必须由**库认定的落库时刻**推导,"+
  204. "用应用时钟会在跨月那一秒把账记到相邻月份(报表上只表现为「某月少一条」)",
  205. *row.Year, *row.Month, row.CreateTime.Format(time.RFC3339))
  206. }
  207. // 列表:字典补齐 type/name(**平台通用字典不能被作用域过滤掉**)
  208. code, msg, data = doJSON(t, app, fiber.MethodGet, PathExpenditures, nil, "X-Test-Token", "t")
  209. if code != 200 {
  210. t.Fatalf("收支列表 = %d(%s),期望 200", code, msg)
  211. }
  212. items := pageList(t, data)
  213. if len(items) != 1 {
  214. t.Fatalf("记录条数 = %d,期望 1", len(items))
  215. }
  216. item, _ := items[0].(map[string]any)
  217. if got, _ := item["name"].(string); got != "房租" {
  218. t.Fatalf("name = %q,期望 \"房租\"(来自**平台通用字典** institution_id=0);"+
  219. "空串说明字典查询被作用域过滤了 —— 记录还在、金额都对,只有类型静默变空", got)
  220. }
  221. if got, _ := item["type"].(float64); got != float64(model.ExpenditureTypeExpense) {
  222. t.Fatalf("type = %v,期望 %d(1 支出 / 2 收入,来自字典)", item["type"], model.ExpenditureTypeExpense)
  223. }
  224. if got, _ := item["expenditureId"].(float64); got != float64(platformType.ID) {
  225. t.Fatalf("expenditureId = %v,期望 %d(缺它前端无法回填编辑表单)", item["expenditureId"], platformType.ID)
  226. }
  227. if got, _ := item["money"].(string); got != "100.00" {
  228. t.Fatalf("money = %v,期望 \"100.00\"(定点 2 位;\"100\" 说明没走 money.Amount)", item["money"])
  229. }
  230. if got, _ := item["bizNo"].(string); got != bizNo {
  231. t.Fatalf("列表 bizNo = %q,期望 %q", item["bizNo"], bizNo)
  232. }
  233. if got, _ := item["createUserId"].(string); got != testFinanceUser {
  234. t.Fatalf("createUserId = %v,期望 %q(← create_user_id,语义 = 记账人)", item["createUserId"], testFinanceUser)
  235. }
  236. if got, _ := item["campusId"].(float64); got != 10 {
  237. t.Fatalf("campusId = %v,期望 10", item["campusId"])
  238. }
  239. if raw, ok := item["createTime"].(string); !ok {
  240. t.Fatalf("createTime = %v,期望时间字符串", item["createTime"])
  241. } else if _, perr := time.Parse(time.RFC3339, raw); perr != nil {
  242. t.Fatalf("createTime = %q 不是 RFC3339: %v", raw, perr)
  243. }
  244. // 契约 v1.9 删掉的两个字段**不得出现**(出现即"同一事实两个名字",前端会拿去比对)
  245. if _, ok := item["direction"]; ok {
  246. t.Fatalf("响应里有 direction:契约 v1.9 已删(与 type 同义,两表都没有该列)")
  247. }
  248. if _, ok := item["userId"]; ok {
  249. t.Fatalf("响应里有 userId:应为 createUserId(记录表只有 create_user_id = 记账人," +
  250. "没有「收付对象」列;叫 userId 会让前端把记账人当收付对象展示)")
  251. }
  252. }
  253. // TestExpenditureTypeFilterKeepsPlatformTypes 端点 60 `type` 过滤:**平台字典类型的记录不能消失**。
  254. //
  255. // 变红条件:`type` 过滤的子查询把字典按作用域收窄(`WHERE type=? AND institution_id=1`)⇒
  256. // 平台通用类型不在子查询结果里 ⇒ 按"支出"筛选时整批记录凭空消失。
  257. // 这比"少一列"更难查:用户会认为"这个月没有支出"。
  258. func TestExpenditureTypeFilterKeepsPlatformTypes(t *testing.T) {
  259. db := newExpenditureTestDB(t)
  260. app := newExpenditureTestApp(t, db)
  261. expenseType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  262. incomeType := seedExpenditureType(t, db, 0, model.ExpenditureTypeIncome, "学费收入")
  263. for _, seed := range []struct {
  264. typ *model.Expenditure
  265. money string
  266. }{
  267. {expenseType, "100.00"},
  268. {incomeType, "200.00"},
  269. } {
  270. code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  271. "institutionId": 1, "campusId": 10,
  272. "expenditureId": seed.typ.ID, "money": seed.money,
  273. }, "X-Test-Token", "t")
  274. if code != 200 {
  275. t.Fatalf("记账(%s) = %d(%s),期望 200", seed.typ.Name, code, msg)
  276. }
  277. }
  278. // type=1(支出)→ 只剩 100.00 那条
  279. code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=1", nil, "X-Test-Token", "t")
  280. if code != 200 {
  281. t.Fatalf("按 type=1 筛选 = %d(%s),期望 200", code, msg)
  282. }
  283. items := pageList(t, data)
  284. if len(items) != 1 {
  285. t.Fatalf("type=1 记录条数 = %d,期望 1(0 条说明含平台通用字典的子查询被作用域过滤了)", len(items))
  286. }
  287. only, _ := items[0].(map[string]any)
  288. if got, _ := only["money"].(string); got != "100.00" {
  289. t.Fatalf("type=1 的记录 money = %v,期望 \"100.00\"", only["money"])
  290. }
  291. // type=2(收入)→ 只剩 200.00
  292. code, _, data = doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=2", nil, "X-Test-Token", "t")
  293. if code != 200 {
  294. t.Fatalf("按 type=2 筛选 = %d,期望 200", code)
  295. }
  296. items = pageList(t, data)
  297. if len(items) != 1 {
  298. t.Fatalf("type=2 记录条数 = %d,期望 1", len(items))
  299. }
  300. only, _ = items[0].(map[string]any)
  301. if got, _ := only["money"].(string); got != "200.00" {
  302. t.Fatalf("type=2 的记录 money = %v,期望 \"200.00\"", only["money"])
  303. }
  304. // type=3 → 2002(枚举非法,不是"空列表")
  305. if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=3", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
  306. t.Fatalf("type=3 = %d(%s),期望 2002", code, msg)
  307. }
  308. }
  309. // TestExpenditureTypesIncludesPlatformAndOwn 端点 60 字典:平台 + 本机构都在,他机构的不在。
  310. //
  311. // 变红条件:
  312. // - 只返回本机构字典(丢了平台字典)→ 机构下拉框空,"新增收支"看起来坏了;
  313. // - 只返回平台字典(丢了本机构自建)→ 自建类型永远选不到;
  314. // - 不做机构过滤 → 他机构的私有类型混进来(选项重复、`parentId` 指向别人的树)。
  315. func TestExpenditureTypesIncludesPlatformAndOwn(t *testing.T) {
  316. db := newExpenditureTestDB(t)
  317. app := newExpenditureTestApp(t, db)
  318. platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  319. own := seedExpenditureType(t, db, 1, model.ExpenditureTypeExpense, "机构1自建")
  320. otherInst := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
  321. names := func(scopeHeader, query string) map[string]float64 {
  322. t.Helper()
  323. headers := []string{"X-Test-Token", "t"}
  324. if scopeHeader != "" {
  325. headers = append(headers, "X-Test-Scope", scopeHeader)
  326. }
  327. code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditureTypes+query, nil, headers...)
  328. if code != 200 {
  329. t.Fatalf("字典(%s%s) = %d(%s),期望 200", scopeHeader, query, code, msg)
  330. }
  331. payload := jsonMap(t, data)
  332. list, ok := payload["types"].([]any)
  333. if !ok {
  334. t.Fatalf("响应缺少 types 数组(契约形状是 {\"types\":[...]}):%v", payload)
  335. }
  336. out := make(map[string]float64, len(list))
  337. for _, item := range list {
  338. row, _ := item.(map[string]any)
  339. name, _ := row["name"].(string)
  340. id, _ := row["id"].(float64)
  341. out[name] = id
  342. if _, ok := row["type"]; !ok {
  343. t.Fatalf("字典项缺少 type:%v", row)
  344. }
  345. if _, ok := row["parentId"]; !ok {
  346. t.Fatalf("字典项缺少 parentId:%v", row)
  347. }
  348. }
  349. return out
  350. }
  351. // 机构 1 视角:平台 + 自建;不含机构 2 的私有类型
  352. got := names("finance", "")
  353. if got["房租"] != float64(platform.ID) {
  354. t.Fatalf("机构视角看不到平台通用字典(房租):%v —— 平台字典 institution_id=0 必须放行", got)
  355. }
  356. if got["机构1自建"] != float64(own.ID) {
  357. t.Fatalf("机构视角看不到自己的字典(机构1自建):%v", got)
  358. }
  359. if _, ok := got["机构2私有"]; ok {
  360. t.Fatalf("机构视角看到了别的机构的私有字典(机构2私有):越权泄露(应为 1004 或不可见)")
  361. }
  362. // 超管(作用域带机构 1)不传 institutionId → 平台 + 机构 1(沿用作用域机构,语义最直白)
  363. superGot := names("super", "")
  364. if _, ok := superGot["房租"]; !ok {
  365. t.Fatalf("超管视角缺少平台字典:%v", superGot)
  366. }
  367. if _, ok := superGot["机构1自建"]; !ok {
  368. t.Fatalf("超管(作用域机构 1)应看到机构 1 的字典:%v", superGot)
  369. }
  370. // 超管**没有**机构上下文(作用域机构 0)→ 只看到平台字典。
  371. // 若此时不过滤,多机构的同名类型会混在一起(选项重复、`parentId` 指向别人的树)。
  372. superNoInst := names("super-noInst", "")
  373. if _, ok := superNoInst["房租"]; !ok {
  374. t.Fatalf("超管无机构上下文时缺少平台字典:%v", superNoInst)
  375. }
  376. if _, ok := superNoInst["机构1自建"]; ok {
  377. t.Fatalf("超管无机构上下文时不应混入机构私有字典(机构1自建):%v", superNoInst)
  378. }
  379. // 超管指定 institutionId=1 → 平台 + 机构 1
  380. superScoped := names("super", "?institutionId=1")
  381. if _, ok := superScoped["房租"]; !ok {
  382. t.Fatalf("超管指定机构后缺少平台字典:%v", superScoped)
  383. }
  384. if superScoped["机构1自建"] != float64(own.ID) {
  385. t.Fatalf("超管指定机构后缺少该机构字典:%v", superScoped)
  386. }
  387. if _, ok := superScoped["机构2私有"]; ok {
  388. t.Fatalf("超管指定机构 1 却看到了机构 2 的字典:%v", superScoped)
  389. }
  390. _ = otherInst
  391. }
  392. // TestExpenditureCreateValidation 端点 60 记账的参数/类型校验(每条对应一种静默错法)。
  393. func TestExpenditureCreateValidation(t *testing.T) {
  394. db := newExpenditureTestDB(t)
  395. app := newExpenditureTestApp(t, db)
  396. platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  397. otherInstType := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
  398. cases := []struct {
  399. name string
  400. body map[string]any
  401. want int
  402. }{
  403. {"缺 expenditureId → 2001", map[string]any{"institutionId": 1, "money": "10.00"},
  404. infraerrors.ErrBadParam.Code},
  405. {"缺 money → 2001", map[string]any{"institutionId": 1, "expenditureId": platform.ID},
  406. infraerrors.ErrBadParam.Code},
  407. {"money = 0 → 2002(零元记账只会污染对账)",
  408. map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "0.00"},
  409. infraerrors.ErrBadFormat.Code},
  410. {"money 为负 → 2002(cost 恒存正数,方向由 type 决定)",
  411. map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "-1.00"},
  412. infraerrors.ErrBadFormat.Code},
  413. {"money 非数字 → 2002",
  414. map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "abc"},
  415. infraerrors.ErrBadFormat.Code},
  416. {"类型不存在 → 3001",
  417. map[string]any{"institutionId": 1, "expenditureId": 99999, "money": "10.00"},
  418. infraerrors.ErrNotFound.Code},
  419. {"他机构私有类型 → 3001(不泄露存在性)",
  420. map[string]any{"institutionId": 1, "expenditureId": otherInstType.ID, "money": "10.00"},
  421. infraerrors.ErrNotFound.Code},
  422. {"缺机构(非超管作用域机构已给出,故用超管无机构验证见下)", nil, 0},
  423. }
  424. for _, tc := range cases {
  425. if tc.body == nil {
  426. continue
  427. }
  428. code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, tc.body, "X-Test-Token", "t")
  429. if code != tc.want {
  430. t.Errorf("%s:code = %d(%s),期望 %d", tc.name, code, msg, tc.want)
  431. }
  432. }
  433. // 超管**无机构上下文**且没给 institutionId → 2001(服务端不猜机构:记到哪个机构的账必须显式)
  434. code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  435. "expenditureId": platform.ID, "money": "10.00",
  436. }, "X-Test-Token", "t", "X-Test-Scope", "super-noInst")
  437. if code != infraerrors.ErrBadParam.Code {
  438. t.Fatalf("超管缺 institutionId = %d(%s),期望 2001", code, msg)
  439. }
  440. // 对照:合法请求必须 200(否则上面的红可能是"全都红"造成的假阳性)
  441. code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  442. "institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
  443. }, "X-Test-Token", "t")
  444. if code != 200 {
  445. t.Fatalf("合法记账 = %d(%s),期望 200", code, msg)
  446. }
  447. }
  448. // TestExpenditureScopeViolation 端点 60 越权一律 **1004**(不是"空列表")。
  449. //
  450. // 为什么非要 1004:SQL 层 `Scoped` 会把跨机构查询变成空列表,症状是"这家机构一条收支都没有",
  451. // 排查会先怀疑数据丢失/迁移没跑,而不是"你越权了"。
  452. func TestExpenditureScopeViolation(t *testing.T) {
  453. db := newExpenditureTestDB(t)
  454. app := newExpenditureTestApp(t, db)
  455. platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  456. // 机构 1 的财务想看机构 2 → 1004
  457. if code, msg, _ := doJSON(t, app, fiber.MethodGet,
  458. PathExpenditures+"?institutionId=2", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  459. t.Fatalf("跨机构查列表 = %d(%s),期望 1004", code, msg)
  460. }
  461. // 校区 10 的财务想看校区 20 → 1004
  462. if code, msg, _ := doJSON(t, app, fiber.MethodGet,
  463. PathExpenditures+"?campusId=20", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  464. t.Fatalf("跨校区查列表 = %d(%s),期望 1004", code, msg)
  465. }
  466. // 记账到别的机构 → 1004
  467. if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  468. "institutionId": 2, "expenditureId": platform.ID, "money": "10.00",
  469. }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  470. t.Fatalf("跨机构记账 = %d(%s),期望 1004", code, msg)
  471. }
  472. // 记账到别的校区 → 1004
  473. if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  474. "institutionId": 1, "campusId": 20, "expenditureId": platform.ID, "money": "10.00",
  475. }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
  476. t.Fatalf("跨校区记账 = %d(%s),期望 1004", code, msg)
  477. }
  478. // 对照:本机构本校区必须 200
  479. if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  480. "institutionId": 1, "campusId": 10, "expenditureId": platform.ID, "money": "10.00",
  481. }, "X-Test-Token", "t"); code != 200 {
  482. t.Fatalf("本机构本校区记账 = %d(%s),期望 200", code, msg)
  483. }
  484. }
  485. // TestExpenditureCampusDefaultsToScope 未传 `campusId` 且有校区作用域时,落到作用域校区。
  486. //
  487. // 变红条件:默认补 0(机构级)→ 一条本该记在某校区的账落到机构级,**校区报表少一条**
  488. // 而机构级报表多一条;两个报表各自都能"自洽",只有对账时才发现。
  489. func TestExpenditureCampusDefaultsToScope(t *testing.T) {
  490. db := newExpenditureTestDB(t)
  491. app := newExpenditureTestApp(t, db)
  492. platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  493. code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  494. "institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
  495. }, "X-Test-Token", "t")
  496. if code != 200 {
  497. t.Fatalf("记账(不传校区)= %d(%s),期望 200", code, msg)
  498. }
  499. var row model.ExpenditureLog
  500. if err := db.Order("id DESC").Take(&row).Error; err != nil {
  501. t.Fatalf("回读收支记录失败: %v", err)
  502. }
  503. if row.CampusID != 10 {
  504. t.Fatalf("库中 campus_id = %d,期望 10(作用域校区);0 = 落到机构级,"+
  505. "会让校区报表少一条、机构级多一条,两边各自"+
  506. "看起来都自洽", row.CampusID)
  507. }
  508. // 校区作用域为 0 的用户(机构级财务):campusId 保持 0 = 机构级记账
  509. code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
  510. "institutionId": 1, "expenditureId": platform.ID, "money": "20.00",
  511. }, "X-Test-Token", "t", "X-Test-Scope", "finance-noCampus")
  512. if code != 200 {
  513. t.Fatalf("机构级财务记账 = %d(%s),期望 200", code, msg)
  514. }
  515. var instRow model.ExpenditureLog
  516. if err := db.Order("id DESC").Take(&instRow).Error; err != nil {
  517. t.Fatalf("回读收支记录失败: %v", err)
  518. }
  519. if instRow.CampusID != 0 {
  520. t.Fatalf("机构级财务记账 campus_id = %d,期望 0(机构级)", instRow.CampusID)
  521. }
  522. }
  523. // TestExpenditureListTimeRange 端点 60 时间区间过滤(`from`/`to` 含两端)。
  524. //
  525. // 变红条件:`to` 写成不含上界(`<`)→ 当天的账查不到,用户会以为"今天没记账"。
  526. func TestExpenditureListTimeRange(t *testing.T) {
  527. db := newExpenditureTestDB(t)
  528. app := newExpenditureTestApp(t, db)
  529. platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
  530. // 直接造库:一条 2026-03-10,一条 2026-04-10(绕开记账接口,才能控时间)
  531. for _, seed := range []struct {
  532. day string
  533. money string
  534. bizNo string
  535. }{
  536. {"2026-03-10 08:00:00", "100.00", "01HTESTMARCH0000000000001"},
  537. {"2026-04-10 08:00:00", "200.00", "01HTESTAPRIL00000000000001"},
  538. } {
  539. row := &model.ExpenditureLog{
  540. BizNo: seed.bizNo, InstitutionID: 1, CampusID: 10,
  541. ExpenditureID: platform.ID, Cost: seed.money,
  542. }
  543. if err := db.Create(row).Error; err != nil {
  544. t.Fatalf("造记录失败: %v", err)
  545. }
  546. if err := db.Exec("UPDATE t_expenditure_log SET create_time = ? WHERE id = ?",
  547. seed.day, row.ID).Error; err != nil {
  548. t.Fatalf("回填 create_time 失败: %v", err)
  549. }
  550. }
  551. code, msg, data := doJSON(t, app, fiber.MethodGet,
  552. PathExpenditures+"?from=2026-04-01&to=2026-04-10", nil, "X-Test-Token", "t")
  553. if code != 200 {
  554. t.Fatalf("按区间筛 = %d(%s),期望 200", code, msg)
  555. }
  556. items := pageList(t, data)
  557. if len(items) != 1 {
  558. t.Fatalf("区间内记录 = %d 条,期望 1(`to` 若写成不含上界,当天的账会查不到)", len(items))
  559. }
  560. only, _ := items[0].(map[string]any)
  561. if got, _ := only["money"].(string); got != "200.00" {
  562. t.Fatalf("区间内记录 money = %v,期望 \"200.00\"", only["money"])
  563. }
  564. // to < from → 2002(不静默返回空)
  565. if code, msg, _ := doJSON(t, app, fiber.MethodGet,
  566. PathExpenditures+"?from=2026-04-10&to=2026-04-01", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
  567. t.Fatalf("to<from = %d(%s),期望 2002", code, msg)
  568. }
  569. }