package service import ( "database/sql" "fmt" "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 orderNo string pddGoodsID string pddOptions string quantity int maxPriceCent int64 errorCode string errorMessage string resultData string claimedAt string finishedAt string } func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) { t.Helper() now := model.NowISO() var assigned, orderNo, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any if p.assignedClient != "" { assigned = p.assignedClient } if p.orderNo != "" { orderNo = p.orderNo } 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, order_no, pdd_goods_url, pdd_goods_id, pdd_options, quantity, max_price_cent, error_code, error_message, result_data, finished_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.taskID, string(p.taskType), string(p.status), assigned, claimedAt, orderNo, pddGoodsID, pddOptions, quantity, maxPrice, errCode, errMsg, resultData, finishedAt, now, now) if err != nil { t.Fatalf("插入测试任务 %s 失败: %v", p.taskID, err) } } // ── 列表:同时显示采集和采购 ────────────────────────── 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_统计跟随当前筛选(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, orderNo: "SO-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) } } 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) } } 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 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) } }