|
|
@@ -0,0 +1,604 @@
|
|
|
+// 收支端点单测(端点 60:记录列表 / 记账 / 类型字典)——AC-13d 第三批。
|
|
|
+//
|
|
|
+// 断言纪律同前几批:只断 body 契约,**每个端点至少一条"写错会变红"的断言**,尽量落在库上。
|
|
|
+//
|
|
|
+// 覆盖:
|
|
|
+//
|
|
|
+// 60 GET 记录 平台通用字典(institution_id=0)**必须**补齐 type/name;契约删掉的
|
|
|
+// `direction`/`userId` **不得**出现;`money` 恒 2 位;`createUserId` 可空;
|
|
|
+// `type` 过滤不得漏掉"平台字典类型的记录"
|
|
|
+// 60 POST 记账 写库列(institution/campus/cost/记账人/bizNo);year/month 与 create_time 同月;
|
|
|
+// 金额 ≤0 → 2002;类型不存在 / 他机构私有类型 → 3001
|
|
|
+// 60 GET types 平台字典 + 本机构字典都在;他机构私有类型不在;超管不指定机构只看到平台字典
|
|
|
+// 作用域 跨机构/跨校区一律 **1004**(不是"空列表")
|
|
|
+package handler
|
|
|
+
|
|
|
+import (
|
|
|
+ "strings"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "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"
|
|
|
+)
|
|
|
+
|
|
|
+// newExpenditureTestDB 端点 60 测试库(资金/活动基座 + 收支 2 表)。
|
|
|
+//
|
|
|
+// 索引处理与 `newCourseTestDB` 同源:SQLite 的索引名是**库级唯一**(MySQL 是表级),
|
|
|
+// `uk_biz_no` 在多张表上同名,不清会直接建表失败。**测试库放弃唯一约束,生产 MySQL 不受影响**。
|
|
|
+func newExpenditureTestDB(t *testing.T) *gorm.DB {
|
|
|
+ t.Helper()
|
|
|
+ base := newHandlerTestDB(t)
|
|
|
+ extra := []any{&model.Expenditure{}, &model.ExpenditureLog{}}
|
|
|
+ var leftovers []string
|
|
|
+ if err := base.Raw(
|
|
|
+ "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'").Scan(&leftovers).Error; err != nil {
|
|
|
+ t.Fatalf("读取遗留索引失败: %v", err)
|
|
|
+ }
|
|
|
+ for _, name := range leftovers {
|
|
|
+ if err := base.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
|
|
|
+ t.Fatalf("删除遗留索引(%s) 失败: %v", name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for _, tbl := range extra {
|
|
|
+ if err := base.AutoMigrate(tbl); err != nil {
|
|
|
+ t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
|
|
|
+ }
|
|
|
+ stmt := &gorm.Statement{DB: base}
|
|
|
+ if err := stmt.Parse(tbl); err != nil {
|
|
|
+ t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
|
|
|
+ }
|
|
|
+ var idxRows []struct {
|
|
|
+ Name string
|
|
|
+ SQL *string
|
|
|
+ }
|
|
|
+ if err := base.Raw(
|
|
|
+ "SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'",
|
|
|
+ stmt.Schema.Table).Scan(&idxRows).Error; err != nil {
|
|
|
+ t.Fatalf("读索引清单(%s) 失败: %v", stmt.Schema.Table, err)
|
|
|
+ }
|
|
|
+ for _, row := range idxRows {
|
|
|
+ if err := base.Exec("DROP INDEX IF EXISTS `" + row.Name + "`").Error; err != nil {
|
|
|
+ t.Fatalf("删索引(%s) 失败: %v", row.Name, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return base
|
|
|
+}
|
|
|
+
|
|
|
+// newExpenditureTestApp 挂端点 60 的 3 条路由(**不做角色白名单**:那是 e2e 的职责)。
|
|
|
+//
|
|
|
+// 作用域由 `X-Test-Scope` 头选择:
|
|
|
+//
|
|
|
+// finance(缺省)机构 1 / 校区 10;finance-noCampus 机构 1 / 校区 0;
|
|
|
+// finance-other 机构 2 / 校区 20;super 超管。
|
|
|
+func newExpenditureTestApp(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":
|
|
|
+ // 无作用域 → 1001
|
|
|
+ case "finance-noCampus":
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
+ InstitutionID: 1, CampusID: 0, UserID: testFinanceUser, Role: RoleFinance,
|
|
|
+ })
|
|
|
+ case "finance-other":
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
+ InstitutionID: 2, CampusID: 20, UserID: "01HFINANCE0000000000000002", Role: RoleFinance,
|
|
|
+ })
|
|
|
+ case "super":
|
|
|
+ appscope.Set(c, testSuperScope())
|
|
|
+ case "super-noInst":
|
|
|
+ // 平台超管但**没有机构上下文**(InstitutionID=0):字典只看平台通用那批、
|
|
|
+ // 记账必须显式给 institutionId(服务端不猜)。
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
+ InstitutionID: 0, CampusID: 0, SuperAdmin: true, UserID: "01HADMIN0000000000000002",
|
|
|
+ Role: RoleSuperAdmin,
|
|
|
+ })
|
|
|
+ default:
|
|
|
+ appscope.Set(c, &appscope.Scope{
|
|
|
+ InstitutionID: 1, CampusID: 10, UserID: testFinanceUser, Role: RoleFinance,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return c.Next()
|
|
|
+ })
|
|
|
+ app.Get(PathExpenditureTypes, h.ListExpenditureTypes)
|
|
|
+ app.Get(PathExpenditures, h.ListExpenditureLogs)
|
|
|
+ app.Post(PathExpenditures, h.CreateExpenditureLog)
|
|
|
+ return app
|
|
|
+}
|
|
|
+
|
|
|
+// parseCostCents 把库里的 `cost` 字符串解析成"分"(跨方言比较用)。
|
|
|
+//
|
|
|
+// 入参:text 库值(SQLite 可能回 "100"、MySQL 回 "100.00")。出参:(分, error)。
|
|
|
+//
|
|
|
+// 为什么不直接比字符串:SQLite 的 numeric 亲和性会吃掉尾部 0,断言字面量必红;
|
|
|
+// 而"按数值比"并不放松口径——真正的 2 位小数断言在**响应**上(`money` 由 `money.Amount.String()`
|
|
|
+// 产出,与方言无关)。两处各测各的:库里存得对、出口格式化得对。
|
|
|
+func parseCostCents(text string) (int64, error) {
|
|
|
+ amount, err := money.Parse(strings.TrimSpace(text))
|
|
|
+ if err != nil {
|
|
|
+ return 0, err
|
|
|
+ }
|
|
|
+ return amount.Cents(), nil
|
|
|
+}
|
|
|
+
|
|
|
+// testFinanceUser 端点 60 测试用的记账人(财务,机构 1)。
|
|
|
+const testFinanceUser = "01HFINANCE0000000000000001"
|
|
|
+
|
|
|
+// seedExpenditureType 造一条收支字典(institutionID = 0 即**平台通用字典**)。
|
|
|
+func seedExpenditureType(t *testing.T, db *gorm.DB, institutionID int64, typ int32, name string) *model.Expenditure {
|
|
|
+ t.Helper()
|
|
|
+ row := &model.Expenditure{InstitutionID: institutionID, Type: typ, Name: name, ParentID: 0}
|
|
|
+ if err := db.Create(row).Error; err != nil {
|
|
|
+ t.Fatalf("造收支字典(%s) 失败: %v", name, err)
|
|
|
+ }
|
|
|
+ return row
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureCreateAndListUsesPlatformDictionary 端点 60 主链路:记账 + 列表(字典补齐)。
|
|
|
+//
|
|
|
+// 这条用例是整个端点的防线,**四个断言各自对应一个真实可犯的错**:
|
|
|
+//
|
|
|
+// ① `name == "房租"` / `type == 1`:字典是**平台通用**(`institution_id = 0`)。
|
|
|
+// 若给字典查询套了作用域(`WHERE institution_id = 1`),平台字典 JOIN 不到 ⇒
|
|
|
+// `name=""`、`type=0` —— 记录还在、金额分页都对,**只有类型这一列静默变空**。
|
|
|
+// (团队裁决原文:"0 是平台通用字典,JOIN 时别被作用域过滤掉"。)
|
|
|
+// ② 行里**不能有** `direction` / `userId`:契约 v1.9 删掉了它们(两表都没有这两列)。
|
|
|
+// 留着 `direction` 会让人拿它跟 `type` 比对,而两者永远一致(本来就是一件事)。
|
|
|
+// ③ `money == "100.00"`:金额走定点,恒 2 位小数;"100" 说明没走 `money.Amount`。
|
|
|
+// ④ 库里的 `year`/`month` 必须等于 `create_time` 的年月:分桶列若按应用时钟算,
|
|
|
+// 跨月那一秒的账会落到相邻月份,报表表现为"某月少一条"。
|
|
|
+func TestExpenditureCreateAndListUsesPlatformDictionary(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ // 平台通用字典(institution_id = 0):机构视角下**必须**可见。
|
|
|
+ platformType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1,
|
|
|
+ "campusId": 10,
|
|
|
+ "expenditureId": platformType.ID,
|
|
|
+ "money": "100.00",
|
|
|
+ "remark": "2026-11 房租",
|
|
|
+ }, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("记账 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ created := jsonMap(t, data)
|
|
|
+ logID, _ := created["id"].(float64)
|
|
|
+ if logID <= 0 {
|
|
|
+ t.Fatalf("记账返回 id = %v,期望 >0", created["id"])
|
|
|
+ }
|
|
|
+ bizNo, _ := created["bizNo"].(string)
|
|
|
+ if len(bizNo) != 26 {
|
|
|
+ t.Fatalf("bizNo = %q(长度 %d),期望 26 位 ULID(后端生成,前端不可指定)", bizNo, len(bizNo))
|
|
|
+ }
|
|
|
+
|
|
|
+ // 库值:作用域列 / 金额 / 记账人 / 分桶列
|
|
|
+ var row model.ExpenditureLog
|
|
|
+ if err := db.Where("id = ?", int64(logID)).Take(&row).Error; err != nil {
|
|
|
+ t.Fatalf("回读收支记录失败: %v", err)
|
|
|
+ }
|
|
|
+ if row.InstitutionID != 1 || row.CampusID != 10 {
|
|
|
+ t.Fatalf("库中 institution/campus = %d/%d,期望 1/10", row.InstitutionID, row.CampusID)
|
|
|
+ }
|
|
|
+ if row.ExpenditureID != platformType.ID {
|
|
|
+ t.Fatalf("库中 expenditure_id = %d,期望 %d", row.ExpenditureID, platformType.ID)
|
|
|
+ }
|
|
|
+ // ⚠ 库值按**数值**比:SQLite 的 numeric 亲和性会把 "100.00" 存成 100.0、读回 "100"
|
|
|
+ // (MySQL 的 decimal(12,2) 会原样返回 "100.00")。字节级的"2 位小数"由响应 `money` 断言兜
|
|
|
+ //(出口经 `money.Amount.String()`,与方言无关)。
|
|
|
+ if cents, perr := parseCostCents(row.Cost); perr != nil || cents != 10000 {
|
|
|
+ t.Fatalf("库中 cost = %q(%d 分,错 %v),期望 10000 分 = 100 元", row.Cost, cents, perr)
|
|
|
+ }
|
|
|
+ if row.CreateUserID == nil || *row.CreateUserID != testFinanceUser {
|
|
|
+ t.Fatalf("库中 create_user_id = %v,期望 %q(记账人 = 当前登录用户,不接受前端指定)",
|
|
|
+ row.CreateUserID, testFinanceUser)
|
|
|
+ }
|
|
|
+ if row.Year == nil || row.Month == nil {
|
|
|
+ t.Fatalf("库中 year/month = %v/%v,期望非空(报表分桶列,NULL 会让月报少掉新记录)",
|
|
|
+ row.Year, row.Month)
|
|
|
+ }
|
|
|
+ if int(*row.Year) != row.CreateTime.Year() || int(*row.Month) != int(row.CreateTime.Month()) {
|
|
|
+ t.Fatalf("库中分桶 = %d-%d,而 create_time = %s:分桶必须由**库认定的落库时刻**推导,"+
|
|
|
+ "用应用时钟会在跨月那一秒把账记到相邻月份(报表上只表现为「某月少一条」)",
|
|
|
+ *row.Year, *row.Month, row.CreateTime.Format(time.RFC3339))
|
|
|
+ }
|
|
|
+
|
|
|
+ // 列表:字典补齐 type/name(**平台通用字典不能被作用域过滤掉**)
|
|
|
+ code, msg, data = doJSON(t, app, fiber.MethodGet, PathExpenditures, nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("收支列表 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ items := pageList(t, data)
|
|
|
+ if len(items) != 1 {
|
|
|
+ t.Fatalf("记录条数 = %d,期望 1", len(items))
|
|
|
+ }
|
|
|
+ item, _ := items[0].(map[string]any)
|
|
|
+ if got, _ := item["name"].(string); got != "房租" {
|
|
|
+ t.Fatalf("name = %q,期望 \"房租\"(来自**平台通用字典** institution_id=0);"+
|
|
|
+ "空串说明字典查询被作用域过滤了 —— 记录还在、金额都对,只有类型静默变空", got)
|
|
|
+ }
|
|
|
+ if got, _ := item["type"].(float64); got != float64(model.ExpenditureTypeExpense) {
|
|
|
+ t.Fatalf("type = %v,期望 %d(1 支出 / 2 收入,来自字典)", item["type"], model.ExpenditureTypeExpense)
|
|
|
+ }
|
|
|
+ if got, _ := item["expenditureId"].(float64); got != float64(platformType.ID) {
|
|
|
+ t.Fatalf("expenditureId = %v,期望 %d(缺它前端无法回填编辑表单)", item["expenditureId"], platformType.ID)
|
|
|
+ }
|
|
|
+ if got, _ := item["money"].(string); got != "100.00" {
|
|
|
+ t.Fatalf("money = %v,期望 \"100.00\"(定点 2 位;\"100\" 说明没走 money.Amount)", item["money"])
|
|
|
+ }
|
|
|
+ if got, _ := item["bizNo"].(string); got != bizNo {
|
|
|
+ t.Fatalf("列表 bizNo = %q,期望 %q", item["bizNo"], bizNo)
|
|
|
+ }
|
|
|
+ if got, _ := item["createUserId"].(string); got != testFinanceUser {
|
|
|
+ t.Fatalf("createUserId = %v,期望 %q(← create_user_id,语义 = 记账人)", item["createUserId"], testFinanceUser)
|
|
|
+ }
|
|
|
+ if got, _ := item["campusId"].(float64); got != 10 {
|
|
|
+ t.Fatalf("campusId = %v,期望 10", item["campusId"])
|
|
|
+ }
|
|
|
+ if raw, ok := item["createTime"].(string); !ok {
|
|
|
+ t.Fatalf("createTime = %v,期望时间字符串", item["createTime"])
|
|
|
+ } else if _, perr := time.Parse(time.RFC3339, raw); perr != nil {
|
|
|
+ t.Fatalf("createTime = %q 不是 RFC3339: %v", raw, perr)
|
|
|
+ }
|
|
|
+ // 契约 v1.9 删掉的两个字段**不得出现**(出现即"同一事实两个名字",前端会拿去比对)
|
|
|
+ if _, ok := item["direction"]; ok {
|
|
|
+ t.Fatalf("响应里有 direction:契约 v1.9 已删(与 type 同义,两表都没有该列)")
|
|
|
+ }
|
|
|
+ if _, ok := item["userId"]; ok {
|
|
|
+ t.Fatalf("响应里有 userId:应为 createUserId(记录表只有 create_user_id = 记账人," +
|
|
|
+ "没有「收付对象」列;叫 userId 会让前端把记账人当收付对象展示)")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureTypeFilterKeepsPlatformTypes 端点 60 `type` 过滤:**平台字典类型的记录不能消失**。
|
|
|
+//
|
|
|
+// 变红条件:`type` 过滤的子查询把字典按作用域收窄(`WHERE type=? AND institution_id=1`)⇒
|
|
|
+// 平台通用类型不在子查询结果里 ⇒ 按"支出"筛选时整批记录凭空消失。
|
|
|
+// 这比"少一列"更难查:用户会认为"这个月没有支出"。
|
|
|
+func TestExpenditureTypeFilterKeepsPlatformTypes(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ expenseType := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+ incomeType := seedExpenditureType(t, db, 0, model.ExpenditureTypeIncome, "学费收入")
|
|
|
+
|
|
|
+ for _, seed := range []struct {
|
|
|
+ typ *model.Expenditure
|
|
|
+ money string
|
|
|
+ }{
|
|
|
+ {expenseType, "100.00"},
|
|
|
+ {incomeType, "200.00"},
|
|
|
+ } {
|
|
|
+ code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "campusId": 10,
|
|
|
+ "expenditureId": seed.typ.ID, "money": seed.money,
|
|
|
+ }, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("记账(%s) = %d(%s),期望 200", seed.typ.Name, code, msg)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // type=1(支出)→ 只剩 100.00 那条
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=1", nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("按 type=1 筛选 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ items := pageList(t, data)
|
|
|
+ if len(items) != 1 {
|
|
|
+ t.Fatalf("type=1 记录条数 = %d,期望 1(0 条说明含平台通用字典的子查询被作用域过滤了)", len(items))
|
|
|
+ }
|
|
|
+ only, _ := items[0].(map[string]any)
|
|
|
+ if got, _ := only["money"].(string); got != "100.00" {
|
|
|
+ t.Fatalf("type=1 的记录 money = %v,期望 \"100.00\"", only["money"])
|
|
|
+ }
|
|
|
+
|
|
|
+ // type=2(收入)→ 只剩 200.00
|
|
|
+ code, _, data = doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=2", nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("按 type=2 筛选 = %d,期望 200", code)
|
|
|
+ }
|
|
|
+ items = pageList(t, data)
|
|
|
+ if len(items) != 1 {
|
|
|
+ t.Fatalf("type=2 记录条数 = %d,期望 1", len(items))
|
|
|
+ }
|
|
|
+ only, _ = items[0].(map[string]any)
|
|
|
+ if got, _ := only["money"].(string); got != "200.00" {
|
|
|
+ t.Fatalf("type=2 的记录 money = %v,期望 \"200.00\"", only["money"])
|
|
|
+ }
|
|
|
+
|
|
|
+ // type=3 → 2002(枚举非法,不是"空列表")
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodGet, PathExpenditures+"?type=3", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
|
|
|
+ t.Fatalf("type=3 = %d(%s),期望 2002", code, msg)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureTypesIncludesPlatformAndOwn 端点 60 字典:平台 + 本机构都在,他机构的不在。
|
|
|
+//
|
|
|
+// 变红条件:
|
|
|
+// - 只返回本机构字典(丢了平台字典)→ 机构下拉框空,"新增收支"看起来坏了;
|
|
|
+// - 只返回平台字典(丢了本机构自建)→ 自建类型永远选不到;
|
|
|
+// - 不做机构过滤 → 他机构的私有类型混进来(选项重复、`parentId` 指向别人的树)。
|
|
|
+func TestExpenditureTypesIncludesPlatformAndOwn(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+ own := seedExpenditureType(t, db, 1, model.ExpenditureTypeExpense, "机构1自建")
|
|
|
+ otherInst := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
|
|
|
+
|
|
|
+ names := func(scopeHeader, query string) map[string]float64 {
|
|
|
+ t.Helper()
|
|
|
+ headers := []string{"X-Test-Token", "t"}
|
|
|
+ if scopeHeader != "" {
|
|
|
+ headers = append(headers, "X-Test-Scope", scopeHeader)
|
|
|
+ }
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodGet, PathExpenditureTypes+query, nil, headers...)
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("字典(%s%s) = %d(%s),期望 200", scopeHeader, query, code, msg)
|
|
|
+ }
|
|
|
+ payload := jsonMap(t, data)
|
|
|
+ list, ok := payload["types"].([]any)
|
|
|
+ if !ok {
|
|
|
+ t.Fatalf("响应缺少 types 数组(契约形状是 {\"types\":[...]}):%v", payload)
|
|
|
+ }
|
|
|
+ out := make(map[string]float64, len(list))
|
|
|
+ for _, item := range list {
|
|
|
+ row, _ := item.(map[string]any)
|
|
|
+ name, _ := row["name"].(string)
|
|
|
+ id, _ := row["id"].(float64)
|
|
|
+ out[name] = id
|
|
|
+ if _, ok := row["type"]; !ok {
|
|
|
+ t.Fatalf("字典项缺少 type:%v", row)
|
|
|
+ }
|
|
|
+ if _, ok := row["parentId"]; !ok {
|
|
|
+ t.Fatalf("字典项缺少 parentId:%v", row)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ }
|
|
|
+
|
|
|
+ // 机构 1 视角:平台 + 自建;不含机构 2 的私有类型
|
|
|
+ got := names("finance", "")
|
|
|
+ if got["房租"] != float64(platform.ID) {
|
|
|
+ t.Fatalf("机构视角看不到平台通用字典(房租):%v —— 平台字典 institution_id=0 必须放行", got)
|
|
|
+ }
|
|
|
+ if got["机构1自建"] != float64(own.ID) {
|
|
|
+ t.Fatalf("机构视角看不到自己的字典(机构1自建):%v", got)
|
|
|
+ }
|
|
|
+ if _, ok := got["机构2私有"]; ok {
|
|
|
+ t.Fatalf("机构视角看到了别的机构的私有字典(机构2私有):越权泄露(应为 1004 或不可见)")
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超管(作用域带机构 1)不传 institutionId → 平台 + 机构 1(沿用作用域机构,语义最直白)
|
|
|
+ superGot := names("super", "")
|
|
|
+ if _, ok := superGot["房租"]; !ok {
|
|
|
+ t.Fatalf("超管视角缺少平台字典:%v", superGot)
|
|
|
+ }
|
|
|
+ if _, ok := superGot["机构1自建"]; !ok {
|
|
|
+ t.Fatalf("超管(作用域机构 1)应看到机构 1 的字典:%v", superGot)
|
|
|
+ }
|
|
|
+ // 超管**没有**机构上下文(作用域机构 0)→ 只看到平台字典。
|
|
|
+ // 若此时不过滤,多机构的同名类型会混在一起(选项重复、`parentId` 指向别人的树)。
|
|
|
+ superNoInst := names("super-noInst", "")
|
|
|
+ if _, ok := superNoInst["房租"]; !ok {
|
|
|
+ t.Fatalf("超管无机构上下文时缺少平台字典:%v", superNoInst)
|
|
|
+ }
|
|
|
+ if _, ok := superNoInst["机构1自建"]; ok {
|
|
|
+ t.Fatalf("超管无机构上下文时不应混入机构私有字典(机构1自建):%v", superNoInst)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超管指定 institutionId=1 → 平台 + 机构 1
|
|
|
+ superScoped := names("super", "?institutionId=1")
|
|
|
+ if _, ok := superScoped["房租"]; !ok {
|
|
|
+ t.Fatalf("超管指定机构后缺少平台字典:%v", superScoped)
|
|
|
+ }
|
|
|
+ if superScoped["机构1自建"] != float64(own.ID) {
|
|
|
+ t.Fatalf("超管指定机构后缺少该机构字典:%v", superScoped)
|
|
|
+ }
|
|
|
+ if _, ok := superScoped["机构2私有"]; ok {
|
|
|
+ t.Fatalf("超管指定机构 1 却看到了机构 2 的字典:%v", superScoped)
|
|
|
+ }
|
|
|
+ _ = otherInst
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureCreateValidation 端点 60 记账的参数/类型校验(每条对应一种静默错法)。
|
|
|
+func TestExpenditureCreateValidation(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+ otherInstType := seedExpenditureType(t, db, 2, model.ExpenditureTypeExpense, "机构2私有")
|
|
|
+
|
|
|
+ cases := []struct {
|
|
|
+ name string
|
|
|
+ body map[string]any
|
|
|
+ want int
|
|
|
+ }{
|
|
|
+ {"缺 expenditureId → 2001", map[string]any{"institutionId": 1, "money": "10.00"},
|
|
|
+ infraerrors.ErrBadParam.Code},
|
|
|
+ {"缺 money → 2001", map[string]any{"institutionId": 1, "expenditureId": platform.ID},
|
|
|
+ infraerrors.ErrBadParam.Code},
|
|
|
+ {"money = 0 → 2002(零元记账只会污染对账)",
|
|
|
+ map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "0.00"},
|
|
|
+ infraerrors.ErrBadFormat.Code},
|
|
|
+ {"money 为负 → 2002(cost 恒存正数,方向由 type 决定)",
|
|
|
+ map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "-1.00"},
|
|
|
+ infraerrors.ErrBadFormat.Code},
|
|
|
+ {"money 非数字 → 2002",
|
|
|
+ map[string]any{"institutionId": 1, "expenditureId": platform.ID, "money": "abc"},
|
|
|
+ infraerrors.ErrBadFormat.Code},
|
|
|
+ {"类型不存在 → 3001",
|
|
|
+ map[string]any{"institutionId": 1, "expenditureId": 99999, "money": "10.00"},
|
|
|
+ infraerrors.ErrNotFound.Code},
|
|
|
+ {"他机构私有类型 → 3001(不泄露存在性)",
|
|
|
+ map[string]any{"institutionId": 1, "expenditureId": otherInstType.ID, "money": "10.00"},
|
|
|
+ infraerrors.ErrNotFound.Code},
|
|
|
+ {"缺机构(非超管作用域机构已给出,故用超管无机构验证见下)", nil, 0},
|
|
|
+ }
|
|
|
+ for _, tc := range cases {
|
|
|
+ if tc.body == nil {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, tc.body, "X-Test-Token", "t")
|
|
|
+ if code != tc.want {
|
|
|
+ t.Errorf("%s:code = %d(%s),期望 %d", tc.name, code, msg, tc.want)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超管**无机构上下文**且没给 institutionId → 2001(服务端不猜机构:记到哪个机构的账必须显式)
|
|
|
+ code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t", "X-Test-Scope", "super-noInst")
|
|
|
+ if code != infraerrors.ErrBadParam.Code {
|
|
|
+ t.Fatalf("超管缺 institutionId = %d(%s),期望 2001", code, msg)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 对照:合法请求必须 200(否则上面的红可能是"全都红"造成的假阳性)
|
|
|
+ code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("合法记账 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureScopeViolation 端点 60 越权一律 **1004**(不是"空列表")。
|
|
|
+//
|
|
|
+// 为什么非要 1004:SQL 层 `Scoped` 会把跨机构查询变成空列表,症状是"这家机构一条收支都没有",
|
|
|
+// 排查会先怀疑数据丢失/迁移没跑,而不是"你越权了"。
|
|
|
+func TestExpenditureScopeViolation(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+
|
|
|
+ // 机构 1 的财务想看机构 2 → 1004
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodGet,
|
|
|
+ PathExpenditures+"?institutionId=2", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
|
|
|
+ t.Fatalf("跨机构查列表 = %d(%s),期望 1004", code, msg)
|
|
|
+ }
|
|
|
+ // 校区 10 的财务想看校区 20 → 1004
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodGet,
|
|
|
+ PathExpenditures+"?campusId=20", nil, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
|
|
|
+ t.Fatalf("跨校区查列表 = %d(%s),期望 1004", code, msg)
|
|
|
+ }
|
|
|
+ // 记账到别的机构 → 1004
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 2, "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
|
|
|
+ t.Fatalf("跨机构记账 = %d(%s),期望 1004", code, msg)
|
|
|
+ }
|
|
|
+ // 记账到别的校区 → 1004
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "campusId": 20, "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t"); code != infraerrors.ErrUnscoped.Code {
|
|
|
+ t.Fatalf("跨校区记账 = %d(%s),期望 1004", code, msg)
|
|
|
+ }
|
|
|
+ // 对照:本机构本校区必须 200
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "campusId": 10, "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t"); code != 200 {
|
|
|
+ t.Fatalf("本机构本校区记账 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureCampusDefaultsToScope 未传 `campusId` 且有校区作用域时,落到作用域校区。
|
|
|
+//
|
|
|
+// 变红条件:默认补 0(机构级)→ 一条本该记在某校区的账落到机构级,**校区报表少一条**
|
|
|
+// 而机构级报表多一条;两个报表各自都能"自洽",只有对账时才发现。
|
|
|
+func TestExpenditureCampusDefaultsToScope(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+
|
|
|
+ code, msg, _ := doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "expenditureId": platform.ID, "money": "10.00",
|
|
|
+ }, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("记账(不传校区)= %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ var row model.ExpenditureLog
|
|
|
+ if err := db.Order("id DESC").Take(&row).Error; err != nil {
|
|
|
+ t.Fatalf("回读收支记录失败: %v", err)
|
|
|
+ }
|
|
|
+ if row.CampusID != 10 {
|
|
|
+ t.Fatalf("库中 campus_id = %d,期望 10(作用域校区);0 = 落到机构级,"+
|
|
|
+ "会让校区报表少一条、机构级多一条,两边各自"+
|
|
|
+ "看起来都自洽", row.CampusID)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 校区作用域为 0 的用户(机构级财务):campusId 保持 0 = 机构级记账
|
|
|
+ code, msg, _ = doJSON(t, app, fiber.MethodPost, PathExpenditures, map[string]any{
|
|
|
+ "institutionId": 1, "expenditureId": platform.ID, "money": "20.00",
|
|
|
+ }, "X-Test-Token", "t", "X-Test-Scope", "finance-noCampus")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("机构级财务记账 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ var instRow model.ExpenditureLog
|
|
|
+ if err := db.Order("id DESC").Take(&instRow).Error; err != nil {
|
|
|
+ t.Fatalf("回读收支记录失败: %v", err)
|
|
|
+ }
|
|
|
+ if instRow.CampusID != 0 {
|
|
|
+ t.Fatalf("机构级财务记账 campus_id = %d,期望 0(机构级)", instRow.CampusID)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExpenditureListTimeRange 端点 60 时间区间过滤(`from`/`to` 含两端)。
|
|
|
+//
|
|
|
+// 变红条件:`to` 写成不含上界(`<`)→ 当天的账查不到,用户会以为"今天没记账"。
|
|
|
+func TestExpenditureListTimeRange(t *testing.T) {
|
|
|
+ db := newExpenditureTestDB(t)
|
|
|
+ app := newExpenditureTestApp(t, db)
|
|
|
+ platform := seedExpenditureType(t, db, 0, model.ExpenditureTypeExpense, "房租")
|
|
|
+
|
|
|
+ // 直接造库:一条 2026-03-10,一条 2026-04-10(绕开记账接口,才能控时间)
|
|
|
+ for _, seed := range []struct {
|
|
|
+ day string
|
|
|
+ money string
|
|
|
+ bizNo string
|
|
|
+ }{
|
|
|
+ {"2026-03-10 08:00:00", "100.00", "01HTESTMARCH0000000000001"},
|
|
|
+ {"2026-04-10 08:00:00", "200.00", "01HTESTAPRIL00000000000001"},
|
|
|
+ } {
|
|
|
+ row := &model.ExpenditureLog{
|
|
|
+ BizNo: seed.bizNo, InstitutionID: 1, CampusID: 10,
|
|
|
+ ExpenditureID: platform.ID, Cost: seed.money,
|
|
|
+ }
|
|
|
+ if err := db.Create(row).Error; err != nil {
|
|
|
+ t.Fatalf("造记录失败: %v", err)
|
|
|
+ }
|
|
|
+ if err := db.Exec("UPDATE t_expenditure_log SET create_time = ? WHERE id = ?",
|
|
|
+ seed.day, row.ID).Error; err != nil {
|
|
|
+ t.Fatalf("回填 create_time 失败: %v", err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodGet,
|
|
|
+ PathExpenditures+"?from=2026-04-01&to=2026-04-10", nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("按区间筛 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ items := pageList(t, data)
|
|
|
+ if len(items) != 1 {
|
|
|
+ t.Fatalf("区间内记录 = %d 条,期望 1(`to` 若写成不含上界,当天的账会查不到)", len(items))
|
|
|
+ }
|
|
|
+ only, _ := items[0].(map[string]any)
|
|
|
+ if got, _ := only["money"].(string); got != "200.00" {
|
|
|
+ t.Fatalf("区间内记录 money = %v,期望 \"200.00\"", only["money"])
|
|
|
+ }
|
|
|
+
|
|
|
+ // to < from → 2002(不静默返回空)
|
|
|
+ if code, msg, _ := doJSON(t, app, fiber.MethodGet,
|
|
|
+ PathExpenditures+"?from=2026-04-10&to=2026-04-01", nil, "X-Test-Token", "t"); code != infraerrors.ErrBadFormat.Code {
|
|
|
+ t.Fatalf("to<from = %d(%s),期望 2002", code, msg)
|
|
|
+ }
|
|
|
+}
|