Files
cmautobuy/admin/service/shopee_list_test.go
T
chengmaandClaude Opus 5 edfe39cc35 feat: 蝦皮数据页分页与状态筛选 (#43)
导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从
「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品
仍然找不到。所以分页和状态筛选一起做。

HTML 3.4MB → 16.7KB。

状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。
显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果
总数:「待补规格:6 个商品」。

列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早
有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。

page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。
总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。

「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时
JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。

分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用
<span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。

这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供
后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。
05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。

实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容
会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里
把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:40:07 +08:00

413 lines
14 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"
"fmt"
"testing"
"cmautobuy/admin/repository"
)
// seedShopeeProduct 建一个蝦皮商品,goods_id / title 之外不填别的。
func seedShopeeProduct(t *testing.T, db *sql.DB, goodsID, title string) {
t.Helper()
if err := repository.UpsertShopeeProduct(db, goodsID, title, "正常", "货号"+goodsID); err != nil {
t.Fatalf("建蝦皮商品 %s 失败: %v", goodsID, err)
}
}
// seedShopeeSKU 建一条蝦皮 SKU。specRaw 只在 parseOK=false 时用于校验回显。
func seedShopeeSKU(t *testing.T, db *sql.DB, skuID, goodsID, specRaw, color, size, advice string, parseOK bool) {
t.Helper()
if err := repository.UpsertShopeeSKU(db, skuID, goodsID, specRaw, color, size, advice, parseOK, "sku-"+skuID); err != nil {
t.Fatalf("建蝦皮 SKU %s 失败: %v", skuID, err)
}
}
// setShopeePddLink 直接写 shopee_products.pdd_goods_url / pdd_goods_id,
// 模拟"已经填过 PDD 链接"。ShopeeSave 本工单不实现,测试直接写库代替。
func setShopeePddLink(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddURL string) {
t.Helper()
_, err := db.Exec(
`UPDATE shopee_products SET pdd_goods_id = ?, pdd_goods_url = ? WHERE goods_id = ?`,
pddGoodsID, pddURL, goodsID)
if err != nil {
t.Fatalf("写蝦皮商品 %s 的 PDD 链接失败: %v", goodsID, err)
}
}
// ── 列表:商品级聚合 ──────────────────────────────────
func TestListShopeeProducts_商品级一行(t *testing.T) {
db := newTestDB(t)
// 商品 24420648774 在真实样本里有 62 个 SKU,这里用 3 个模拟同样的
// "一个商品多个 SKU 但只应该出现一行"的场景,见工单 #41。
seedShopeeProduct(t, db, "24420648774", "吊帶背心")
seedShopeeSKU(t, db, "sku-1", "24420648774", "", "黑色", "M", "40-50公斤", true)
seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true)
seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 {
t.Fatalf("行数 = %d,想要 1(商品级一行,不是 SKU 级)", len(result.Rows))
}
row := result.Rows[0]
if row.SKUCount != 3 {
t.Errorf("SKUCount = %d,想要 3", row.SKUCount)
}
if row.ColorCount != 2 {
t.Errorf("ColorCount = %d,想要 2(黑/白)", row.ColorCount)
}
if row.SizeCount != 2 {
t.Errorf("SizeCount = %d,想要 2(M/L)", row.SizeCount)
}
if row.PendingCount != 0 {
t.Errorf("PendingCount = %d,想要 0", row.PendingCount)
}
}
func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) {
db := newTestDB(t)
// 模拟工单里 26886533818 的情形:颜色数 × 尺码数会大于实际 SKU 数,
// 而且有一部分 SKU 解析失败——它们不能被算进颜色/尺码的 DISTINCT。
seedShopeeProduct(t, db, "26886533818", "測試商品")
seedShopeeSKU(t, db, "s1", "26886533818", "", "咖啡色", "2XL", "", true)
seedShopeeSKU(t, db, "s2", "26886533818", "", "咖啡色", "3XL", "", true)
seedShopeeSKU(t, db, "s3", "26886533818", "", "紅色", "M", "", true)
// 解析失败的两条:spec_raw 保留,color/size 为空,不该被 DISTINCT 算进去。
seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false)
seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 {
t.Fatalf("行数 = %d,想要 1", len(result.Rows))
}
row := result.Rows[0]
if row.SKUCount != 5 {
t.Errorf("SKUCount = %d,想要 5(含 2 条解析失败的)", row.SKUCount)
}
if row.ColorCount != 2 {
t.Errorf("ColorCount = %d,想要 2(咖啡色/紅色,不含解析失败的空颜色)", row.ColorCount)
}
if row.SizeCount != 3 {
t.Errorf("SizeCount = %d,想要 3(2XL/3XL/M,不含解析失败的空尺码)", row.SizeCount)
}
if row.PendingCount != 2 {
t.Errorf("PendingCount = %d,想要 2", row.PendingCount)
}
}
func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Rows[0].PendingCount != 1 {
t.Fatalf("PendingCount = %d,想要 1", result.Rows[0].PendingCount)
}
// 整行标黄由模板按 PendingCount > 0 判断,这里只验证数据源正确。
}
func TestListShopeeProducts_PDD链接未填写(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
row := result.Rows[0]
if !row.PddMissing || row.PddURL != "未填写" {
t.Errorf("PddMissing/PddURL = %v/%q,想要 true/\"未填写\"", row.PddMissing, row.PddURL)
}
if row.StatusText != "未填链接" {
t.Errorf("StatusText = %q,想要 \"未填链接\"", row.StatusText)
}
}
func TestListShopeeProducts_采集状态联查(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
if _, err := repository.EnsurePddProduct(db, "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001"); err != nil {
t.Fatalf("建 PDD 商品失败: %v", err)
}
if err := repository.SetCollectResult(db, "9001", "PDD标题", "店铺", "{}"); err != nil {
t.Fatalf("设置采集结果失败: %v", err)
}
setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
row := result.Rows[0]
if row.PddMissing {
t.Error("已填链接,PddMissing 应为 false")
}
if row.StatusText != "已采集" {
t.Errorf("StatusText = %q,想要 \"已采集\"", row.StatusText)
}
}
func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺码(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "吊帶背心")
seedShopeeProduct(t, db, "1002", "短袖T恤")
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true)
// 按商品 ID 匹配
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "1001"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("按商品 ID 搜索结果不对: %+v", result.Rows)
}
// 按商品名称匹配
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("按商品名称搜索结果不对: %+v", result.Rows)
}
// 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。
result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 0 {
t.Fatalf("关键字不应匹配颜色,得到 %+v", result.Rows)
}
}
// ── 弹窗详情 ──────────────────────────────────
func TestGetShopeeProductDetail_待补行显示原文(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "商品A")
seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "40-50公斤", true)
seedShopeeSKU(t, db, "s2", "1001", "紅色,3XL建議80-90公斤】", "", "", "", false)
detail, err := GetShopeeProductDetail(db, "1001")
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if detail == nil {
t.Fatal("详情为 nil")
}
if len(detail.SKUs) != 2 {
t.Fatalf("SKUs 数量 = %d,想要 2(含解析失败的)", len(detail.SKUs))
}
if detail.PendingCount != 1 {
t.Errorf("PendingCount = %d,想要 1", detail.PendingCount)
}
var pending *ShopeeSpecView
for i := range detail.SKUs {
if detail.SKUs[i].Pending {
pending = &detail.SKUs[i]
}
}
if pending == nil {
t.Fatal("没有找到待补的那一行")
}
if pending.SpecRaw != "紅色,3XL建議80-90公斤】" {
t.Errorf("SpecRaw = %q,想要显示原文", pending.SpecRaw)
}
if pending.StatusText != "待补" {
t.Errorf("StatusText = %q,想要 \"待补\"", pending.StatusText)
}
for _, s := range detail.SKUs {
if !s.Pending && s.StatusText != "✓" {
t.Errorf("解析成功的行 StatusText = %q,想要 \"✓\"", s.StatusText)
}
}
}
func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) {
db := newTestDB(t)
detail, err := GetShopeeProductDetail(db, "not-exist")
if err != nil {
t.Fatalf("不应该报错: %v", err)
}
if detail != nil {
t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail)
}
}
// ── 分页(工单 #43) ──────────────────────────────────
func seedShopeeProducts(t *testing.T, db *sql.DB, n int) {
t.Helper()
for i := 0; i < n; i++ {
seedShopeeProduct(t, db, fmt.Sprintf("g%04d", i), fmt.Sprintf("商品%d", i))
}
}
func TestListShopeeProducts_每页固定20条(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result.Rows) != 20 {
t.Errorf("第 1 页行数 = %d,想要 20", len(result.Rows))
}
if result.Total != 25 {
t.Errorf("Total = %d,想要 25(全量,不是本页数)", result.Total)
}
if result.TotalPages != 2 {
t.Errorf("TotalPages = %d,想要 2", result.TotalPages)
}
result2, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 2)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(result2.Rows) != 5 {
t.Errorf("第 2 页行数 = %d,想要 5(25 条剩下的)", len(result2.Rows))
}
}
func TestListShopeeProducts_page越界兜到最后一页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 25)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 9999)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 2 {
t.Errorf("Page = %d,想要兜到最后一页 2", result.Page)
}
if len(result.Rows) != 5 {
t.Errorf("越界后应显示最后一页的数据,行数 = %d,想要 5,不是空表格", len(result.Rows))
}
}
func TestListShopeeProducts_page小于1或非数字当作第1页(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
for _, p := range []int{0, -1, -100} {
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, p)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Page != 1 {
t.Errorf("page=%d 时 Page = %d,想要 1", p, result.Page)
}
}
}
func TestListShopeeProducts_总数为0时第1页共1页(t *testing.T) {
db := newTestDB(t)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.TotalPages != 1 {
t.Errorf("总数为 0 时 TotalPages = %d,想要 1(不能出现『第 1/0 页』)", result.TotalPages)
}
if result.Page != 1 {
t.Errorf("Page = %d,想要 1", result.Page)
}
}
// ── 状态筛选(工单 #43) ──────────────────────────────
func TestListShopeeProducts_待补规格筛选_一个商品多个失败SKU只出现一行(t *testing.T) {
db := newTestDB(t)
// 同一商品两条解析失败的 SKU:用 JOIN + DISTINCT 会因为多行匹配 EXISTS
// 子查询而出重复,这里验证过滤用 EXISTS 时该商品只出现一次。
seedShopeeProduct(t, db, "1001", "待补商品")
seedShopeeSKU(t, db, "s1", "1001", "坏数据1", "", "", "", false)
seedShopeeSKU(t, db, "s2", "1001", "坏数据2", "", "", "", false)
seedShopeeProduct(t, db, "1002", "正常商品")
seedShopeeSKU(t, db, "s3", "1002", "", "黑色", "M", "", true)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "pending_spec"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if result.Total != 1 {
t.Fatalf("Total = %d,想要 1(不因为两条失败 SKU 出重复行)", result.Total)
}
if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" {
t.Fatalf("筛选结果不对: %+v", result.Rows)
}
}
func TestListShopeeProducts_未填链接和已填链接筛选(t *testing.T) {
db := newTestDB(t)
seedShopeeProduct(t, db, "1001", "未填链接商品")
seedShopeeProduct(t, db, "1002", "已填链接商品")
setShopeePddLink(t, db, "1002", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001")
noLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "no_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if noLink.Total != 1 || noLink.Rows[0].GoodsID != "1001" {
t.Fatalf("未填链接筛选结果不对: %+v", noLink.Rows)
}
hasLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "has_link"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if hasLink.Total != 1 || hasLink.Rows[0].GoodsID != "1002" {
t.Fatalf("已填链接筛选结果不对: %+v", hasLink.Rows)
}
}
func TestListShopeeProducts_筛选参数认不出来当全部(t *testing.T) {
db := newTestDB(t)
seedShopeeProducts(t, db, 3)
result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "not_a_real_status"}, 1)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
// repository 层不做校验,直接透传的话这个取值走不进 switch 的任何分支,
// 等价于不筛选——校验(认不出来当全部)在 service.ParseShopeeStatus,
// 这里验证 repository 端不会因为陌生取值报错或漏数据。
if result.Total != 3 {
t.Errorf("Total = %d,想要 3(陌生取值不应影响结果)", result.Total)
}
}
func TestParseShopeeStatus_认不出来当全部(t *testing.T) {
cases := []string{"", "not_a_real_status", " ", "PENDING_SPEC"}
for _, c := range cases {
if got := ParseShopeeStatus(c); got != "" {
t.Errorf("ParseShopeeStatus(%q) = %q,想要空串(当全部)", c, got)
}
}
for _, c := range []string{"pending_spec", "no_link", "has_link"} {
if got := ParseShopeeStatus(c); got != c {
t.Errorf("ParseShopeeStatus(%q) = %q,想要原样返回", c, got)
}
}
}