Files
cmautobuy/admin/service/client_test.go
T
chengmaandClaude Opus 5 5a5c1f1f68 feat: 新增幂等 Client 登记接口 (#12)
PUT /api/v1/client/registration —— 设置页点"保存"时调用,
只登记客户端,不碰任务。

为什么需要它
原设计"注册就在 claim 里做"有个真问题:设置页保存被迫调 claim,
而 claim 可能真的领到一个任务——Admin 那边已把任务标成 claimed,
Client 必须可靠落库否则任务就丢了。一个"保存设置"的动作
不该承担"领取任务并保证不丢"的责任。这违反了本项目自己的原则
(05 §1:界面上只有一个会产生外部后果的命令)。

实现
- ClientProfileRequest + Validate() 由**登记和领取共用**,
  避免两个入口的结构和校验各写一份、迟早漂移
- 校验:名称 <=50 字(按字符不按字节,中文一个字三字节)、
  supported_types 非空且只含 collect/purchase、platform 只支持 android、
  purchase_mode 必填且只允许 dry_run/live、schema_versions 均为正整数
- 非法内容返回 422 INVALID_CLIENT_PROFILE,错误消息指明具体字段
- UpsertClient 加 explicit 参数区分名称规则:
  显式登记(用户点保存)带非空名称时更新名称;
  隐式登记(claim 顺带)永不更新,否则操作员改的名字会被反复冲掉

已验证(Go 1.23.0)
- 单元测试 40 个全过,含"登记不产生任何任务副作用"的快照比对
- 端到端逐条走完手册 §5.2~5.7:重复登记记录数恒为 1;
  更新/空名称行为正确;插入任务后登记 3 次任务字段完全未变且仍可领取;
  四种非法输入均 422 且不写库;claim 不受影响

一处行为变更需注意
名称归属规则改了:原来是"Admin 操作员永远赢",现在是"最后一次
显式操作赢"——用户在 Client 点保存会覆盖 Admin 侧改的名字。
按 #12 文档实现,已拆成三个独立测试盯住三种情况。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:31:38 +08:00

397 lines
12 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"
"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)
}
}
// ── 领取任务 ───────────────────────────────────────────
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)
}
}