| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- // 收支端点单测(端点 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)
- }
- }
|