feat(M3): 申请审批 + 邮件队列 — 公开申请、管理员审批自动建号、mail_logs 落库重试

This commit is contained in:
2026-08-30 10:01:38 +08:00
parent a5f501dba4
commit 1ea18490e0
13 changed files with 830 additions and 27 deletions
+97 -1
View File
@@ -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())
}
}