package repository import ( "crypto/rand" "database/sql" "encoding/hex" "os" "strings" "testing" "cmautobuy/admin/config" "cmautobuy/admin/model" ) func newSybTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { t.Skip("未启用真实 MySQL 8 集成测试") } cfg, err := config.LoadDatabaseFromEnv() if err != nil { t.Fatal(err) } if !strings.HasSuffix(cfg.Name, "_test") { t.Fatalf("MySQL 测试基础库名必须以 _test 结尾") } adminDB, err := OpenMySQL(cfg) if err != nil { t.Fatal(err) } random := make([]byte, 8) if _, err := rand.Read(random); err != nil { t.Fatal(err) } databaseName := cfg.Name + "_" + hex.EncodeToString(random) quotedName := "`" + strings.ReplaceAll(databaseName, "`", "``") + "`" if _, err := adminDB.Exec("CREATE DATABASE " + quotedName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil { adminDB.Close() t.Fatal(err) } cfg.Name = databaseName db, err := OpenMySQL(cfg) if err != nil { adminDB.Exec("DROP DATABASE " + quotedName) adminDB.Close() t.Fatal(err) } if err := MigrateMySQL(db); err != nil { db.Close() adminDB.Exec("DROP DATABASE " + quotedName) adminDB.Close() t.Fatal(err) } t.Cleanup(func() { db.Close() if _, err := adminDB.Exec("DROP DATABASE " + quotedName); err != nil { t.Errorf("清理隔离 MySQL 测试库失败: %v", err) } adminDB.Close() }) return db } func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在(t *testing.T) { // 这是工单 #46 最要紧的一条:shopee_sku_id 是人工规格匹配的结果, // 顺运宝同步绝不能把它覆盖成空值——覆盖了不会报错,等到建采购任务 // 才会发现匹配成果被洗掉,那时已经找不回来了。 db := newSybTestDB(t) first := model.SybOrder{ SybID: "SYB-1", OrderNo: "ORDER-1", Title: "初始标题", ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255", Quantity: 1, PriceTwdCent: 23900, ImageURL: "https://x/img.jpg", SybData: `{"stock":{}}`, } created, err := UpsertSybOrder(db, first) if err != nil { t.Fatalf("首次写入失败: %v", err) } if !created { t.Fatal("首次写入应该是新建") } // 模拟操作员在界面上完成了规格匹配,手工把 shopee_sku_id 填上。 if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`, "TEST-SKU-123", "SYB-1"); err != nil { t.Fatalf("模拟人工匹配失败: %v", err) } // 再次同步:标题、数量、价格都变了(模拟顺运宝那边数据更新), // 但 upsert 调用方不会传 shopee_sku_id 的新值(顺运宝根本没有这个字段)。 second := model.SybOrder{ SybID: "SYB-1", OrderNo: "ORDER-1", Title: "更新后的标题", ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255", Quantity: 3, PriceTwdCent: 25900, ImageURL: "https://x/img2.jpg", SybData: `{"stock":{"updated":true}}`, // 注意:这里刻意不设置 ShopeeSKUID(零值,即空字符串), // 模拟"同步流程从来不知道匹配结果,只管顺运宝返回的字段"。 } created2, err := UpsertSybOrder(db, second) if err != nil { t.Fatalf("二次写入失败: %v", err) } if created2 { t.Fatal("二次写入应该是更新,不是新建") } var skuID, title string var qty int if err := db.QueryRow(`SELECT shopee_sku_id, title, quantity FROM syb_orders WHERE syb_id = ?`, "SYB-1").Scan(&skuID, &title, &qty); err != nil { t.Fatalf("查询失败: %v", err) } if skuID != "TEST-SKU-123" { t.Fatalf("shopee_sku_id 应该还是人工匹配的 TEST-SKU-123,实际 %q"+ "——同步把匹配成果覆盖掉了,这是本工单最不能接受的问题", skuID) } // 其余允许覆盖的字段应该已经更新,证明这不是"upsert 整个没生效"的假通过。 if title != "更新后的标题" || qty != 3 { t.Errorf("title/quantity 应该被同步更新,实际 title=%q quantity=%d", title, qty) } } func TestUpsertSybOrder_只更新Syb骨架标题(t *testing.T) { db := newSybTestDB(t) now := model.NowISO() if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SYB-P','旧骨架','syb',?,?),('API-P','正式标题','api',?,?)`, now, now, now, now); err != nil { t.Fatal(err) } for _, o := range []model.SybOrder{{SybID: "S1", OrderNo: "O1", Title: "新骨架", ProductSpec: "黑色,M", ShopeeGoodsID: "SYB-P", Quantity: 1, PriceTwdCent: 100, SybData: "{}"}, {SybID: "S2", OrderNo: "O2", Title: "顺运宝标题", ProductSpec: "白色,L", ShopeeGoodsID: "API-P", Quantity: 1, PriceTwdCent: 200, SybData: "{}"}} { if _, err := UpsertSybOrder(db, o); err != nil { t.Fatal(err) } } var sybTitle, apiTitle string _ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='SYB-P'`).Scan(&sybTitle) _ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='API-P'`).Scan(&apiTitle) if sybTitle != "新骨架" || apiTitle != "正式标题" { t.Fatalf("来源保护失败:syb=%q api=%q", sybTitle, apiTitle) } } func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) { db := newSybTestDB(t) if _, err := UpsertSybOrder(db, model.SybOrder{ SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "11111111111", Quantity: 1, SybData: "{}", }); err != nil { t.Fatalf("首次写入失败: %v", err) } if _, err := UpsertSybOrder(db, model.SybOrder{ SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "22222222222", Quantity: 1, SybData: "{}", }); err != nil { t.Fatalf("二次写入失败: %v", err) } var goodsID string if err := db.QueryRow(`SELECT shopee_goods_id FROM syb_orders WHERE syb_id = ?`, "SYB-2").Scan(&goodsID); err != nil { t.Fatalf("查询失败: %v", err) } if goodsID != "22222222222" { t.Errorf("shopee_goods_id 应该被同步更新为顺运宝返回的新值,实际 %q", goodsID) } } func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) { db := newSybTestDB(t) for i := 0; i < 3; i++ { if _, err := UpsertSybOrder(db, model.SybOrder{ SybID: "SYB-3", OrderNo: "ORDER-3", Quantity: 1, SybData: "{}", }); err != nil { t.Fatalf("第 %d 次写入失败: %v", i+1, err) } } n, err := CountSybOrdersTotal(db) if err != nil { t.Fatalf("统计失败: %v", err) } if n != 1 { t.Fatalf("重复同步同一个 syb_id 应该只有 1 行,实际 %d 行", n) } } func TestUpsertSybOrder_骨架失败时同事务不写订单(t *testing.T) { db := newSybTestDB(t) tx, err := db.Begin() if err != nil { t.Fatal(err) } if _, err := UpsertSybOrder(tx, model.SybOrder{ SybID: "SYB-REJECT", OrderNo: "ORDER-REJECT", Title: "测试", ShopeeGoodsID: strings.Repeat("G", 192), ProductSpec: "黑色,M", Quantity: 1, SybData: "{}", }); err == nil { t.Fatal("骨架失败必须让订单写入失败") } tx.Rollback() var count int if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'`).Scan(&count); err != nil || count != 0 { t.Fatalf("失败后不应留下订单 count=%d err=%v", count, err) } } func TestSybSession_保存读取和清除(t *testing.T) { db := newSybTestDB(t) if got, err := GetSybSession(db, "tester"); err != nil || got != nil { t.Fatalf("没有会话时应该返回 (nil, nil),实际 got=%v err=%v", got, err) } if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"abc"}]`, "2026-08-10T00:00:00Z"); err != nil { t.Fatalf("保存会话失败: %v", err) } got, err := GetSybSession(db, "tester") if err != nil { t.Fatalf("读取会话失败: %v", err) } if got == nil || got.ExpiresAt != "2026-08-10T00:00:00Z" { t.Fatalf("读取到的会话不对: %+v", got) } // upsert:同一用户名再保存一次应该覆盖,不是新增一行。 if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"xyz"}]`, "2026-08-11T00:00:00Z"); err != nil { t.Fatalf("二次保存会话失败: %v", err) } got2, err := GetSybSession(db, "tester") if err != nil || got2 == nil || got2.ExpiresAt != "2026-08-11T00:00:00Z" { t.Fatalf("二次保存后应该读到新值: got=%+v err=%v", got2, err) } if err := DeleteSybSession(db, "tester"); err != nil { t.Fatalf("删除会话失败: %v", err) } if got3, err := GetSybSession(db, "tester"); err != nil || got3 != nil { t.Fatalf("删除后应该查不到,实际 got=%v err=%v", got3, err) } } func TestSybSyncState_首次为空之后可更新(t *testing.T) { db := newSybTestDB(t) _, found, err := GetSybLastSyncedAt(db) if err != nil { t.Fatalf("查询失败: %v", err) } if found { t.Fatal("从没同步过时 found 应该是 false") } if err := SetSybLastSyncedAt(db, "2026-08-09T14:30:00Z"); err != nil { t.Fatalf("更新失败: %v", err) } at, found, err := GetSybLastSyncedAt(db) if err != nil || !found || at != "2026-08-09T14:30:00Z" { t.Fatalf("读取错误: at=%q found=%v err=%v", at, found, err) } // 再更新一次,确认是 upsert 而不是报主键冲突。 if err := SetSybLastSyncedAt(db, "2026-08-10T09:00:00Z"); err != nil { t.Fatalf("二次更新失败: %v", err) } at2, _, err := GetSybLastSyncedAt(db) if err != nil || at2 != "2026-08-10T09:00:00Z" { t.Fatalf("二次更新后应该读到新值: at2=%q err=%v", at2, err) } } func createSybSyncTestUser(t *testing.T, db *sql.DB) model.User { t.Helper() user := model.User{ UserID: "USR-SYNC", Username: "buyer", PasswordHash: "test-hash", Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: "2026-08-09T00:00:00Z", CreatedAt: "2026-08-09T00:00:00Z", UpdatedAt: "2026-08-09T00:00:00Z", } if err := CreateInitialAdmin(db, user); err != nil { t.Fatalf("创建同步记录测试用户失败: %v", err) } return user } func TestSybSyncRun_创建完成并分页读取(t *testing.T) { db := newSybTestDB(t) user := createSybSyncTestUser(t, db) run := model.SybSyncRun{ RunID: "SYB-RUN-1", UserID: user.UserID, DateFrom: "2026-08-07", DateTo: "2026-08-09", StartedAt: "2026-08-09T01:00:00Z", } if err := CreateSybSyncRun(db, run); err != nil { t.Fatalf("创建同步记录失败: %v", err) } if err := FinishSybSyncRun(db, model.SybSyncRun{ RunID: "SYB-RUN-1", Status: model.SybSyncSucceeded, StockCount: 3, DetailCount: 4, Created: 2, Updated: 2, Skipped: 1, CursorAdvanced: true, FinishedAt: "2026-08-09T01:02:00Z", }); err != nil { t.Fatalf("完成同步记录失败: %v", err) } rows, err := ListSybSyncRuns(db, 10, 0) if err != nil || len(rows) != 1 { t.Fatalf("读取同步记录失败: rows=%+v err=%v", rows, err) } got := rows[0] if got.Username != "buyer" || got.Status != model.SybSyncSucceeded || got.DetailCount != 4 || !got.CursorAdvanced || got.FinishedAt == "" { t.Fatalf("同步记录字段不正确: %+v", got) } if count, err := CountSybSyncRuns(db); err != nil || count != 1 { t.Fatalf("同步记录总数错误: count=%d err=%v", count, err) } } func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) { db := newSybTestDB(t) user := createSybSyncTestUser(t, db) for _, id := range []string{"RUNNING", "DONE"} { if err := CreateSybSyncRun(db, model.SybSyncRun{ RunID: id, UserID: user.UserID, DateFrom: "2026-08-09", DateTo: "2026-08-09", StartedAt: "2026-08-09T01:00:00Z", }); err != nil { t.Fatal(err) } } if err := FinishSybSyncRun(db, model.SybSyncRun{ RunID: "DONE", Status: model.SybSyncSucceeded, FinishedAt: "2026-08-09T01:01:00Z", }); err != nil { t.Fatal(err) } affected, err := InterruptRunningSybSyncRuns(db, "2026-08-09T02:00:00Z") if err != nil || affected != 1 { t.Fatalf("应该只中断一条 running 记录: affected=%d err=%v", affected, err) } rows, _ := ListSybSyncRuns(db, 10, 0) statuses := map[string]model.SybSyncRunStatus{} for _, row := range rows { statuses[row.RunID] = row.Status } if statuses["RUNNING"] != model.SybSyncInterrupted || statuses["DONE"] != model.SybSyncSucceeded { t.Fatalf("中断状态错误: %+v", statuses) } } func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) { db := newSybTestDB(t) mustUpsert := func(sybID, orderNo, title string) { if _, err := UpsertSybOrder(db, model.SybOrder{ SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}", }); err != nil { t.Fatalf("写入 %s 失败: %v", sybID, err) } } mustUpsert("SYB-A", "260728AAA", "纯棉上衣") mustUpsert("SYB-B", "260728BBB", "牛仔裤") rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0) if err != nil { t.Fatalf("查询失败: %v", err) } if len(rows) != 1 || rows[0].SybID != "SYB-A" { t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows) } rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0) if err != nil { t.Fatalf("查询失败: %v", err) } if len(rows2) != 1 || rows2[0].SybID != "SYB-B" { t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2) } }