Files
cmautobuy/admin/service/client_test.go
T

527 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"database/sql"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// newTestDB 建一个临时库,测试结束自动删。
// 用真实的 migrations,这样表结构变了测试会跟着失败。
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
if err := repository.Migrate(db); err != nil {
t.Fatalf("迁移失败: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// insertTask 插一条待领取的任务,供领取相关的测试使用。
func insertTask(t *testing.T, db *sql.DB, taskID, client string) {
t.Helper()
now := model.NowISO()
_, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, 'TEST-ORDER',
'https://mobile.yangkeduo.com/goods.html?goods_id=1', '1',
'{"color":"黑色","size":"M码"}', 2, 4200, ?, ?)`,
taskID, client, now, now)
if err != nil {
t.Fatalf("插入测试任务失败: %v", err)
}
}
// ── 注册 ───────────────────────────────────────────────
func TestRegisterClient_新客户端被登记(t *testing.T) {
db := newTestDB(t)
err := RegisterClient(db, model.Client{
ClientID: "client-001", Name: "办公室-01",
DeviceAddress: "192.168.0.173:5555", Platform: "android",
}, true)
if err != nil {
t.Fatalf("注册失败: %v", err)
}
views, err := ListClientViews(db, "", time.Minute)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(views) != 1 {
t.Fatalf("期望 1 台客户端,实际 %d", len(views))
}
if views[0].Name != "办公室-01" {
t.Errorf("名称错误:期望 办公室-01,实际 %s", views[0].Name)
}
if views[0].DeviceAddress != "192.168.0.173:5555" {
t.Errorf("设备地址错误:%s", views[0].DeviceAddress)
}
}
func TestRegisterClient_没上报名称时用编号兜底(t *testing.T) {
db := newTestDB(t)
if err := RegisterClient(db, model.Client{ClientID: "client-002"}, true); err != nil {
t.Fatalf("注册失败: %v", err)
}
views, _ := ListClientViews(db, "", time.Minute)
if views[0].Name != "client-002" {
t.Errorf("期望用 client-002 兜底,实际 %q", views[0].Name)
}
}
// 名称规则分两种,下面两个测试各盯一种。规则见 repository.UpsertClient。
// 隐式登记(claim 顺带):**永远不更新名称**。
// 否则操作员在 Admin 精心改的名字,会被客户端每次领取时反复冲掉。
func TestRegisterClient_隐式登记不覆盖名称(t *testing.T) {
db := newTestDB(t)
if err := RegisterClient(db, model.Client{
ClientID: "client-003", Name: "默认名", Platform: "android",
}, true); err != nil {
t.Fatalf("首次注册失败: %v", err)
}
// 操作员在 Admin 界面上改成好记的名字
if _, err := db.Exec(`UPDATE clients SET name = ? WHERE client_id = ?`,
"仓库那台", "client-003"); err != nil {
t.Fatal(err)
}
// 客户端后台 claim,又上报了"默认名" —— explicit=false
if err := RegisterClient(db, model.Client{
ClientID: "client-003", Name: "默认名", Platform: "android",
DeviceAddress: "10.0.0.9:5555",
}, false); err != nil {
t.Fatalf("隐式登记失败: %v", err)
}
views, _ := ListClientViews(db, "", time.Minute)
if views[0].Name != "仓库那台" {
t.Errorf("claim 不该覆盖名称:期望 仓库那台,实际 %s", views[0].Name)
}
// 但设备信息要更新
if views[0].DeviceAddress != "10.0.0.9:5555" {
t.Errorf("设备地址没更新:%s", views[0].DeviceAddress)
}
}
// 显式登记(设置页点保存):带非空名称时**更新**名称。
// 这是用户明确的人为操作,应该能把新名字同步过去。
func TestRegisterClient_显式登记更新名称(t *testing.T) {
db := newTestDB(t)
RegisterClient(db, model.Client{ClientID: "client-003", Name: "旧名"}, true)
if _, err := db.Exec(`UPDATE clients SET name='仓库那台' WHERE client_id='client-003'`); err != nil {
t.Fatal(err)
}
if err := RegisterClient(db, model.Client{
ClientID: "client-003", Name: "办公室-01",
}, true); err != nil {
t.Fatalf("显式登记失败: %v", err)
}
views, _ := ListClientViews(db, "", time.Minute)
if views[0].Name != "办公室-01" {
t.Errorf("显式登记应更新名称:期望 办公室-01,实际 %s", views[0].Name)
}
}
// 显式登记但名称为空:保留原有名称,不要清空。
func TestRegisterClient_显式登记空名称保留原名(t *testing.T) {
db := newTestDB(t)
RegisterClient(db, model.Client{ClientID: "client-003", Name: "办公室-01"}, true)
if err := RegisterClient(db, model.Client{ClientID: "client-003", Name: " "}, true); err != nil {
t.Fatalf("登记失败: %v", err)
}
views, _ := ListClientViews(db, "", time.Minute)
if views[0].Name != "办公室-01" {
t.Errorf("空名称不该清空原名:期望 办公室-01,实际 %q", views[0].Name)
}
}
// ── 在线状态 ───────────────────────────────────────────
func TestIsOnline_阈值边界(t *testing.T) {
now := time.Date(2026, 8, 6, 12, 0, 0, 0, time.UTC)
threshold := 10 * time.Minute
cases := []struct {
name string
lastSeen string
want bool
}{
{"刚刚活动", now.Add(-1 * time.Second).Format(model.TimeLayout), true},
{"阈值内", now.Add(-9 * time.Minute).Format(model.TimeLayout), true},
{"刚好到阈值", now.Add(-10 * time.Minute).Format(model.TimeLayout), false},
{"超过阈值", now.Add(-11 * time.Minute).Format(model.TimeLayout), false},
{"时间解析不了一律算离线", "不是时间", false},
{"空值一律算离线", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := model.Client{LastSeenAt: tc.lastSeen}
if got := c.IsOnline(now, threshold); got != tc.want {
t.Errorf("期望 %v,实际 %v", tc.want, got)
}
})
}
}
func TestTouchClient_刷新活动时间(t *testing.T) {
db := newTestDB(t)
// 造一台很久没活动的客户端
if err := RegisterClient(db, model.Client{ClientID: "client-004"}, true); err != nil {
t.Fatalf("注册失败: %v", err)
}
old := "2020-01-01T00:00:00Z"
if _, err := db.Exec(
`UPDATE clients SET last_seen_at = ? WHERE client_id = ?`,
old, "client-004"); err != nil {
t.Fatalf("改时间失败: %v", err)
}
views, _ := ListClientViews(db, "", 10*time.Minute)
if views[0].Status != "离线" {
t.Fatalf("改时间后应为离线,实际 %s", views[0].Status)
}
if err := TouchClient(db, "client-004"); err != nil {
t.Fatalf("TouchClient 失败: %v", err)
}
views, _ = ListClientViews(db, "", 10*time.Minute)
if views[0].Status != "在线" {
t.Errorf("刷新后应为在线,实际 %s", views[0].Status)
}
}
// ── 搜索与删除 ─────────────────────────────────────────
func TestListClientViews_按名称搜索(t *testing.T) {
db := newTestDB(t)
RegisterClient(db, model.Client{ClientID: "c-1", Name: "办公室-01"}, true)
RegisterClient(db, model.Client{ClientID: "c-2", Name: "仓库-01"}, true)
views, err := ListClientViews(db, "办公室", time.Minute)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
if len(views) != 1 || views[0].ClientID != "c-1" {
t.Errorf("搜索结果不对:%+v", views)
}
}
func TestDeleteClients_批量删除(t *testing.T) {
db := newTestDB(t)
RegisterClient(db, model.Client{ClientID: "c-1"}, true)
RegisterClient(db, model.Client{ClientID: "c-2"}, true)
RegisterClient(db, model.Client{ClientID: "c-3"}, true)
n, err := DeleteClients(db, []string{"c-1", "c-3"})
if err != nil {
t.Fatalf("删除失败: %v", err)
}
if n != 2 {
t.Errorf("期望删除 2 条,实际 %d", n)
}
views, _ := ListClientViews(db, "", time.Minute)
if len(views) != 1 || views[0].ClientID != "c-2" {
t.Errorf("剩余客户端不对:%+v", views)
}
}
// ── 采购员归属(#54)──────────────────────────────────
func prepareClientAssignmentUsers(t *testing.T, db *sql.DB) (*model.User, *model.User, *model.User) {
t.Helper()
now := time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC)
admin := prepareAdminUser(t, db, now)
for _, username := range []string{"buyer-a", "buyer-b"} {
if err := CreatePurchaser(db, admin, username, "buyer-password", "buyer-password", now); err != nil {
t.Fatalf("准备采购员 %s 失败: %v", username, err)
}
}
a, _ := repository.FindUserByUsername(db, "buyer-a")
b, _ := repository.FindUserByUsername(db, "buyer-b")
return admin, a, b
}
func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testing.T) {
db := newTestDB(t)
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
RegisterClient(db, model.Client{ClientID: "c-2", Name: "二号机"}, true)
now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
for _, clientID := range []string{"c-1", "c-2"} {
changed, transferred, err := AssignClient(db, admin, clientID, buyerA.UserID, now)
if err != nil || !changed || transferred {
t.Fatalf("绑定 %s 失败: changed=%t transferred=%t err=%v", clientID, changed, transferred, err)
}
}
aRows, err := ListClientViewsForUser(db, buyerA, "", time.Minute)
if err != nil || len(aRows) != 2 {
t.Fatalf("采购员 A 应看到两台客户端,rows=%+v err=%v", aRows, err)
}
bRows, err := ListAssignableClients(db, buyerB, time.Minute)
if err != nil || len(bRows) != 0 {
t.Fatalf("采购员 B 不应看到 A 的客户端,rows=%+v err=%v", bRows, err)
}
adminRows, _ := ListClientViewsForUser(db, admin, "", time.Minute)
if len(adminRows) != 2 || adminRows[0].AssignedUsername != "buyer-a" {
t.Fatalf("管理员应看到全部客户端及负责人,实际 %+v", adminRows)
}
}
func TestClientAssignment_转交解绑保留审计且不改任务(t *testing.T) {
db := newTestDB(t)
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
insertTask(t, db, "TASK-KEEP", "c-1")
t1 := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
t2 := t1.Add(time.Hour)
t3 := t2.Add(time.Hour)
if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, t1); err != nil {
t.Fatal(err)
}
changed, transferred, err := AssignClient(db, admin, "c-1", buyerB.UserID, t2)
if err != nil || !changed || !transferred {
t.Fatalf("转交失败: changed=%t transferred=%t err=%v", changed, transferred, err)
}
if err := UnassignClient(db, admin, "c-1", t3); err != nil {
t.Fatalf("解绑失败: %v", err)
}
rows, err := db.Query(`SELECT user_id, started_at, ended_at, assigned_by_user_id,
ended_by_user_id, end_reason FROM client_user_assignments
WHERE client_id = 'c-1' ORDER BY started_at`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
type audit struct{ user, started, ended, assignedBy, endedBy, reason string }
audits := make([]audit, 0, 2)
for rows.Next() {
var a audit
if err := rows.Scan(&a.user, &a.started, &a.ended, &a.assignedBy, &a.endedBy, &a.reason); err != nil {
t.Fatal(err)
}
audits = append(audits, a)
}
if len(audits) != 2 || audits[0].reason != "transfer" || audits[1].reason != "unbind" {
t.Fatalf("归属历史不完整: %+v", audits)
}
for _, a := range audits {
if a.assignedBy != admin.UserID || a.endedBy != admin.UserID || a.ended == "" {
t.Errorf("操作管理员或结束时间未记录: %+v", a)
}
}
var assignedClient string
var status model.TaskStatus
if err := db.QueryRow(`SELECT assigned_client, status FROM tasks WHERE task_id = 'TASK-KEEP'`).Scan(&assignedClient, &status); err != nil {
t.Fatal(err)
}
if assignedClient != "c-1" || status != model.TaskAssigned {
t.Errorf("归属变化不应修改任务,client=%q status=%q", assignedClient, status)
}
}
func TestClientAssignment_权限禁用账号唯一约束及重新登记(t *testing.T) {
db := newTestDB(t)
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
if _, _, err := AssignClient(db, buyerA, "c-1", buyerB.UserID, now); !errors.Is(err, ErrAdminRequired) {
t.Fatalf("采购员不应有绑定权限,实际 %v", err)
}
if err := ChangeUserStatus(db, admin, buyerB.UserID, model.UserDisabled, now); err != nil {
t.Fatal(err)
}
if _, _, err := AssignClient(db, admin, "c-1", buyerB.UserID, now); !errors.Is(err, repository.ErrPurchaserNotActive) {
t.Fatalf("禁用采购员不应成为目标,实际 %v", err)
}
if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, now); err != nil {
t.Fatal(err)
}
// 数据库索引是最后一道并发保护,直接插入第二条当前归属必须失败。
if _, err := db.Exec(`INSERT INTO client_user_assignments
(assignment_id, client_id, user_id, started_at, assigned_by_user_id)
VALUES ('duplicate', 'c-1', ?, ?, ?)`, buyerA.UserID, model.NowISO(), admin.UserID); err == nil {
t.Fatal("同一客户端插入第二个当前负责人应该被唯一索引拒绝")
}
if n, err := DeleteClients(db, []string{"c-1"}); err != nil || n != 1 {
t.Fatalf("删除客户端失败: n=%d err=%v", n, err)
}
RegisterClient(db, model.Client{ClientID: "c-1", Name: "重新登记的一号机"}, true)
views, err := ListClientViewsForUser(db, buyerA, "", time.Minute)
if err != nil || len(views) != 1 || views[0].AssignedUsername != "buyer-a" {
t.Fatalf("同编号重新登记后应恢复当前归属,views=%+v err=%v", views, err)
}
}
// ── 领取任务 ───────────────────────────────────────────
func TestClaimNextTask_没有任务返回nil(t *testing.T) {
db := newTestDB(t)
RegisterClient(db, model.Client{ClientID: "client-001"}, true)
task, err := ClaimNextTask(db, "client-001", []string{"collect", "purchase"})
if err != nil {
t.Fatalf("领取失败: %v", err)
}
if task != nil {
t.Errorf("没有任务时应返回 nil,实际拿到 %+v", task)
}
}
func TestClaimNextTask_只领分配给自己的(t *testing.T) {
db := newTestDB(t)
insertTask(t, db, "TASK-A", "client-001")
insertTask(t, db, "TASK-B", "client-002")
task, err := ClaimNextTask(db, "client-002", []string{"purchase"})
if err != nil {
t.Fatalf("领取失败: %v", err)
}
if task == nil {
t.Fatal("应该领到 TASK-B,实际没领到")
}
if task.TaskID != "TASK-B" {
t.Errorf("领错了任务:期望 TASK-B,实际 %s", task.TaskID)
}
}
func TestClaimNextTask_同一任务不会被领两次(t *testing.T) {
db := newTestDB(t)
insertTask(t, db, "TASK-A", "client-001")
first, _ := ClaimNextTask(db, "client-001", []string{"purchase"})
if first == nil {
t.Fatal("第一次应该领到任务")
}
second, err := ClaimNextTask(db, "client-001", []string{"purchase"})
if err != nil {
t.Fatalf("第二次领取报错: %v", err)
}
if second != nil {
t.Errorf("同一任务被领了两次:%s", second.TaskID)
}
}
// 并发领取:多个 goroutine 同时抢一条任务,只能有一个拿到。
func TestClaimNextTask_并发只有一个拿到(t *testing.T) {
db := newTestDB(t)
insertTask(t, db, "TASK-ONLY-ONE", "client-001")
const workers = 8
var (
wg sync.WaitGroup
mu sync.Mutex
gotIt int
lastErr error
)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
task, err := ClaimNextTask(db, "client-001", []string{"purchase"})
mu.Lock()
defer mu.Unlock()
if err != nil {
lastErr = err
return
}
if task != nil {
gotIt++
}
}()
}
wg.Wait()
if lastErr != nil {
t.Fatalf("并发领取出错: %v", lastErr)
}
if gotIt != 1 {
t.Errorf("同一任务被 %d 个客户端领到,期望正好 1 个", gotIt)
}
}
func TestClaimNextTask_不领不支持的类型(t *testing.T) {
db := newTestDB(t)
insertTask(t, db, "TASK-P", "client-001") // 这是 purchase 类型
task, err := ClaimNextTask(db, "client-001", []string{"collect"})
if err != nil {
t.Fatalf("领取失败: %v", err)
}
if task != nil {
t.Errorf("只支持 collect 的客户端不该拿到 purchase 任务:%s", task.TaskID)
}
}
func TestClaimNextTask_返回的任务带齐必填字段(t *testing.T) {
db := newTestDB(t)
insertTask(t, db, "TASK-A", "client-001")
task, _ := ClaimNextTask(db, "client-001", []string{"purchase"})
if task == nil {
t.Fatal("应该领到任务")
}
// Client 契约要求:goods_url 必有值
if task.PddGoodsURL == "" {
t.Error("pdd_goods_url 为空,Client 无法执行")
}
// 采购任务必须带价格保护
if task.Quantity <= 0 {
t.Errorf("采购任务的数量必须 > 0,实际 %d", task.Quantity)
}
if task.MaxPriceCent <= 0 {
t.Errorf("采购任务的价格上限必须 > 0,实际 %d", task.MaxPriceCent)
}
if task.Status != model.TaskClaimed {
t.Errorf("领取后状态应为 claimed,实际 %s", task.Status)
}
}
// 确认迁移文件路径拼接没问题(顺带覆盖 repository.Open)
func TestOpen_数据库文件落在指定目录(t *testing.T) {
dir := t.TempDir()
db, err := repository.Open(dir)
if err != nil {
t.Fatalf("打开失败: %v", err)
}
defer db.Close()
if _, err := repository.Migrate(db), error(nil); err != nil {
t.Fatal(err)
}
if _, err := filepath.Abs(filepath.Join(dir, "admin.db")); err != nil {
t.Fatal(err)
}
}