|
|
@@ -27,7 +27,9 @@
|
|
|
package handler
|
|
|
|
|
|
import (
|
|
|
+ "context"
|
|
|
"encoding/json"
|
|
|
+ "errors"
|
|
|
"strings"
|
|
|
"testing"
|
|
|
"time"
|
|
|
@@ -38,6 +40,7 @@ import (
|
|
|
"yuansiwei/unified-backend/internal/academic/apperr"
|
|
|
"yuansiwei/unified-backend/internal/academic/model"
|
|
|
"yuansiwei/unified-backend/internal/academic/money"
|
|
|
+ "yuansiwei/unified-backend/internal/academic/port"
|
|
|
infraerrors "yuansiwei/unified-backend/internal/infra/errors"
|
|
|
appscope "yuansiwei/unified-backend/internal/infra/scope"
|
|
|
)
|
|
|
@@ -118,7 +121,15 @@ func newCourseTestDB(t *testing.T) *gorm.DB {
|
|
|
// `teacher` = 老师、缺省 = 财务(机构 1 / 校区 10)。
|
|
|
func newCourseTestApp(t *testing.T, db *gorm.DB) *fiber.App {
|
|
|
t.Helper()
|
|
|
- h, err := New(Config{DB: db})
|
|
|
+ return newCourseTestAppWithUsers(t, db, nil)
|
|
|
+}
|
|
|
+
|
|
|
+// newCourseTestAppWithUsers 同 `newCourseTestApp`,但可注入用户姓名端口(D4 用)。
|
|
|
+//
|
|
|
+// 入参:users `port.UserNameReader`(nil = 未注入,端点 53 的 realName 应全部为 null)。
|
|
|
+func newCourseTestAppWithUsers(t *testing.T, db *gorm.DB, users port.UserNameReader) *fiber.App {
|
|
|
+ t.Helper()
|
|
|
+ h, err := New(Config{DB: db, Users: users})
|
|
|
if err != nil {
|
|
|
t.Fatalf("handler.New 失败: %v", err)
|
|
|
}
|
|
|
@@ -715,8 +726,128 @@ func TestListCourseStudentsUsesRemainderReceivable(t *testing.T) {
|
|
|
if _, hasUnpaid := row["unpaid"]; hasUnpaid {
|
|
|
t.Fatalf("响应下发了 unpaid(%s):废弃列禁下发(设计 §3.8-E4)", string(data))
|
|
|
}
|
|
|
- if _, hasName := row["realName"]; hasName {
|
|
|
- t.Fatalf("响应下发了 realName(%s):本域无 t_user,不得凭空编姓名", string(data))
|
|
|
+ // 未注入姓名端口时 `realName` 必须是 **null**(键在、值为 nil):
|
|
|
+ // - 不下发该键 → 前端 `row.realName` undefined 与 "" 混在一起,无法区分;
|
|
|
+ // - 下发 "" → 把"没有这个人"和"姓名为空"混在一起。
|
|
|
+ nameValue, hasName := row["realName"]
|
|
|
+ if !hasName {
|
|
|
+ t.Fatalf("响应缺少 realName 键(%s):未注入端口时应下发 null,而不是省掉该键", string(data))
|
|
|
+ }
|
|
|
+ if nameValue != nil {
|
|
|
+ t.Fatalf("realName = %v,期望 null(未注入姓名端口)", nameValue)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// fakeUserNameReader 姓名端口测试替身(**只实现端口契约**:查不到的不进 map)。
|
|
|
+type fakeUserNameReader struct {
|
|
|
+ names map[string]string
|
|
|
+ err error
|
|
|
+ // calls 记录调用次数(断言"批量一次"而不是逐行 N 次)
|
|
|
+ calls int
|
|
|
+ last []string
|
|
|
+}
|
|
|
+
|
|
|
+// DisplayNames 实现 `port.UserNameReader`。
|
|
|
+func (f *fakeUserNameReader) DisplayNames(_ context.Context, userIDs []string) (map[string]string, error) {
|
|
|
+ f.calls++
|
|
|
+ f.last = append([]string{}, userIDs...)
|
|
|
+ if f.err != nil {
|
|
|
+ return nil, f.err
|
|
|
+ }
|
|
|
+ out := make(map[string]string, len(userIDs))
|
|
|
+ for _, id := range userIDs {
|
|
|
+ if name, ok := f.names[id]; ok {
|
|
|
+ out[id] = name
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out, nil
|
|
|
+}
|
|
|
+
|
|
|
+// TestListCourseStudentsResolvesRealName 端点 53 的 `realName`(D4:跨域只读 port)。
|
|
|
+//
|
|
|
+// 三行数据覆盖三种情形:
|
|
|
+//
|
|
|
+// A:端口能查到 → `realName` = 姓名
|
|
|
+// B:端口查不到 → `realName` = **null**(不是 "")
|
|
|
+// C:端口返回空串 → 同样按"没名字"→ null(空串不该出现在响应里)
|
|
|
+//
|
|
|
+// 变红条件:
|
|
|
+// - 端口没接(仍是 null)→ A 红;
|
|
|
+// - 用空串兜底 → B/C 红;
|
|
|
+// - 逐行查询 → `calls != 1` 红(N+1)。
|
|
|
+func TestListCourseStudentsResolvesRealName(t *testing.T) {
|
|
|
+ db := newCourseTestDB(t)
|
|
|
+ course := seedCourse(t, db, "姓名测试课", 10)
|
|
|
+ const studentB = "01HSTUDENT0000000000000B"
|
|
|
+ const studentC = "01HSTUDENT0000000000000C"
|
|
|
+ seedEnrollment(t, db, course.ID, courseTestStudent, "NAME-1", "100.00", "300.00", "300.00", "0.00", "0.00")
|
|
|
+ seedEnrollment(t, db, course.ID, studentB, "NAME-2", "100.00", "300.00", "300.00", "0.00", "0.00")
|
|
|
+ seedEnrollment(t, db, course.ID, studentC, "NAME-3", "100.00", "300.00", "300.00", "0.00", "0.00")
|
|
|
+ reader := &fakeUserNameReader{names: map[string]string{
|
|
|
+ courseTestStudent: "张三",
|
|
|
+ studentC: "", // 端口返回空串:调用方必须当成"没名字"
|
|
|
+ }}
|
|
|
+ app := newCourseTestAppWithUsers(t, db, reader)
|
|
|
+
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodGet,
|
|
|
+ "/admin/courses/"+itoa(course.ID)+"/students?pageSize=50", nil, "X-Test-Token", "t")
|
|
|
+ if code != 200 {
|
|
|
+ t.Fatalf("报课学生列表 = %d(%s),期望 200", code, msg)
|
|
|
+ }
|
|
|
+ items := pageList(t, data)
|
|
|
+ if len(items) != 3 {
|
|
|
+ t.Fatalf("行数 = %d,期望 3", len(items))
|
|
|
+ }
|
|
|
+ byUser := make(map[string]map[string]any, len(items))
|
|
|
+ for _, item := range items {
|
|
|
+ row, _ := item.(map[string]any)
|
|
|
+ uid, _ := row["userId"].(string)
|
|
|
+ byUser[uid] = row
|
|
|
+ }
|
|
|
+ if got, _ := byUser[courseTestStudent]["realName"].(string); got != "张三" {
|
|
|
+ t.Fatalf("A 行 realName = %v,期望 张三(端口已查到,不得为 null)",
|
|
|
+ byUser[courseTestStudent]["realName"])
|
|
|
+ }
|
|
|
+ if v, ok := byUser[studentB]["realName"]; !ok || v != nil {
|
|
|
+ t.Fatalf("B 行 realName = %v,期望 null(端口查不到;空串会让前端无法区分两种没名字)", v)
|
|
|
+ }
|
|
|
+ if v, ok := byUser[studentC]["realName"]; !ok || v != nil {
|
|
|
+ t.Fatalf("C 行 realName = %v,期望 null(端口返回空串 ⇒ 等同于没名字)", v)
|
|
|
+ }
|
|
|
+ // 批量:一页 3 行只应调用端口 1 次(N+1 会变成 3 次)
|
|
|
+ if reader.calls != 1 {
|
|
|
+ t.Fatalf("姓名端口调用 %d 次,期望 1 次(批量)—— 多次说明逐行查", reader.calls)
|
|
|
+ }
|
|
|
+ if len(reader.last) != 3 {
|
|
|
+ t.Fatalf("端口收到 %d 个 ID,期望 3(一页全部学生一次传入)", len(reader.last))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestListCourseStudentsNameReaderFailureDegradesToNull 端口报错时**降级为 null**、列表仍 200。
|
|
|
+//
|
|
|
+// 为什么不让它 5002:列表页因"少一列姓名"整页失败,用户连报课/欠费都看不到——
|
|
|
+// 展示类依赖必须可降级(与 `MembershipChecker` 那种安全依赖相反)。
|
|
|
+func TestListCourseStudentsNameReaderFailureDegradesToNull(t *testing.T) {
|
|
|
+ db := newCourseTestDB(t)
|
|
|
+ course := seedCourse(t, db, "降级测试课", 10)
|
|
|
+ seedEnrollment(t, db, course.ID, courseTestStudent, "DEGRADE-1", "100.00", "300.00", "300.00", "0.00", "0.00")
|
|
|
+ reader := &fakeUserNameReader{err: errors.New("identity 库不可用")}
|
|
|
+ app := newCourseTestAppWithUsers(t, db, reader)
|
|
|
+ code, msg, data := doJSON(t, app, fiber.MethodGet,
|
|
|
+ "/admin/courses/"+itoa(course.ID)+"/students", 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))
|
|
|
+ }
|
|
|
+ row, _ := items[0].(map[string]any)
|
|
|
+ if v, ok := row["realName"]; !ok || v != nil {
|
|
|
+ t.Fatalf("realName = %v,期望 null(端口报错时降级)", v)
|
|
|
+ }
|
|
|
+ if got, _ := row["remainderReceivable"].(string); got != "0.00" {
|
|
|
+ t.Fatalf("remainderReceivable = %v,期望 0.00(降级不得影响其它字段)", row["remainderReceivable"])
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -767,8 +898,8 @@ func TestEnrollCreatesSnapshotAndRejectsDuplicate(t *testing.T) {
|
|
|
|
|
|
// 重复报名 → 3140,且库里仍只有一行
|
|
|
code, msg, _ = doJSON(t, app, fiber.MethodPost, path, body, "X-Test-Token", "t")
|
|
|
- if code != apperr.CodeBusinessParam {
|
|
|
- t.Fatalf("重复报名 = %d(%s),期望 3140(已在读报课)", code, msg)
|
|
|
+ if code != apperr.CodeEnrollmentDuplicate {
|
|
|
+ t.Fatalf("重复报名 = %d(%s),期望 3143(重复报名)", code, msg)
|
|
|
}
|
|
|
var count int64
|
|
|
db.Model(&model.CoursesStudent{}).Where("courses_id = ? AND student_id = ?", course.ID, courseTestStudent).Count(&count)
|