From 128bb7cda64c92c5bc9c565770b8017efb1cad32 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 26 May 2026 11:04:23 +0800 Subject: [PATCH] Add aftersales stats to dashboard and service-layer tests - CompanyStatsOverviewDTO and GetStats() now include aftersales counts (total, pending confirmation, closed, rejected) and a recentAftersales list - aftersales_service_test.go covers YYMMNN sequence, owner-only submit, state machine, phone last-4 check, reject increment, force-close - users_service_test.go covers duplicate username, self-demotion guard, last-admin guard, password reset, assignable filter Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 6 +- README.md | 10 +- models/models.go | 22 ++- services/aftersales_service_test.go | 278 ++++++++++++++++++++++++++++ services/companies_service.go | 64 ++++++- services/services_test.go | 2 + services/users_service_test.go | 206 +++++++++++++++++++++ 7 files changed, 571 insertions(+), 17 deletions(-) create mode 100644 services/aftersales_service_test.go create mode 100644 services/users_service_test.go diff --git a/AGENTS.md b/AGENTS.md index c6e9aae..0e38764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,10 @@ backend-go/ │ ├── companies_service.go # Company CRUD │ ├── employees_service.go # Employee serials: generate, query, update, revoke, qrcode │ ├── serials_service.go # Company serials: generate, query, update, revoke, qrcode -│ ├── services_test.go # Unit tests -│ └── users_service.go # User CRUD, role management, password reset (admin) +│ ├── aftersales_service_test.go # Aftersales unit tests +│ ├── services_test.go # Auth / Serials / Employees / Companies unit tests +│ ├── users_service.go # User CRUD, role management, password reset (admin) +│ └── users_service_test.go # Users unit tests ├── tests/ # Integration tests │ └── main_test.go # End-to-end tests ├── data/ # SQLite data directory diff --git a/README.md b/README.md index 561369d..1ed4dd5 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,10 @@ backend-go/ │ ├── companies_service.go # 企业管理业务逻辑 │ ├── employees_service.go # 员工赋码业务逻辑 │ ├── serials_service.go # 序列号业务逻辑 -│ ├── services_test.go # 服务层单元测试 -│ └── users_service.go # 用户管理业务逻辑 +│ ├── aftersales_service_test.go # 售后工单单元测试 +│ ├── services_test.go # 认证/序列号/员工/企业单元测试 +│ ├── users_service.go # 用户管理业务逻辑 +│ └── users_service_test.go # 用户管理单元测试 ├── tests/ # 集成测试 │ └── main_test.go # 端到端测试 ├── data/ # 数据目录(SQLite 数据库存储位置) @@ -365,11 +367,13 @@ go tool cover -html=coverage.out ### 当前测试覆盖 -- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService 和 CompaniesService 的完整单元测试 +- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService、CompaniesService、AftersalesService 和 UsersService 的完整单元测试 - 用户认证测试(登录、获取用户信息、修改密码、更新资料) - 序列号管理测试(生成、查询、更新、吊销、分页列表) - 员工赋码测试(生成、查询、更新、吊销、二维码生成) - 企业统计测试(统计概览) + - 售后工单测试(YYMMNN 序号生成、状态机、客户确认手机号校验、强制关闭) + - 用户管理测试(重复用户名、自降级保护、最后管理员保护、密码重置) - **tests/**: 集成测试(健康检查、登录流程) ## 代码检查 diff --git a/models/models.go b/models/models.go index f4a7547..09d3d86 100644 --- a/models/models.go +++ b/models/models.go @@ -187,15 +187,19 @@ type CompanyUpdateRequest struct { // CompanyStatsOverviewDTO 企业统计概览 type CompanyStatsOverviewDTO struct { - TotalCompanies int64 `json:"totalCompanies"` - ActiveCompanies int64 `json:"activeCompanies"` - InactiveCompanies int64 `json:"inactiveCompanies"` - TotalSerials int64 `json:"totalSerials"` - ActiveSerials int64 `json:"activeSerials"` - RevokedSerials int64 `json:"revokedSerials"` - TotalEmployeeSerials int64 `json:"totalEmployeeSerials"` - ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"` - RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"` + TotalCompanies int64 `json:"totalCompanies"` + ActiveCompanies int64 `json:"activeCompanies"` + InactiveCompanies int64 `json:"inactiveCompanies"` + TotalSerials int64 `json:"totalSerials"` + ActiveSerials int64 `json:"activeSerials"` + RevokedSerials int64 `json:"revokedSerials"` + TotalEmployeeSerials int64 `json:"totalEmployeeSerials"` + ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"` + RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"` + TotalAftersales int64 `json:"totalAftersales"` + PendingConfirmation int64 `json:"pendingConfirmation"` + ClosedAftersales int64 `json:"closedAftersales"` + RejectedAftersales int64 `json:"rejectedAftersales"` } // EmployeeSerial 员工序列号模型 diff --git a/services/aftersales_service_test.go b/services/aftersales_service_test.go new file mode 100644 index 0000000..9cdef92 --- /dev/null +++ b/services/aftersales_service_test.go @@ -0,0 +1,278 @@ +package services + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +func seedTechnician(t *testing.T, username string) models.User { + t.Helper() + user := models.User{ + Username: username, + Password: "hashed", + Name: "技术员-" + username, + Email: username + "@example.com", + Role: "technician", + } + if err := database.DB.Create(&user).Error; err != nil { + t.Fatalf("seed technician failed: %v", err) + } + return user +} + +func createOrderFor(t *testing.T, user models.User, phone string) *models.AftersalesOrder { + t.Helper() + svc := AftersalesService{} + order, err := svc.Create(models.CreateAftersalesOrderDTO{ + CompanyName: "TestCo_" + user.Username, + CompanyAddress: "测试地址", + ContactName: "张三", + ContactPhone: phone, + ServiceType: "software", + IssueDescription: "系统无法启动", + }, user.ID) + if err != nil { + t.Fatalf("create order failed: %v", err) + } + return order +} + +func TestAftersalesService_Create_GeneratesYYMMNNSerial(t *testing.T) { + user := seedTechnician(t, "aftersales_create_tech") + defer database.DB.Unscoped().Delete(&user) + + svc := AftersalesService{} + order, err := svc.Create(models.CreateAftersalesOrderDTO{ + CompanyName: "AftersalesSerialCo", + CompanyAddress: "杭州市西湖区", + ContactName: "李四", + ContactPhone: "13800001234", + ServiceType: "hardware", + IssueDescription: "硬盘故障", + }, user.ID) + + assert.NoError(t, err) + assert.NotNil(t, order) + assert.True(t, strings.HasPrefix(order.SerialNumber, "zjbf-sh-")) + assert.Len(t, order.SerialNumber, len("zjbf-sh-")+6, "default serial should be 6 digits (YYMMNN)") + assert.Equal(t, WorkOrderStatusCreated, order.WorkOrderStatus) + assert.Equal(t, AuthorizationStatusPending, order.AuthorizationStatus) + assert.NotNil(t, order.TechnicianID) + assert.Equal(t, user.ID, *order.TechnicianID) + + database.DB.Unscoped().Delete(order) + database.DB.Unscoped().Where("company_name = ?", "AftersalesSerialCo").Delete(&models.Company{}) +} + +func TestAftersalesService_Create_SerialIncrementsWithinMonth(t *testing.T) { + user := seedTechnician(t, "aftersales_seq_tech") + defer database.DB.Unscoped().Delete(&user) + + svc := AftersalesService{} + first, err := svc.Create(models.CreateAftersalesOrderDTO{ + CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000", + ServiceType: "other", IssueDescription: "issue 1", + }, user.ID) + assert.NoError(t, err) + + second, err := svc.Create(models.CreateAftersalesOrderDTO{ + CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000", + ServiceType: "other", IssueDescription: "issue 2", + }, user.ID) + assert.NoError(t, err) + + assert.NotEqual(t, first.SerialNumber, second.SerialNumber) + // 第二单的序号应大于第一单(按月递增) + assert.True(t, second.SerialNumber > first.SerialNumber) + + database.DB.Unscoped().Delete(first) + database.DB.Unscoped().Delete(second) + database.DB.Unscoped().Where("company_name = ?", "SeqCo").Delete(&models.Company{}) +} + +func TestAftersalesService_SubmitForConfirmation_OwnerOnly(t *testing.T) { + owner := seedTechnician(t, "aftersales_owner") + intruder := seedTechnician(t, "aftersales_intruder") + defer database.DB.Unscoped().Delete(&owner) + defer database.DB.Unscoped().Delete(&intruder) + + order := createOrderFor(t, owner, "13800003000") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + + // 非负责人技术员不能提交 + _, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "已重装系统", + }, intruder) + assert.Error(t, err) + + // 负责人可以提交 + updated, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "已重装系统", + }, owner) + assert.NoError(t, err) + assert.Equal(t, WorkOrderStatusPendingConfirmation, updated.WorkOrderStatus) + assert.Equal(t, "已重装系统", updated.ResolutionNote) +} + +func TestAftersalesService_SubmitForConfirmation_RejectsClosed(t *testing.T) { + owner := seedTechnician(t, "aftersales_closed_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800004000") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + + _, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "first submit", + }, owner) + assert.NoError(t, err) + + // 已经 pending_confirmation 状态,不能再提交 + _, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "second submit", + }, owner) + assert.Error(t, err) +} + +func TestAftersalesService_PublicQuery_MasksPhoneAndSetsScannedAt(t *testing.T) { + owner := seedTechnician(t, "aftersales_public_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800005000") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + view, err := svc.PublicQuery(order.SerialNumber) + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, order.CompanyName, view.CompanyName) + assert.Equal(t, order.ContactName, view.ContactName) + + // PublicView 不应该带电话字段(只有以下字段,通过类型保证) + // 验证 ScannedAt 已设置 + var refreshed models.AftersalesOrder + database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed) + assert.NotNil(t, refreshed.ScannedAt) +} + +func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) { + owner := seedTechnician(t, "aftersales_auth_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800006789") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + _, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "done", + }, owner) + + view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + PhoneLast4: "6789", + Action: "authorize", + }) + assert.NoError(t, err) + assert.NotNil(t, view) + assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus) + assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus) + assert.NotNil(t, view.ConfirmedAt) +} + +func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) { + owner := seedTechnician(t, "aftersales_phone_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800007777") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + _, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "done", + }, owner) + + _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + PhoneLast4: "0000", + Action: "authorize", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "手机号校验失败") +} + +func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) { + owner := seedTechnician(t, "aftersales_reject_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800008888") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + _, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "done", + }, owner) + + view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + PhoneLast4: "8888", + Action: "reject", + RejectReason: "没修好", + }) + assert.NoError(t, err) + assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus) + assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus) + + var refreshed models.AftersalesOrder + database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed) + assert.Equal(t, 1, refreshed.RejectCount) + assert.Contains(t, refreshed.ResolutionNote, "没修好") + + // 退回后技术员可以再次提交 + _, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{ + ResolutionNote: "re-do", + }, owner) + assert.NoError(t, err) +} + +func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) { + owner := seedTechnician(t, "aftersales_wrongstatus_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800009999") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + // 未提交客户确认,工单仍是 created,应该拒绝 + _, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{ + PhoneLast4: "9999", + Action: "authorize", + }) + assert.Error(t, err) +} + +func TestAftersalesService_ForceClose_AdminOverride(t *testing.T) { + owner := seedTechnician(t, "aftersales_force_owner") + defer database.DB.Unscoped().Delete(&owner) + + order := createOrderFor(t, owner, "13800001111") + defer database.DB.Unscoped().Delete(order) + defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{}) + + svc := AftersalesService{} + updated, err := svc.ForceClose(order.SerialNumber) + assert.NoError(t, err) + assert.Equal(t, WorkOrderStatusClosed, updated.WorkOrderStatus) + assert.Equal(t, AuthorizationStatusAuthorized, updated.AuthorizationStatus) +} diff --git a/services/companies_service.go b/services/companies_service.go index 4d09604..d2c81ac 100644 --- a/services/companies_service.go +++ b/services/companies_service.go @@ -317,6 +317,11 @@ func (s *CompaniesService) GetStats() (map[string]any, error) { return nil, errors.New("查询员工序列号统计失败") } + var aftersales []models.AftersalesOrder + if err := database.DB.Preload("Technician").Order("created_at DESC").Find(&aftersales).Error; err != nil { + return nil, errors.New("查询售后工单统计失败") + } + companyCount := len(companies) serialCount := len(serials) employeeSerialCount := len(employeeSerials) @@ -392,6 +397,41 @@ func (s *CompaniesService) GetStats() (map[string]any, error) { recentSerials = recentSerials[:10] } + aftersalesTotal := len(aftersales) + aftersalesPending := 0 + aftersalesClosed := 0 + aftersalesRejected := 0 + for _, o := range aftersales { + switch o.WorkOrderStatus { + case "pending_confirmation": + aftersalesPending++ + case "closed": + aftersalesClosed++ + case "rejected": + aftersalesRejected++ + } + } + + recentAftersales := make([]map[string]any, 0) + for i, order := range aftersales { + if i >= 10 { + break + } + technicianName := "" + if order.Technician != nil { + technicianName = order.Technician.Name + } + recentAftersales = append(recentAftersales, map[string]any{ + "serialNumber": order.SerialNumber, + "companyName": order.CompanyName, + "serviceType": order.ServiceType, + "workOrderStatus": order.WorkOrderStatus, + "authorizationStatus": order.AuthorizationStatus, + "technicianName": technicianName, + "createdAt": order.CreatedAt, + }) + } + return map[string]any{ "overview": map[string]any{ "totalCompanies": companyCount, @@ -399,10 +439,15 @@ func (s *CompaniesService) GetStats() (map[string]any, error) { "totalEmployeeSerials": employeeSerialCount, "activeSerials": activeCount, "inactiveSerials": inactiveCount, + "totalAftersales": aftersalesTotal, + "pendingConfirmation": aftersalesPending, + "closedAftersales": aftersalesClosed, + "rejectedAftersales": aftersalesRejected, }, - "monthlyStats": monthlyItems, - "recentCompanies": recentCompanies, - "recentSerials": recentSerials, + "monthlyStats": monthlyItems, + "recentCompanies": recentCompanies, + "recentSerials": recentSerials, + "recentAftersales": recentAftersales, }, nil } @@ -440,5 +485,18 @@ func (s *CompaniesService) GetStatsOverview() (*models.CompanyStatsOverviewDTO, stats.RevokedEmployeeSerials = stats.TotalEmployeeSerials - stats.ActiveEmployeeSerials + if err := database.DB.Model(&models.AftersalesOrder{}).Count(&stats.TotalAftersales).Error; err != nil { + return nil, errors.New("统计售后工单总数失败") + } + if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "pending_confirmation").Count(&stats.PendingConfirmation).Error; err != nil { + return nil, errors.New("统计待客户确认工单失败") + } + if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "closed").Count(&stats.ClosedAftersales).Error; err != nil { + return nil, errors.New("统计已完成工单失败") + } + if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "rejected").Count(&stats.RejectedAftersales).Error; err != nil { + return nil, errors.New("统计已退回工单失败") + } + return stats, nil } diff --git a/services/services_test.go b/services/services_test.go index 434a5f6..a90a6d5 100644 --- a/services/services_test.go +++ b/services/services_test.go @@ -32,6 +32,7 @@ func TestMain(m *testing.M) { database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{}) database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{}) database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{}) exitCode := m.Run() @@ -39,6 +40,7 @@ func TestMain(m *testing.M) { database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{}) database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{}) database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{}) os.Exit(exitCode) } diff --git a/services/users_service_test.go b/services/users_service_test.go new file mode 100644 index 0000000..ebee5ee --- /dev/null +++ b/services/users_service_test.go @@ -0,0 +1,206 @@ +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" + + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +func TestUsersService_Create_Success(t *testing.T) { + svc := UsersService{} + dto, err := svc.Create(models.CreateUserDTO{ + Username: "users_create_ok", + Password: "password123", + Name: "新技术员", + Email: "new@example.com", + Role: "technician", + }) + assert.NoError(t, err) + assert.NotNil(t, dto) + assert.Equal(t, "users_create_ok", dto.Username) + assert.Equal(t, "technician", dto.Role) + + database.DB.Unscoped().Where("username = ?", "users_create_ok").Delete(&models.User{}) +} + +func TestUsersService_Create_DuplicateUsername(t *testing.T) { + user := models.User{ + Username: "users_create_dup", + Password: "x", + Name: "existing", + Role: "technician", + } + database.DB.Create(&user) + defer database.DB.Unscoped().Delete(&user) + + svc := UsersService{} + _, err := svc.Create(models.CreateUserDTO{ + Username: "users_create_dup", + Password: "password123", + Name: "duplicate", + Role: "technician", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "用户名已存在") +} + +func TestUsersService_Update_BlocksSelfDemotion(t *testing.T) { + admin := models.User{ + Username: "users_self_admin", + Password: "x", + Name: "self admin", + Role: "admin", + } + database.DB.Create(&admin) + defer database.DB.Unscoped().Delete(&admin) + + svc := UsersService{} + _, err := svc.Update(admin.ID, models.UpdateUserDTO{ + Role: "technician", + }, admin.ID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "不能修改自己的管理员角色") +} + +func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) { + currentAdmin := models.User{ + Username: "users_update_admin", + Password: "x", + Name: "current", + Role: "admin", + } + target := models.User{ + Username: "users_update_target", + Password: "x", + Name: "target", + Role: "technician", + } + database.DB.Create(¤tAdmin) + database.DB.Create(&target) + defer database.DB.Unscoped().Delete(¤tAdmin) + defer database.DB.Unscoped().Delete(&target) + + svc := UsersService{} + updated, err := svc.Update(target.ID, models.UpdateUserDTO{ + Name: "新名字", + Role: "admin", + }, currentAdmin.ID) + assert.NoError(t, err) + assert.Equal(t, "新名字", updated.Name) + assert.Equal(t, "admin", updated.Role) +} + +func TestUsersService_ResetPassword_ChangesHash(t *testing.T) { + hashed, _ := bcrypt.GenerateFromPassword([]byte("oldpass"), bcrypt.DefaultCost) + user := models.User{ + Username: "users_reset_pwd", + Password: string(hashed), + Name: "reset", + Role: "technician", + } + database.DB.Create(&user) + defer database.DB.Unscoped().Delete(&user) + + svc := UsersService{} + err := svc.ResetPassword(user.ID, "newpass456") + assert.NoError(t, err) + + var refreshed models.User + database.DB.First(&refreshed, user.ID) + assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("newpass456"))) + assert.Error(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("oldpass"))) +} + +func TestUsersService_Delete_BlocksSelf(t *testing.T) { + user := models.User{ + Username: "users_delete_self", + Password: "x", + Name: "self", + Role: "admin", + } + database.DB.Create(&user) + defer database.DB.Unscoped().Delete(&user) + + svc := UsersService{} + err := svc.Delete(user.ID, user.ID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "不能删除自己") +} + +func TestUsersService_Delete_BlocksLastAdmin(t *testing.T) { + // 清理可能存在的其他 admin(来自 seed 或前面测试) + database.DB.Unscoped().Where("role = ?", "admin").Delete(&models.User{}) + + last := models.User{ + Username: "users_last_admin", + Password: "x", + Name: "last", + Role: "admin", + } + other := models.User{ + Username: "users_other_admin_actor", + Password: "x", + Name: "actor", + Role: "admin", + } + database.DB.Create(&last) + database.DB.Create(&other) + + svc := UsersService{} + // 当前调用者是 other(不是 last),但删除会让 last 变成最后一个 admin + // 删 last 时计数会变 0,所以拒绝 + database.DB.Unscoped().Delete(&other) + err := svc.Delete(last.ID, 999999) + assert.Error(t, err) + assert.Contains(t, err.Error(), "不能删除最后一个管理员") + + database.DB.Unscoped().Delete(&last) +} + +func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) { + a := models.User{Username: "assignable_admin", Password: "x", Name: "A", Role: "admin"} + tech := models.User{Username: "assignable_tech", Password: "x", Name: "T", Role: "technician"} + plain := models.User{Username: "assignable_user", Password: "x", Name: "U", Role: "user"} + database.DB.Create(&a) + database.DB.Create(&tech) + database.DB.Create(&plain) + defer database.DB.Unscoped().Delete(&a) + defer database.DB.Unscoped().Delete(&tech) + defer database.DB.Unscoped().Delete(&plain) + + svc := UsersService{} + users, err := svc.FindAssignable() + assert.NoError(t, err) + + usernames := make(map[string]string) + for _, u := range users { + usernames[u.Username] = u.Role + } + assert.Equal(t, "admin", usernames["assignable_admin"]) + assert.Equal(t, "technician", usernames["assignable_tech"]) + _, hasPlain := usernames["assignable_user"] + assert.False(t, hasPlain, "plain user should not be assignable") +} + +func TestUsersService_FindAll_FilterByRole(t *testing.T) { + tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "technician"} + tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", Role: "technician"} + user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "user"} + database.DB.Create(&tech1) + database.DB.Create(&tech2) + database.DB.Create(&user1) + defer database.DB.Unscoped().Delete(&tech1) + defer database.DB.Unscoped().Delete(&tech2) + defer database.DB.Unscoped().Delete(&user1) + + svc := UsersService{} + results, _, _, err := svc.FindAll(1, 50, "technician", "") + assert.NoError(t, err) + for _, u := range results { + assert.Equal(t, "technician", u.Role) + } +}