package service import ( "database/sql" "testing" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) // ── OptionKey ────────────────────────────────────────── // 键序不同的同一个规格必须算出同一个 key。 // 这是整个匹配机制的地基:采集回来的 JSON 键序和存映射时不一定一样, // 算不出同一个 key,匹配就会**静默失效**——不报错,只是查不到。 func TestOptionKey_键序不影响结果(t *testing.T) { a, err := OptionKey(map[string]string{"color": "黑色", "size": "M"}) if err != nil { t.Fatal(err) } b, err := OptionKey(map[string]string{"size": "M", "color": "黑色"}) if err != nil { t.Fatal(err) } if a != b { t.Errorf("同一个规格算出了不同的 key:\n%s\n%s", a, b) } } func TestOptionKey_不同规格算出不同key(t *testing.T) { a, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) b, _ := OptionKey(map[string]string{"color": "黑色", "size": "L"}) if a == b { t.Error("不同规格不该算出同一个 key") } } // 维度数量不固定——可能有第三个维度(款式),key 要能容纳。 func TestOptionKey_支持任意多个维度(t *testing.T) { k, err := OptionKey(map[string]string{"color": "黑色", "size": "M", "style": "加绒"}) if err != nil { t.Fatalf("三个维度应该合法: %v", err) } if k == "" { t.Error("key 不该为空") } } func TestOptionKey_空组合报错(t *testing.T) { if _, err := OptionKey(map[string]string{}); err == nil { t.Error("空规格组合应该报错,不能算出一个看起来正常的 key") } } // 从 JSON 原文算 key,和从 map 算的结果必须一致—— // 存映射走一条路径、查规格走另一条路径,两边算不出同一个值就白搭。 func TestOptionKeyFromJSON_与map版本一致(t *testing.T) { fromMap, _ := OptionKey(map[string]string{"color": "黑色", "size": "M"}) fromJSON, err := OptionKeyFromJSON(`{"size":"M","color":"黑色"}`) if err != nil { t.Fatal(err) } if fromMap != fromJSON { t.Errorf("两条路径算出不同的 key:\nmap %s\njson %s", fromMap, fromJSON) } } // ── PDD 商品的建立与复活 ─────────────────────────────── func TestEnsurePddProduct_新建(t *testing.T) { db := newTestDB(t) p, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1") if err != nil { t.Fatalf("新建失败: %v", err) } if p.GoodsID != "PDD-1" { t.Errorf("goods_id 不对: %s", p.GoodsID) } if p.CollectStatus != model.CollectPending { t.Errorf("新建的商品应为待采集,实际 %s", p.CollectStatus) } } // 同一个商品重复保存链接,不能产生第二行—— // goods_id 上有 UNIQUE,重复了会直接插入失败。 func TestEnsurePddProduct_重复保存不产生重复行(t *testing.T) { db := newTestDB(t) for i := 0; i < 3; i++ { if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1"); err != nil { t.Fatalf("第 %d 次保存失败: %v", i+1, err) } } var n int db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n) if n != 1 { t.Errorf("应只有 1 行,实际 %d 行", n) } } // 已采集的商品再次保存链接,不能把采集结果冲掉。 func TestEnsurePddProduct_不覆盖已有采集结果(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") if err := repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`); err != nil { t.Fatal(err) } if _, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1?from=share"); err != nil { t.Fatalf("再次保存失败: %v", err) } p, _ := repository.GetPddProductByGoodsID(db, "PDD-1") if p.CollectStatus != model.CollectCollected { t.Errorf("采集状态被冲掉了,实际 %s", p.CollectStatus) } if p.SkusJSON == "" { t.Error("采集结果被清空了") } if p.URL != "https://x/1?from=share" { t.Errorf("链接原文应更新,实际 %s", p.URL) } } // 软删除后再填同一个链接要能复活。 // 不复活的话,goods_id 上的 UNIQUE 会让插入失败, // 操作员会看到一个莫名其妙的错误。 func TestEnsurePddProduct_软删除后可复活(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") repository.SetCollectResult(db, "PDD-1", "商品甲", "", `{"skus":[1]}`) if err := repository.SoftDeletePddProduct(db, "PDD-1"); err != nil { t.Fatal(err) } p, _ := repository.GetPddProductByGoodsID(db, "PDD-1") if !p.IsDeleted() { t.Fatal("前置条件不对,应已软删除") } revived, err := repository.EnsurePddProduct(db, "PDD-1", "https://x/1") if err != nil { t.Fatalf("复活失败: %v", err) } if revived.IsDeleted() { t.Error("复活后不该还带删除标记") } if revived.CollectStatus != model.CollectPending { t.Errorf("复活后应回到待采集,实际 %s", revived.CollectStatus) } // 删过一次的记录,旧采集结果不能再当有效的用 if revived.SkusJSON != "" { t.Error("复活后应清空旧采集结果,避免用到删除前的过期数据") } var n int db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id='PDD-1'`).Scan(&n) if n != 1 { t.Errorf("复活不该新增行,实际 %d 行", n) } } // ── 采集中不允许重复发起 ─────────────────────────────── func TestMarkCollecting_采集中不允许再次发起(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") ok, err := repository.MarkCollecting(db, "PDD-1") if err != nil || !ok { t.Fatalf("第一次应成功: ok=%v err=%v", ok, err) } ok, err = repository.MarkCollecting(db, "PDD-1") if err != nil { t.Fatal(err) } if ok { t.Error("已在采集中,不该允许再建一个采集任务") } } func TestMarkCollecting_失败后可重新采集(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-1", "https://x/1") repository.SetCollectFailed(db, "PDD-1", "页面超时", "") ok, err := repository.MarkCollecting(db, "PDD-1") if err != nil { t.Fatal(err) } if !ok { t.Error("采集失败的商品应该允许重新采集") } } // ── 映射隔离:本工单的安全核心 ───────────────────────── // insertMapping 造一条规格映射。 func insertMapping(t *testing.T, db *sql.DB, goodsID, specKey, pddGoodsID string, options map[string]string) { t.Helper() key, err := OptionKey(options) if err != nil { t.Fatal(err) } if err := repository.UpsertSpecMapping(db, model.SpecMapping{ ShopeeGoodsID: goodsID, SpecKey: specKey, SpecRaw: specKey, PddGoodsID: pddGoodsID, PddOptionKey: key, PddOptions: key, MappedAt: model.NowISO(), }); err != nil { t.Fatalf("插入映射失败: %v", err) } } // findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。 // 这个条件就是隔离机制本身,少了它旧映射就会被误用。 func findMapping(t *testing.T, db *sql.DB, goodsID, specKey, currentPddGoodsID string) (string, bool) { t.Helper() var key string err := db.QueryRow(` SELECT pdd_option_key FROM spec_mappings WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`, goodsID, specKey, currentPddGoodsID).Scan(&key) if err == sql.ErrNoRows { return "", false } if err != nil { t.Fatal(err) } return key, true } // 换了 PDD 商品之后,旧映射必须查不到。 // // 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服, // 就会**静默买错东西**,而且事后查不出来。 func TestSpecMapping_换PDD商品后查不到旧映射(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-A", "https://x/a") repository.EnsurePddProduct(db, "PDD-B", "https://x/b") // 顺运宝规格原来匹配到 PDD 商品 A 的"黑色/M码" insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"}) if _, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A"); !ok { t.Fatal("前置条件不对:A 的映射应该查得到") } // A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配" if key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B"); ok { t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+ "这正是会导致买错商品的情况", key) } } // 换回原来的 PDD 商品,旧映射要还能用。 // 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处: // PDD 商品补货换回去时,之前的人工匹配成果直接复用。 func TestSpecMapping_换回原商品旧映射仍可用(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-A", "https://x/a") repository.EnsurePddProduct(db, "PDD-B", "https://x/b") insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"}) // 换到 B,也匹配一次 insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑色", "size": "M"}) // 换回 A key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A") if !ok { t.Fatal("换回 A 之后,A 的旧映射应该还在") } expect, _ := OptionKey(map[string]string{"color": "黑色", "size": "M码"}) if key != expect { t.Errorf("拿到的是别的商品的映射:期望 %s,实际 %s", expect, key) } } // 同一蝦皮商品规格可以同时有多个 PDD 商品的映射,互不干扰。 func TestSpecMapping_同一商品规格可对多个PDD商品各有映射(t *testing.T) { db := newTestDB(t) repository.EnsurePddProduct(db, "PDD-A", "https://x/a") repository.EnsurePddProduct(db, "PDD-B", "https://x/b") insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"}) insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑", "size": "均码"}) var n int db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SHOPEE-1' AND spec_key='黑色,M'`).Scan(&n) if n != 2 { t.Errorf("应能同时存在 2 条映射,实际 %d 条", n) } kA, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A") kB, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B") if kA == kB { t.Error("两个 PDD 商品的映射不该相同") } }