feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试
This commit is contained in:
@@ -107,8 +107,9 @@ func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
|
||||
mailer := &recordingMailer{}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
|
||||
approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log)
|
||||
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, auditSvc)
|
||||
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc)
|
||||
r := router.New(cfg, h, sessions, log)
|
||||
|
||||
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
|
||||
@@ -480,3 +481,98 @@ func testSSHPubKey(t *testing.T) string {
|
||||
}
|
||||
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
|
||||
}
|
||||
|
||||
func TestAPIApprovalFlow(t *testing.T) {
|
||||
app := setupTestApp(t)
|
||||
|
||||
// 公开提交申请(无需登录)
|
||||
w := app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "zhaoliu", "email": "zl@example.com", "supervisor": "prof.wang", "purpose": "课程实验",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("submit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
appr := decodeBody(t, w)["data"].(map[string]any)
|
||||
apprID := uint(appr["id"].(float64))
|
||||
if appr["status"] != "pending" {
|
||||
t.Fatalf("approval status = %v", appr["status"])
|
||||
}
|
||||
|
||||
// 同名待审批申请 → 409
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
||||
"username": "zhaoliu", "email": "zl2@example.com",
|
||||
})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("duplicate submit status = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 未登录访问列表 → 401;外部用户访问 → 403
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/approvals", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth list status = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
// 管理员登录 → 列表 1 条
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
||||
ck := sessionCookie(t, w)
|
||||
w = app.doJSON(http.MethodGet, "/api/v1/approvals?status=pending", nil, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
||||
t.Fatalf("pending items = %d, want 1", len(items))
|
||||
}
|
||||
|
||||
// 拒绝必须填理由 → 400
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false}, ck)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("reject without reason status = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// 通过 → 自动建号 + 通知邮件
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": true}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("approve status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "approved" {
|
||||
t.Fatalf("approved status = %v", status)
|
||||
}
|
||||
if app.mailer.lastTo != "zl@example.com" || !strings.Contains(app.mailer.lastBody, "ext_zhaoliu") {
|
||||
t.Fatalf("approval mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
||||
}
|
||||
var u model.User
|
||||
if err := app.db.First(&u, "username = ?", "ext_zhaoliu").Error; err != nil {
|
||||
t.Fatalf("user not created after approve: %v", err)
|
||||
}
|
||||
if u.Status != model.UserStatusActive {
|
||||
t.Fatalf("created user status = %s", u.Status)
|
||||
}
|
||||
|
||||
// 重复审批 → 409
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false, "reason": "x"}, ck)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("re-review status = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// 新申请 → 拒绝 + 理由 → 通知;被拒后可重新提交
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq@example.com"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second submit status = %d", w.Code)
|
||||
}
|
||||
apprID2 := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64))
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID2)+"/review", map[string]any{"approve": false, "reason": "用途不明确"}, ck)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("reject status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "rejected" {
|
||||
t.Fatalf("rejected status = %v", status)
|
||||
}
|
||||
if app.mailer.lastTo != "qq@example.com" || !strings.Contains(app.mailer.lastBody, "用途不明确") {
|
||||
t.Fatalf("reject mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
||||
}
|
||||
// 被拒后可重新提交同名申请
|
||||
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq2@example.com"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resubmit status = %d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user