package service import ( "database/sql" "errors" "fmt" "strings" "testing" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) func TestListTasksView_统一每页20条并收敛越界页(t *testing.T) { db := newTestDB(t) for i := 0; i < 25; i++ { insertTestTask(t, db, testTaskParams{ taskID: fmt.Sprintf("PAGE-%02d", i), taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: fmt.Sprintf("%d", i+1), }) } second, err := ListTasksView(db, repository.TaskFilter{}, 2) if err != nil { t.Fatal(err) } if len(second.Rows) != 5 || second.Total != 25 || second.Page != 2 || second.TotalPages != 2 { t.Fatalf("第二页结果不对: rows=%d total=%d page=%d/%d", len(second.Rows), second.Total, second.Page, second.TotalPages) } overflow, err := ListTasksView(db, repository.TaskFilter{}, 999) if err != nil { t.Fatal(err) } if overflow.Page != 2 || len(overflow.Rows) != 5 { t.Fatalf("越界页应收敛到末页: page=%d rows=%d", overflow.Page, len(overflow.Rows)) } } // insertTestTask 插一条任务,字段按需覆盖默认值,供本文件的用例使用。 // 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值, // 这里需要覆盖类型、状态、options 等更多维度。 type testTaskParams struct { taskID string taskType model.TaskType status model.TaskStatus assignedClient string sybID string orderNo string goodsID string pddGoodsID string pddOptions string quantity int maxPriceCent int64 errorCode string errorMessage string resultData string claimedAt string finishedAt string createdByUserID string } func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) { t.Helper() now := model.NowISO() var assigned, sybID, orderNo, goodsID, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any if p.assignedClient != "" { assigned = p.assignedClient } if p.orderNo != "" { orderNo = p.orderNo } if p.sybID != "" { sybID = p.sybID } if p.goodsID != "" { goodsID = p.goodsID } if p.pddGoodsID != "" { pddGoodsID = p.pddGoodsID } if p.pddOptions != "" { pddOptions = p.pddOptions } if p.errorCode != "" { errCode = p.errorCode } if p.errorMessage != "" { errMsg = p.errorMessage } if p.resultData != "" { resultData = p.resultData } if p.claimedAt != "" { claimedAt = p.claimedAt } if p.finishedAt != "" { finishedAt = p.finishedAt } var quantity, maxPrice any if p.quantity > 0 { quantity = p.quantity } if p.maxPriceCent > 0 { maxPrice = p.maxPriceCent } _, err := db.Exec(` INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at, syb_id, order_no, goods_id, pdd_goods_url, pdd_goods_id, pdd_options, quantity, max_price_cent, error_code, error_message, result_data, finished_at, created_by_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.taskID, string(p.taskType), string(p.status), assigned, claimedAt, sybID, orderNo, goodsID, pddGoodsID, pddOptions, quantity, maxPrice, errCode, errMsg, resultData, finishedAt, nullableString(p.createdByUserID), now, now) if err != nil { t.Fatalf("插入测试任务 %s 失败: %v", p.taskID, err) } } func nullableString(value string) any { if value == "" { return nil } return value } // ── 列表:同时显示采集和采购 ────────────────────────── func TestListTasksView_同时显示采集和采购(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) result, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(result.Rows) != 2 { t.Fatalf("应该看到 2 条任务,实际 %d 条", len(result.Rows)) } } // ── 类型 / 状态 / 关键词筛选 ────────────────────────── func TestListTasksView_类型筛选生效(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) result, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-1" { t.Fatalf("筛选采集应该只剩 COL-1,实际 %+v", result.Rows) } if !result.IsFiltered { t.Error("有类型筛选时 IsFiltered 应该为 true") } } func TestListTasksView_状态筛选生效(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"}) result, err := ListTasksView(db, repository.TaskFilter{Status: model.TaskSucceeded}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(result.Rows) != 1 || result.Rows[0].TaskID != "COL-2" { t.Fatalf("筛选'成功'应该只剩 COL-2,实际 %+v", result.Rows) } } func TestListTasksView_关键词同时匹配任务编号订单号PDD商品ID(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-ABC", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-XYZ"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-OTHER"}) // 按任务编号命中 r, err := ListTasksView(db, repository.TaskFilter{Keyword: "ABC"}, 1) if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" { t.Fatalf("按任务编号搜索失败: err=%v rows=%+v", err, r) } // 按订单号命中 r, err = ListTasksView(db, repository.TaskFilter{Keyword: "XYZ"}, 1) if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "PUR-1" { t.Fatalf("按订单号搜索失败: err=%v rows=%+v", err, r) } // 按 PDD 商品 ID 命中 r, err = ListTasksView(db, repository.TaskFilter{Keyword: "737116531267"}, 1) if err != nil || len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-ABC" { t.Fatalf("按 PDD 商品 ID 搜索失败: err=%v rows=%+v", err, r) } } // ── 目标列 ──────────────────────────────────────────── func TestListTasksView_目标列_采集任务显示PDD商品ID(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } want := "PDD 737116531267" if r.Rows[0].Target != want { t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) } } func TestListTasksView_目标列_采集任务能join到标题时追加显示(t *testing.T) { db := newTestDB(t) if _, err := db.Exec(` INSERT INTO pdd_products (goods_id, url, title, collect_status, created_at, updated_at) VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', '秋冬保暖加绒卫衣', 'collected', ?, ?)`, model.NowISO(), model.NowISO()); err != nil { t.Fatalf("插入 PDD 商品失败: %v", err) } insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "737116531267"}) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } want := "PDD 737116531267 · 秋冬保暖加绒卫衣" if r.Rows[0].Target != want { t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) } } func TestListTasksView_目标列_商品已软删除时退回显示商品ID(t *testing.T) { db := newTestDB(t) now := model.NowISO() if _, err := db.Exec(` INSERT INTO pdd_products (goods_id, url, title, collect_status, deleted_at, created_at, updated_at) VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', '秋冬保暖加绒卫衣', 'collected', ?, ?, ?)`, now, now, now); err != nil { t.Fatalf("插入 PDD 商品失败: %v", err) } insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "737116531267"}) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } want := "PDD 737116531267" if r.Rows[0].Target != want { t.Errorf("商品已软删除时应退回只显示商品 ID,实际 %q", r.Rows[0].Target) } } func TestListTasksView_目标列_采购任务显示订单号规格数量价格上限(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{ taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`, quantity: 2, maxPriceCent: 4200, }) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } want := "SO-001 · 黑色/M · 2件 · ≤¥42.00" if r.Rows[0].Target != want { t.Errorf("目标列 = %q,want %q", r.Rows[0].Target, want) } } // ── 客户端列 ────────────────────────────────────────── func TestListTasksView_无主任务客户端列显示占位符(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if r.Rows[0].ClientText != "—" { t.Errorf("无主任务客户端列应该显示占位符,实际 %q", r.Rows[0].ClientText) } } func TestListTasksView_显示PDD店铺和客户端名称并保留ID提示(t *testing.T) { db := newTestDB(t) now := model.NowISO() if _, err := db.Exec(` INSERT INTO pdd_products (goods_id, url, title, shop_name, collect_status, created_at, updated_at) VALUES ('737116531267', 'https://mobile.yangkeduo.com/goods.html?goods_id=737116531267', '测试商品', '测试旗舰店', 'collected', ?, ?)`, now, now); err != nil { t.Fatal(err) } if _, err := db.Exec(` INSERT INTO clients (client_id, name, last_seen_at, created_at, updated_at) VALUES ('client-001', '办公室手机', ?, ?, ?)`, now, now, now); err != nil { t.Fatal(err) } insertTestTask(t, db, testTaskParams{ taskID: "COL-SHOP", taskType: model.TaskCollect, status: model.TaskAssigned, pddGoodsID: "737116531267", assignedClient: "client-001", }) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatal(err) } if len(r.Rows) != 1 || r.Rows[0].PddShopText != "测试旗舰店" { t.Fatalf("列表应显示 PDD 店铺,实际 %+v", r.Rows) } if r.Rows[0].ClientText != "办公室手机" || r.Rows[0].ClientTitle != "办公室手机(client-001)" { t.Errorf("客户端应显示名称并在提示保留 ID,实际 text=%q title=%q", r.Rows[0].ClientText, r.Rows[0].ClientTitle) } if _, err := db.Exec(`UPDATE clients SET name='仓库手机' WHERE client_id='client-001'`); err != nil { t.Fatal(err) } renamed, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatal(err) } if renamed.Rows[0].ClientText != "仓库手机" { t.Errorf("客户端重命名后列表应显示当前名称,实际 %q", renamed.Rows[0].ClientText) } detail, err := GetTaskDetail(db, "COL-SHOP") if err != nil { t.Fatal(err) } if detail.PddShopText != "测试旗舰店" || detail.ClientText != "仓库手机" || detail.ClientIDText != "client-001" { t.Errorf("详情展示信息不完整:%+v", detail) } } func TestListTasksView_关联缺失时店铺占位且客户端回退ID(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{ taskID: "COL-FALLBACK", taskType: model.TaskCollect, status: model.TaskAssigned, pddGoodsID: "missing-product", assignedClient: "deleted-client", }) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatal(err) } if r.Rows[0].PddShopText != "—" || r.Rows[0].ClientText != "deleted-client" || r.Rows[0].ClientTitle != "deleted-client" { t.Errorf("关联缺失回退错误:%+v", r.Rows[0]) } } // ── 统计跟随筛选 ────────────────────────────────────── func TestListTasksView_统计跟随当前筛选(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskSucceeded, pddGoodsID: "222"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-1"}) insertTestTask(t, db, testTaskParams{taskID: "PUR-2", taskType: model.TaskPurchase, status: model.TaskAssigned, orderNo: "SO-2"}) all, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if all.Total != 4 { t.Fatalf("不筛选时总数应该是 4,实际 %d", all.Total) } collectOnly, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskCollect}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if collectOnly.Total != 2 { t.Fatalf("筛选采集后总数应该是 2,实际 %d", collectOnly.Total) } if collectOnly.Counts[model.TaskAssigned] != 0 { t.Errorf("筛选采集后不该统计到采购任务的'待领取',实际 %d", collectOnly.Counts[model.TaskAssigned]) } if collectOnly.Counts[model.TaskPending] != 1 || collectOnly.Counts[model.TaskSucceeded] != 1 { t.Errorf("筛选采集后的状态分布不对: %+v", collectOnly.Counts) } } // ── 空状态 ──────────────────────────────────────────── func TestListTasksView_从未建过任务时不算筛选无结果(t *testing.T) { db := newTestDB(t) r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(r.Rows) != 0 { t.Fatalf("空库应该查出 0 条") } if r.IsFiltered { t.Error("没有任何筛选条件时 IsFiltered 应该为 false,模板据此显示'还没有任务'而不是'筛选无结果'") } } func TestListTasksView_筛选无结果时IsFiltered为true(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) r, err := ListTasksView(db, repository.TaskFilter{Type: model.TaskPurchase}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(r.Rows) != 0 { t.Fatalf("筛选采购应该查出 0 条") } if !r.IsFiltered { t.Error("筛选出 0 条时 IsFiltered 应该为 true,模板据此显示'筛选无结果'而不是'还没有任务'") } } // ── 详情弹窗 ────────────────────────────────────────── func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{ taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskClaimed, sybID: "SYB-001", orderNo: "SO-001", goodsID: "SHOPEE-001", pddGoodsID: "PDD-001", pddOptions: `{"color":"黑色","size":"M"}`, quantity: 2, maxPriceCent: 4200, assignedClient: "client-001", claimedAt: model.NowISO(), }) d, err := GetTaskDetail(db, "PUR-1") if err != nil { t.Fatalf("查询失败: %v", err) } if d == nil { t.Fatal("任务应该存在") } if !d.IsPurchase { t.Error("采购任务 IsPurchase 应该为 true") } if d.SpecText != "黑色/M" || d.QuantityText != "2件" || d.PriceLimitText != "≤¥42.00" { t.Errorf("采购专有字段不对: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText) } if d.ClientText != "client-001" { t.Errorf("分配客户端 = %q, want client-001", d.ClientText) } if d.ShopeeOrderNo != "SO-001" || d.SybID != "SYB-001" || d.ShopeeGoodsID != "SHOPEE-001" || d.PddGoodsID != "PDD-001" || !d.HasSybSource { t.Errorf("采购来源字段不完整: %+v", d) } if d.ViewSybURL != "/syb?open_syb_id=SYB-001&order_no=SO-001" || d.CanRecreatePurchase || d.RecreatePurchaseURL != "" { t.Errorf("执行中的采购任务只应允许查看来源: view=%q recreate=%t url=%q", d.ViewSybURL, d.CanRecreatePurchase, d.RecreatePurchaseURL) } } func TestGetTaskDetail_只有失败和取消允许进入重新创建确认(t *testing.T) { db := newTestDB(t) statuses := []model.TaskStatus{ model.TaskPending, model.TaskAssigned, model.TaskClaimed, model.TaskSucceeded, model.TaskManualReview, model.TaskFailed, model.TaskCancelled, } for _, status := range statuses { taskID := "PUR-" + string(status) insertTestTask(t, db, testTaskParams{ taskID: taskID, taskType: model.TaskPurchase, status: status, sybID: "SYB A&B", orderNo: "ORDER/1", goodsID: "SHOPEE-1", pddGoodsID: "PDD-1", }) detail, err := GetTaskDetail(db, taskID) if err != nil { t.Fatal(err) } want := status == model.TaskFailed || status == model.TaskCancelled if detail.CanRecreatePurchase != want { t.Errorf("状态 %s CanRecreatePurchase=%t,want %t", status, detail.CanRecreatePurchase, want) } if want && detail.RecreatePurchaseURL != "/syb?open_syb_id=SYB+A%26B&order_no=ORDER%2F1&purchase_intent=1" { t.Errorf("状态 %s 的深链接编码错误: %q", status, detail.RecreatePurchaseURL) } } } func TestGetTaskDetail_采购成功独立显示PDD核单结果(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{ taskID: "PUR-ORDER", taskType: model.TaskPurchase, status: model.TaskSucceeded, resultData: `{"purchase":{"order_no":"PDD-20260810-001","ordered_at":"2026-08-10T11:12:13Z","payment_status":"unpaid","match_status":"matched","order_submitted":true}}`, }) d, err := GetTaskDetail(db, "PUR-ORDER") if err != nil { t.Fatal(err) } if !d.HasPddOrder || d.PddOrderNo != "PDD-20260810-001" { t.Fatalf("PDD 订单编号没有结构化显示: %+v", d) } if d.PddOrderedAt != formatLocalTime("2026-08-10T11:12:13Z") { t.Errorf("UTC 下单时间应转为本地时间,实际 %q", d.PddOrderedAt) } if d.PddPaymentStatusText != "未付款(Client 上报时)" || d.PddOrderResultText != "Admin 不会查询 PDD 实时付款状态。" { t.Errorf("付款状态说明不明确: status=%q help=%q", d.PddPaymentStatusText, d.PddOrderResultText) } if !d.HasResult || !strings.Contains(d.ResultData, `"order_no":"PDD-20260810-001"`) { t.Error("结构化展示后仍必须保留原始结果") } } func TestGetTaskDetail_核单结果状态分别显示(t *testing.T) { db := newTestDB(t) tests := []struct { name string status model.TaskStatus resultData string want string }{ {"等待上报", model.TaskClaimed, "", "等待 Client 核单上报。"}, {"人工处理", model.TaskManualReview, "", "订单结果不确定,请结合错误信息人工核对。"}, {"非法JSON", model.TaskSucceeded, `{`, "采购结果格式异常,请展开完整结果进行诊断。"}, {"缺少purchase", model.TaskSucceeded, `{}`, "采购结果格式异常,请展开完整结果进行诊断。"}, {"尚未匹配", model.TaskSucceeded, `{"purchase":{"match_status":"not_found"}}`, "本次结果没有已匹配的 PDD 订单。"}, {"时间非法", model.TaskSucceeded, `{"purchase":{"order_no":"PDD-1","ordered_at":"2026-08-10 11:12","payment_status":"unpaid","match_status":"matched","order_submitted":true}}`, "采购结果中的下单时间格式异常,请展开完整结果进行诊断。"}, } for i, tc := range tests { t.Run(tc.name, func(t *testing.T) { taskID := fmt.Sprintf("PUR-STATE-%d", i) insertTestTask(t, db, testTaskParams{ taskID: taskID, taskType: model.TaskPurchase, status: tc.status, resultData: tc.resultData, }) d, err := GetTaskDetail(db, taskID) if err != nil { t.Fatal(err) } if d.HasPddOrder || d.PddOrderResultText != tc.want { t.Errorf("状态说明不对: HasPddOrder=%t text=%q", d.HasPddOrder, d.PddOrderResultText) } }) } } func TestGetTaskDetail_采集任务不带采购专有字段(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) d, err := GetTaskDetail(db, "COL-1") if err != nil { t.Fatalf("查询失败: %v", err) } if d.IsPurchase { t.Error("采集任务 IsPurchase 应该为 false,模板据此隐藏采购专有字段") } if d.SpecText != "" || d.QuantityText != "" || d.PriceLimitText != "" { t.Errorf("采集任务不该填充采购专有字段: spec=%q qty=%q price=%q", d.SpecText, d.QuantityText, d.PriceLimitText) } if d.HasPddOrder || d.PddOrderResultText != "" { t.Error("采集任务不应生成 PDD 核单结果展示") } if d.HasSybSource || d.ShopeeOrderNo != "" || d.CanRecreatePurchase { t.Error("采集任务不应生成采购来源和重新创建入口") } } func TestGetTaskDetail_有错误信息时显示(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{ taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskFailed, pddGoodsID: "111", errorCode: "PDD_PAGE_TIMEOUT", errorMessage: "商品页加载超时", }) d, err := GetTaskDetail(db, "COL-1") if err != nil { t.Fatalf("查询失败: %v", err) } if !d.HasError { t.Fatal("应该检测到错误信息") } if d.ErrorCode != "PDD_PAGE_TIMEOUT" || d.ErrorMessage != "商品页加载超时" { t.Errorf("错误信息不对: code=%q msg=%q", d.ErrorCode, d.ErrorMessage) } } func TestGetTaskDetail_没有错误信息时不显示错误段(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) d, err := GetTaskDetail(db, "COL-1") if err != nil { t.Fatalf("查询失败: %v", err) } if d.HasError { t.Error("没有错误码和错误说明时 HasError 应该为 false") } } func TestGetTaskDetail_任务不存在返回nil(t *testing.T) { db := newTestDB(t) d, err := GetTaskDetail(db, "NOT-EXIST") if err != nil { t.Fatalf("不该报错: %v", err) } if d != nil { t.Error("不存在的任务应该返回 nil") } } // ── 删除 ────────────────────────────────────────────── func TestDeleteTasks_批量删除(t *testing.T) { db := newTestDB(t) insertTestTask(t, db, testTaskParams{taskID: "COL-1", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "COL-2", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "222"}) n, err := DeleteTasks(db, []string{"COL-1"}) if err != nil { t.Fatalf("删除失败: %v", err) } if n != 1 { t.Fatalf("应该删除 1 条,实际 %d", n) } r, err := ListTasksView(db, repository.TaskFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } if len(r.Rows) != 1 || r.Rows[0].TaskID != "COL-2" { t.Fatalf("剩余任务应该只有 COL-2,实际 %+v", r.Rows) } } func TestDeleteTasks_删除最后有效采集任务才回收商品状态(t *testing.T) { db := newTestDB(t) if _, err := repository.EnsurePddProduct(db, "111", "https://mobile.yangkeduo.com/goods.html?goods_id=111"); err != nil { t.Fatal(err) } if _, err := db.Exec(`UPDATE pdd_products SET collect_status='collecting' WHERE goods_id='111'`); err != nil { t.Fatal(err) } insertTestTask(t, db, testTaskParams{taskID: "COL-A", taskType: model.TaskCollect, status: model.TaskPending, pddGoodsID: "111"}) insertTestTask(t, db, testTaskParams{taskID: "COL-B", taskType: model.TaskCollect, status: model.TaskAssigned, pddGoodsID: "111"}) if _, err := DeleteTasks(db, []string{"COL-A"}); err != nil { t.Fatal(err) } product, err := repository.GetPddProductByGoodsID(db, "111") if err != nil || product.CollectStatus != model.CollectCollecting { t.Fatalf("仍有有效任务时不应回收:status=%s err=%v", product.CollectStatus, err) } if _, err := DeleteTasks(db, []string{"COL-B"}); err != nil { t.Fatal(err) } product, err = repository.GetPddProductByGoodsID(db, "111") if err != nil || product.CollectStatus != model.CollectPending { t.Fatalf("删除最后有效任务后应回收为 pending:status=%s err=%v", product.CollectStatus, err) } } func TestTaskScope_采购员只能查看自己的任务且不能伪造筛选(t *testing.T) { db := newTestDB(t) admin, buyerA, buyerB := insertTaskUsers(t, db) insertTestTask(t, db, testTaskParams{taskID: "OWN-A", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerA.UserID}) insertTestTask(t, db, testTaskParams{taskID: "OWN-B", taskType: model.TaskCollect, status: model.TaskSucceeded, createdByUserID: buyerB.UserID}) insertTestTask(t, db, testTaskParams{taskID: "HISTORY", taskType: model.TaskCollect, status: model.TaskFailed}) buyerResult, err := ListTasksViewForUser(db, buyerA, repository.TaskFilter{}, TaskCreatorHistoryValue, 1) if err != nil { t.Fatal(err) } if buyerResult.Total != 1 || len(buyerResult.Rows) != 1 || buyerResult.Rows[0].TaskID != "OWN-A" { t.Fatalf("采购员范围不正确: %+v", buyerResult.Rows) } if buyerResult.Counts[model.TaskSucceeded] != 0 || buyerResult.Counts[model.TaskFailed] != 0 { t.Fatalf("采购员统计泄露了其他任务: %+v", buyerResult.Counts) } history, err := ListTasksViewForUser(db, admin, repository.TaskFilter{}, TaskCreatorHistoryValue, 1) if err != nil || history.Total != 1 || history.Rows[0].TaskID != "HISTORY" { t.Fatalf("管理员历史任务筛选不正确: err=%v rows=%+v", err, history.Rows) } byBuyer, err := ListTasksViewForUser(db, admin, repository.TaskFilter{}, buyerB.UserID, 1) if err != nil || byBuyer.Total != 1 || byBuyer.Rows[0].TaskID != "OWN-B" { t.Fatalf("管理员创建人筛选不正确: err=%v rows=%+v", err, byBuyer.Rows) } } func TestTaskScope_详情越权返回不存在且批量删除完整回滚(t *testing.T) { db := newTestDB(t) admin, buyerA, buyerB := insertTaskUsers(t, db) insertTestTask(t, db, testTaskParams{taskID: "OWN-A", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerA.UserID}) insertTestTask(t, db, testTaskParams{taskID: "OWN-B", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerB.UserID}) detail, err := GetTaskDetailForUser(db, buyerA, "OWN-B") if err != nil || detail != nil { t.Fatalf("越权详情应表现为不存在: detail=%+v err=%v", detail, err) } if _, err := DeleteTasksForUser(db, buyerA, []string{"OWN-A", "OWN-B"}); !errors.Is(err, ErrTaskNotVisible) { t.Fatalf("混合越权删除应整批拒绝: %v", err) } for _, id := range []string{"OWN-A", "OWN-B"} { visible, err := repository.TaskVisibleToUser(db, id, "") if err != nil || !visible { t.Fatalf("回滚后任务 %s 应仍存在: visible=%t err=%v", id, visible, err) } } if n, err := DeleteTasksForUser(db, buyerA, []string{"OWN-A"}); err != nil || n != 1 { t.Fatalf("采购员删除本人任务失败: n=%d err=%v", n, err) } if n, err := DeleteTasksForUser(db, admin, []string{"OWN-B"}); err != nil || n != 1 { t.Fatalf("管理员删除任意任务失败: n=%d err=%v", n, err) } } func TestTaskCreatorOptions_包含禁用账号和历史任务(t *testing.T) { db := newTestDB(t) admin, _, buyerB := insertTaskUsers(t, db) if _, err := db.Exec(`UPDATE users SET status='disabled' WHERE user_id=?`, buyerB.UserID); err != nil { t.Fatal(err) } options, err := TaskCreatorOptions(db, admin) if err != nil { t.Fatal(err) } texts := map[string]string{} for _, option := range options { texts[option.Value] = option.Text } if texts[TaskCreatorHistoryValue] != "历史任务" || texts[buyerB.UserID] != buyerB.Username+"(已禁用)" { t.Fatalf("创建人选项不完整: %+v", texts) } } func insertTaskUsers(t *testing.T, db *sql.DB) (*model.User, *model.User, *model.User) { t.Helper() now := model.NowISO() users := []*model.User{ {UserID: "task-admin", Username: "task-admin", PasswordHash: "hash", Role: model.RoleAdmin, Status: model.UserActive}, {UserID: "task-buyer-a", Username: "task-buyer-a", PasswordHash: "hash", Role: model.RolePurchaser, Status: model.UserActive}, {UserID: "task-buyer-b", Username: "task-buyer-b", PasswordHash: "hash", Role: model.RolePurchaser, Status: model.UserActive}, } for _, user := range users { user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt = now, now, now if err := repository.CreateUser(db, *user); err != nil { t.Fatalf("插入任务测试用户失败: %v", err) } } return users[0], users[1], users[2] } // ── 目标列拼接的边界情况 ────────────────────────────── func TestSpecText_空或无法解析时返回占位符(t *testing.T) { if got := specText(""); got != placeholder { t.Errorf("空 options: got %q", got) } if got := specText("not json"); got != placeholder { t.Errorf("无法解析: got %q", got) } } func TestQuantityText_零或负数返回占位符(t *testing.T) { if got := quantityText(0); got != placeholder+"件" { t.Errorf("数量为 0: got %q", got) } if got := quantityText(3); got != "3件" { t.Errorf("数量为 3: got %q", got) } } func TestPriceLimitText_零或负数返回占位符(t *testing.T) { if got := priceLimitText(0); got != "≤"+placeholder { t.Errorf("价格为 0: got %q", got) } if got := priceLimitText(4200); got != "≤¥42.00" { t.Errorf("价格为 4200 分: got %q", got) } }