1089 lines
40 KiB
Go
1089 lines
40 KiB
Go
package repository
|
||
|
||
import (
|
||
"bytes"
|
||
"database/sql"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件是工单 #20 的核心交付物:证明不管从哪种库起步,迁到最新版本后
|
||
// schema 都完全一致。#20 的缺陷之所以发生,就是因为当初只测了"全新建库"
|
||
// 这一条路径——老库停在 user_version=2,循环条件 `v < len(migrations)`
|
||
// 一次都不进,被原地改写的 v1 永远不会在老库上重跑。
|
||
//
|
||
// 起点不止两种。#16(commit 998c06a)把 pdd_products 等结构直接原地
|
||
// 改写进了 v1,没有新增版本号,所以 user_version = 2 现在对应**两种不同的
|
||
// 真实结构**:
|
||
//
|
||
// - newV2DB:原始 v1(#16 之前)建的库,没有 pdd_products,
|
||
// shopee_products 还带着 pdd_data 等四列,sku_mappings 是单列主键。
|
||
// - newV2NewStructureDB:#16 改写后的 v1 建的库,结构已经是最终形态,
|
||
// 只是 user_version 还停在 2——#20 审查阶段用户在真实环境里实测到的
|
||
// 正是这种库,migrateV3 一度对着已经存在的 pdd_products 又 CREATE
|
||
// 了一遍,直接报 "table already exists"。
|
||
//
|
||
// 三个起点(加上 newFreshDB)迁到最新版本后必须完全一致。
|
||
// assertSchemaEqual 逐条比较 sqlite_master 里的建表/建索引语句
|
||
// (列定义、CHECK、PRIMARY KEY 全在这条语句文本里),不是只比表名——
|
||
// 只比表名会漏掉"表名一样但主键不同"这类问题
|
||
// (sku_mappings 的主键从单列 shopee_sku_id 变成了两列)。
|
||
//
|
||
// 但"三条路径互相一致"防不住 migrateV3 自己写错、三条路径一起错的情况
|
||
// (因为全新库也是靠 migrateV3 收敛到最终结构的,三条路径共用同一份
|
||
// migrateV3 代码)。TestMigrate_最终schema符合设计要求 用行为断言
|
||
// (插数据看报不报错)单独兜住这一类问题,见那个测试前面的注释。
|
||
|
||
// newFreshDB 建一个全新库,一路迁到最新版本。
|
||
func newFreshDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
db, err := Open(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("打开测试库失败: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移到最新版本失败: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
// newV2DB 造一个停在 v2(#20 修复之前)的老库:只跑 migrations[0:2],
|
||
// 不跑 v3,手动把 user_version 设成 2。
|
||
//
|
||
// 用的是 migrations 这个正式变量本身(而不是抄一份 SQL),
|
||
// 这样将来 v1/v2 万一又被错误地原地改写,这个夹具也会跟着变、
|
||
// 从而暴露出"两条路径其实没有真正独立"的局限——但那正是
|
||
// admin/AGENTS.md 里"迁移只追加"这条规则要防的事,不是这个测试要防的事。
|
||
func newV2DB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
db, err := Open(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("打开测试库失败: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
for v, stmts := range migrations[:2] {
|
||
for i, stmt := range stmts {
|
||
if _, err := db.Exec(stmt); err != nil {
|
||
t.Fatalf("造 v2 老库失败(v%d 第 %d 条语句): %v", v+1, i+1, err)
|
||
}
|
||
}
|
||
}
|
||
if _, err := db.Exec("PRAGMA user_version = 2"); err != nil {
|
||
t.Fatalf("设置 user_version 失败: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
// migrations998c06a 是 #16(commit 998c06a)原地改写 v1 之后的迁移语句,
|
||
// 逐字复制自 `git show 998c06a:admin/repository/db.go`——那次改写直接把
|
||
// pdd_products 等结构塞进了 v1,没有新增版本号。任何在 998c06a 之后、
|
||
// #20 之前建过库的机器,user_version 都是 2,但结构已经是最终形态。
|
||
var migrations998c06a = [][]string{
|
||
// v1: 初始表结构,对应 docs/admin/03-data-model.md
|
||
{
|
||
`CREATE TABLE shopee_products (
|
||
goods_id TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
shopee_status TEXT,
|
||
main_sku_code TEXT,
|
||
|
||
-- 人工维护的,蝦皮报表里没有这两列,Excel 导入时绝不能覆盖。
|
||
-- pdd_goods_id 指向 pdd_products.goods_id,表示"这个蝦皮商品
|
||
-- 当前对应哪个 PDD 商品"。PDD 商品下架换新时改这里。
|
||
pdd_goods_url TEXT,
|
||
pdd_goods_id TEXT,
|
||
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);`,
|
||
`CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);`,
|
||
`CREATE TABLE shopee_skus (
|
||
sku_id TEXT PRIMARY KEY,
|
||
goods_id TEXT NOT NULL,
|
||
spec_raw TEXT NOT NULL,
|
||
color TEXT,
|
||
size TEXT,
|
||
advice TEXT,
|
||
parse_ok INTEGER NOT NULL DEFAULT 0,
|
||
sku_code TEXT,
|
||
is_manual INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
FOREIGN KEY (goods_id) REFERENCES shopee_products(goods_id) ON DELETE CASCADE
|
||
);`,
|
||
`CREATE INDEX idx_shopee_skus_goods ON shopee_skus(goods_id);`,
|
||
`CREATE INDEX idx_shopee_skus_parse ON shopee_skus(parse_ok);`,
|
||
`CREATE TABLE pdd_products (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
||
-- 从 PDD 链接里解析出来。它不是主键,所以**必须加 UNIQUE**:
|
||
-- 少了这条约束,同一个 PDD 商品会被存成好几行,
|
||
-- 采好几遍,映射还说不清指向哪一行。
|
||
goods_id TEXT NOT NULL UNIQUE,
|
||
|
||
url TEXT NOT NULL, -- 操作员填的链接原文
|
||
title TEXT, -- 采集回来,人工核对"是不是我要的那个商品"
|
||
skus_json TEXT, -- schema_version + dimensions + skus
|
||
|
||
-- 注意这里**没有 no_link**:这张表里有这一行,就说明链接已经填了。
|
||
-- "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。
|
||
collect_status TEXT NOT NULL DEFAULT 'pending'
|
||
CHECK (collect_status IN (
|
||
'pending', 'collecting', 'collected', 'failed'
|
||
)),
|
||
collect_msg TEXT, -- 失败原因,要能定位问题
|
||
artifact_ref TEXT, -- 诊断产物在哪台机器哪个目录
|
||
collected_at TEXT,
|
||
|
||
-- 软删除。不硬删是因为 sku_mappings 指向它,
|
||
-- 硬删会把人工攒了很久的匹配成果一起带走。
|
||
deleted_at TEXT,
|
||
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);`,
|
||
`CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);`,
|
||
`CREATE TABLE syb_orders (
|
||
syb_id TEXT PRIMARY KEY,
|
||
order_no TEXT NOT NULL,
|
||
title TEXT,
|
||
shopee_goods_id TEXT,
|
||
shopee_sku_id TEXT,
|
||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
|
||
image_url TEXT,
|
||
syb_data TEXT NOT NULL DEFAULT '{}',
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);`,
|
||
`CREATE INDEX idx_syb_orders_order ON syb_orders(order_no);`,
|
||
`CREATE INDEX idx_syb_orders_goods ON syb_orders(shopee_goods_id);`,
|
||
`CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);`,
|
||
`CREATE TABLE sku_mappings (
|
||
shopee_sku_id TEXT NOT NULL,
|
||
pdd_goods_id TEXT NOT NULL,
|
||
pdd_option_key TEXT NOT NULL,
|
||
pdd_options TEXT NOT NULL,
|
||
goods_id TEXT NOT NULL,
|
||
mapped_at TEXT NOT NULL,
|
||
mapped_by TEXT,
|
||
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
|
||
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
|
||
);`,
|
||
`CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);`,
|
||
`CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);`,
|
||
`CREATE TABLE tasks (
|
||
task_id TEXT PRIMARY KEY,
|
||
task_type TEXT NOT NULL CHECK (task_type IN ('collect', 'purchase')),
|
||
status TEXT NOT NULL DEFAULT 'pending'
|
||
CHECK (status IN ('pending', 'assigned', 'claimed',
|
||
'succeeded', 'manual_review',
|
||
'failed', 'cancelled')),
|
||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
|
||
assigned_client TEXT,
|
||
claimed_at TEXT,
|
||
|
||
syb_id TEXT,
|
||
order_no TEXT,
|
||
goods_id TEXT,
|
||
shopee_sku_id TEXT,
|
||
|
||
-- Client 契约要求:pdd_goods_url 必填;
|
||
-- 采购任务的 quantity 和 max_price_cent 也必填(价格保护)
|
||
pdd_goods_url TEXT NOT NULL,
|
||
pdd_goods_id TEXT,
|
||
pdd_options TEXT,
|
||
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
||
max_price_cent INTEGER CHECK (max_price_cent IS NULL OR max_price_cent > 0),
|
||
|
||
result_data TEXT,
|
||
error_code TEXT,
|
||
error_message TEXT,
|
||
finished_at TEXT,
|
||
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);`,
|
||
`CREATE INDEX idx_tasks_claim ON tasks(assigned_client, status, priority DESC, created_at);`,
|
||
`CREATE INDEX idx_tasks_list ON tasks(updated_at DESC, task_id DESC);`,
|
||
`CREATE INDEX idx_tasks_order ON tasks(order_no);`,
|
||
`CREATE TABLE clients (
|
||
client_id TEXT PRIMARY KEY,
|
||
name TEXT,
|
||
device_address TEXT,
|
||
platform TEXT,
|
||
pdd_package TEXT,
|
||
capabilities TEXT,
|
||
last_seen_at TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
);`,
|
||
`CREATE TABLE idempotency_keys (
|
||
key TEXT PRIMARY KEY,
|
||
request_hash TEXT NOT NULL,
|
||
response_body TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
);`,
|
||
},
|
||
|
||
// v2: 领取历史。
|
||
//
|
||
// 为什么需要它:契约要求"只有**从未分配给该客户端**的任务才返回 403"
|
||
// (docs/admin/04-client-api.md §4.1)。但 tasks.assigned_client 只记
|
||
// **当前**归属,任务一旦重派给别人,就查不出原来那台领过——
|
||
// 而契约又明确要求"已重派仍要接受原客户端提交的结果"。
|
||
// 没有这张表,那条规则根本没法判断。
|
||
//
|
||
// 顺带得到一份审计记录:这个任务被哪几台客户端领过。
|
||
{
|
||
`CREATE TABLE task_claims (
|
||
task_id TEXT NOT NULL,
|
||
client_id TEXT NOT NULL,
|
||
claimed_at TEXT NOT NULL,
|
||
PRIMARY KEY (task_id, client_id)
|
||
);`,
|
||
`CREATE INDEX idx_task_claims_client ON task_claims(client_id);`,
|
||
},
|
||
}
|
||
|
||
// newV2NewStructureDB 造一个 user_version=2、但结构已经是最终形态的库——
|
||
// 对应 #16(commit 998c06a)改写后的 v1。这不是假设出来的边界情况:
|
||
// #20 审查阶段,用户按当时的工单指示删库重建后,得到的正是这种库
|
||
// (migrateV3 曾经对着已经存在的 pdd_products 又 CREATE 了一遍,
|
||
// 直接报 "table already exists")。
|
||
func newV2NewStructureDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
db, err := Open(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("打开测试库失败: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
for v, stmts := range migrations998c06a {
|
||
for i, stmt := range stmts {
|
||
if _, err := db.Exec(stmt); err != nil {
|
||
t.Fatalf("造 998c06a 结构的老库失败(v%d 第 %d 条语句): %v", v+1, i+1, err)
|
||
}
|
||
}
|
||
}
|
||
if _, err := db.Exec("PRAGMA user_version = 2"); err != nil {
|
||
t.Fatalf("设置 user_version 失败: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
// insertOldShopeeProduct 按 v1/v2 时代的旧 shopee_products 结构插一行
|
||
// (带 pdd_data / collect_status 等已经在 v3 里搬走的列)。
|
||
func insertOldShopeeProduct(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddGoodsURL, collectStatus string) {
|
||
t.Helper()
|
||
const now = "2026-08-01T00:00:00Z"
|
||
_, err := db.Exec(`
|
||
INSERT INTO shopee_products
|
||
(goods_id, title, pdd_goods_url, pdd_goods_id, collect_status,
|
||
created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
goodsID, "测试商品-"+goodsID, pddGoodsURL, pddGoodsID, collectStatus, now, now)
|
||
if err != nil {
|
||
t.Fatalf("插入老蝦皮商品 %s 失败: %v", goodsID, err)
|
||
}
|
||
}
|
||
|
||
// schemaRow 是 sqlite_master 里的一行。
|
||
type schemaRow struct {
|
||
typ string
|
||
name string
|
||
sql string
|
||
}
|
||
|
||
// dumpSchema 读出数据库里所有"有建表/建索引语句"的对象,按 type、name 排序。
|
||
//
|
||
// 排除 sql 为 NULL 的行(比如 UNIQUE 约束自动生成的隐藏索引)——那些不是
|
||
// migrations 里写的语句直接产生的,两条路径不保证顺序和命名一致,
|
||
// 比较它们没有意义,也不是这个测试要防的问题。
|
||
func dumpSchema(t *testing.T, db *sql.DB) []schemaRow {
|
||
t.Helper()
|
||
rows, err := db.Query(`
|
||
SELECT type, name, sql FROM sqlite_master
|
||
WHERE sql IS NOT NULL
|
||
ORDER BY type, name`)
|
||
if err != nil {
|
||
t.Fatalf("读取 schema 失败: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []schemaRow
|
||
for rows.Next() {
|
||
var r schemaRow
|
||
if err := rows.Scan(&r.typ, &r.name, &r.sql); err != nil {
|
||
t.Fatalf("读取 schema 失败: %v", err)
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("读取 schema 失败: %v", err)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool {
|
||
if out[i].typ != out[j].typ {
|
||
return out[i].typ < out[j].typ
|
||
}
|
||
return out[i].name < out[j].name
|
||
})
|
||
return out
|
||
}
|
||
|
||
// quotedIdentAfterCreate 匹配 `CREATE TABLE "foo" (` / `CREATE INDEX "foo" ON`
|
||
// 这种被引号包住的标识符,用于 normalizeCreateStatement。
|
||
var quotedIdentAfterCreate = regexp.MustCompile(
|
||
`^(CREATE (?:TABLE|UNIQUE INDEX|INDEX) )"([A-Za-z_][A-Za-z0-9_]*)"`)
|
||
|
||
// normalizeCreateStatement 去掉 CREATE TABLE/INDEX 语句里表名/索引名外层
|
||
// 那对可有可无的双引号,再比较文本。
|
||
//
|
||
// 为什么需要它:SQLite 的 `ALTER TABLE ... RENAME TO` 会把
|
||
// sqlite_master.sql 里的表名重写成**带双引号**的形式,哪怕原来没有引号:
|
||
//
|
||
// CREATE TABLE foo_new (a TEXT); ALTER TABLE foo_new RENAME TO foo;
|
||
// -> sqlite_master.sql = `CREATE TABLE "foo" (a TEXT)`
|
||
// CREATE TABLE foo (a TEXT); -- 从来没被 RENAME 过
|
||
// -> sqlite_master.sql = `CREATE TABLE foo (a TEXT)`
|
||
//
|
||
// migrateV3RebuildShopeeProducts 按 SQLite 官方 12 步流程重建
|
||
// shopee_products,最后一步是 RENAME,所以它产出的 shopee_products
|
||
// 一定带这层引号;而"结构已经是最终形态、v3 全程没碰过"的库
|
||
// (newV2NewStructureDB,对应 998c06a 建的、从未被改过名的表)没有这层引号。
|
||
// 两边是同一份列定义、同一张语义上完全等价的表,只是构造路径不同——
|
||
// 用 RENAME 而不是直接 CREATE 得到最终表名,这是
|
||
// migrateV3RebuildShopeeProducts 注释里解释过的、为了不破坏 shopee_skus
|
||
// 外键而必须付出的代价,不是缺陷,所以在比较时把这个无意义的差异抹平,
|
||
// 而不是反过来改生产代码去凑一个不安全的建表顺序。
|
||
func normalizeCreateStatement(sql string) string {
|
||
sql = strings.TrimSpace(sql)
|
||
return quotedIdentAfterCreate.ReplaceAllString(sql, "$1$2")
|
||
}
|
||
|
||
// assertSchemaEqual 逐条比较两份 schema:表和索引都要存在,
|
||
// 且**完整建表/建索引语句**(列定义、CHECK、PRIMARY KEY 都在这条语句里)
|
||
// 必须一致。只比表名的话,sku_mappings 主键从单列变两列这种问题照样漏过去。
|
||
func assertSchemaEqual(t *testing.T, label string, got, want []schemaRow) {
|
||
t.Helper()
|
||
|
||
toMap := func(rows []schemaRow) map[string]string {
|
||
m := make(map[string]string, len(rows))
|
||
for _, r := range rows {
|
||
m[r.typ+":"+r.name] = normalizeCreateStatement(r.sql)
|
||
}
|
||
return m
|
||
}
|
||
gotMap := toMap(got)
|
||
wantMap := toMap(want)
|
||
|
||
var diffs []string
|
||
for k, wantSQL := range wantMap {
|
||
gotSQL, ok := gotMap[k]
|
||
if !ok {
|
||
diffs = append(diffs, "缺少 "+k)
|
||
continue
|
||
}
|
||
if gotSQL != wantSQL {
|
||
diffs = append(diffs, k+" 定义不一致:\n--- 期望 ---\n"+wantSQL+"\n--- 实际 ---\n"+gotSQL)
|
||
}
|
||
}
|
||
for k := range gotMap {
|
||
if _, ok := wantMap[k]; !ok {
|
||
diffs = append(diffs, "多出 "+k)
|
||
}
|
||
}
|
||
|
||
if len(diffs) > 0 {
|
||
t.Errorf("%s: schema 不一致:\n%s", label, strings.Join(diffs, "\n\n"))
|
||
}
|
||
}
|
||
|
||
// ── 迁移收敛:核心交付物 ──────────────────────────────
|
||
|
||
func TestMigrate_不同起点最终schema一致(t *testing.T) {
|
||
fresh := newFreshDB(t)
|
||
freshSchema := dumpSchema(t, fresh)
|
||
if err := CheckSchema(fresh); err != nil {
|
||
t.Errorf("全新库迁移后应该通过自检: %v", err)
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
db *sql.DB
|
||
}{
|
||
{"v2 老结构库(#16 之前的 v1)", newV2DB(t)},
|
||
{"v2 新结构库(#16 改写后的 v1,见 998c06a)", newV2NewStructureDB(t)},
|
||
}
|
||
|
||
for _, c := range cases {
|
||
if err := Migrate(c.db); err != nil {
|
||
t.Fatalf("%s 迁移失败: %v", c.name, err)
|
||
}
|
||
gotSchema := dumpSchema(t, c.db)
|
||
assertSchemaEqual(t, "全新库 vs "+c.name, gotSchema, freshSchema)
|
||
|
||
var version int
|
||
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
|
||
}
|
||
if version != schemaVersion {
|
||
t.Errorf("%s 迁移后 user_version = %d,期望 %d(不能卡在中间版本)", c.name, version, schemaVersion)
|
||
}
|
||
|
||
if err := CheckSchema(c.db); err != nil {
|
||
t.Errorf("%s 迁移后应该通过自检: %v", c.name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMigrate_已经是最新版本再次调用不报错(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("对已经是最新版本的库重复调用 Migrate 不应该报错: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMigrate_v4增加可空店铺名且老数据保持NULL(t *testing.T) {
|
||
db := newV2NewStructureDB(t)
|
||
if _, err := db.Exec(`
|
||
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
|
||
VALUES ('737116531267', 'https://example.invalid', 'pending', '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z')`); err != nil {
|
||
t.Fatalf("插入 v2 老数据失败: %v", err)
|
||
}
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移到 v4 失败: %v", err)
|
||
}
|
||
|
||
columns, err := tableColumnSet(db, "pdd_products")
|
||
if err != nil {
|
||
t.Fatalf("读取 pdd_products 列失败: %v", err)
|
||
}
|
||
if !columns["shop_name"] {
|
||
t.Fatal("v4 必须新增 shop_name 列")
|
||
}
|
||
|
||
var shop sql.NullString
|
||
if err := db.QueryRow(
|
||
`SELECT shop_name FROM pdd_products WHERE goods_id = '737116531267'`,
|
||
).Scan(&shop); err != nil {
|
||
t.Fatalf("读取老数据店铺名失败: %v", err)
|
||
}
|
||
if shop.Valid {
|
||
t.Errorf("老数据的 shop_name 应为 NULL,实际 %q", shop.String)
|
||
}
|
||
}
|
||
|
||
// v2 新结构库(#16 改写后的 v1)第一次 Migrate 时,migrateV3 检测到结构
|
||
// 已经是最终形态,只更新版本号、不改任何表——第二次调用(对应用户重启
|
||
// 服务)应该是彻底的空操作:user_version 已经是 3,Migrate 最外层的
|
||
// 版本号判断就会直接跳过,连 migrateV3 都不会再进去。
|
||
func TestMigrate_v2新结构库连续调用两次不报错且第二次是空操作(t *testing.T) {
|
||
db := newV2NewStructureDB(t)
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("第一次迁移失败: %v", err)
|
||
}
|
||
var version1 int
|
||
if err := db.QueryRow("PRAGMA user_version").Scan(&version1); err != nil {
|
||
t.Fatalf("读取 user_version 失败: %v", err)
|
||
}
|
||
if version1 != schemaVersion {
|
||
t.Fatalf("第一次迁移后 user_version = %d,期望 %d", version1, schemaVersion)
|
||
}
|
||
schemaAfterFirst := dumpSchema(t, db)
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("第二次迁移失败: %v", err)
|
||
}
|
||
var version2 int
|
||
if err := db.QueryRow("PRAGMA user_version").Scan(&version2); err != nil {
|
||
t.Fatalf("读取 user_version 失败: %v", err)
|
||
}
|
||
if version2 != schemaVersion {
|
||
t.Errorf("第二次迁移后 user_version = %d,期望仍是 %d", version2, schemaVersion)
|
||
}
|
||
|
||
assertSchemaEqual(t, "第一次迁移后 vs 第二次迁移后", dumpSchema(t, db), schemaAfterFirst)
|
||
|
||
if err := CheckSchema(db); err != nil {
|
||
t.Errorf("连续迁移两次后应该通过自检: %v", err)
|
||
}
|
||
}
|
||
|
||
// v2 新结构库结构已经是最终形态,migrateV3 应该只更新版本号,
|
||
// 不应该打"新增 pdd_products / 重建 ..."这类日志——那是假话,
|
||
// 会让操作员误以为数据被动过。
|
||
func TestMigrate_v2新结构库只更新版本号不打误导性日志(t *testing.T) {
|
||
db := newV2NewStructureDB(t)
|
||
|
||
var logBuf bytes.Buffer
|
||
origOutput := log.Writer()
|
||
log.SetOutput(&logBuf)
|
||
defer log.SetOutput(origOutput)
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
if !strings.Contains(logBuf.String(), "结构已是最新,仅更新版本号") {
|
||
t.Errorf("日志应该说明结构已是最新,实际日志:\n%s", logBuf.String())
|
||
}
|
||
if strings.Contains(logBuf.String(), "新增 pdd_products") || strings.Contains(logBuf.String(), "重建") {
|
||
t.Errorf("结构已经是最终形态时不应该打\"新增/重建\"这类日志,实际日志:\n%s", logBuf.String())
|
||
}
|
||
}
|
||
|
||
// ── v5:顺运宝会话表、同步状态表、product_spec 列 ──────
|
||
|
||
// TestMigrate_不同起点最终schema一致(上面已有)用的是全量 dumpSchema 对比,
|
||
// v5 的新表/新列不需要改那个测试就已经被覆盖——这里再加行为断言,
|
||
// 专门锁住 v5 引入的三样东西,即使将来有人改坏了三起点收敛测试本身,
|
||
// 这几个测试仍然能单独发现问题。
|
||
func TestMigrate_v5新增会话表同步状态表和product_spec列(t *testing.T) {
|
||
for _, c := range []struct {
|
||
name string
|
||
db *sql.DB
|
||
}{
|
||
{"全新库", newFreshDB(t)},
|
||
{"v2 老结构库", newV2DB(t)},
|
||
{"v2 新结构库", newV2NewStructureDB(t)},
|
||
} {
|
||
if c.name != "全新库" {
|
||
if err := Migrate(c.db); err != nil {
|
||
t.Fatalf("%s 迁移失败: %v", c.name, err)
|
||
}
|
||
}
|
||
|
||
var version int
|
||
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
|
||
}
|
||
if version != schemaVersion {
|
||
t.Fatalf("%s user_version = %d,期望 %d", c.name, version, schemaVersion)
|
||
}
|
||
|
||
tables := existingTableSet(t, c.db)
|
||
for _, table := range []string{"syb_session", "syb_sync_state"} {
|
||
if !tables[table] {
|
||
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
|
||
}
|
||
}
|
||
|
||
cols, err := tableColumnSet(c.db, "syb_orders")
|
||
if err != nil {
|
||
t.Fatalf("%s 读取 syb_orders 列失败: %v", c.name, err)
|
||
}
|
||
if !cols["product_spec"] {
|
||
t.Errorf("%s:syb_orders 迁移后应该有 product_spec 列", c.name)
|
||
}
|
||
|
||
if err := CheckSchema(c.db); err != nil {
|
||
t.Errorf("%s:迁移后应该通过自检: %v", c.name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestMigrate_v6新增用户和WebSession表(t *testing.T) {
|
||
for _, c := range []struct {
|
||
name string
|
||
db *sql.DB
|
||
}{
|
||
{"全新库", newFreshDB(t)},
|
||
{"v2 老结构库", newV2DB(t)},
|
||
{"v2 新结构库", newV2NewStructureDB(t)},
|
||
} {
|
||
if c.name != "全新库" {
|
||
if err := Migrate(c.db); err != nil {
|
||
t.Fatalf("%s 迁移失败: %v", c.name, err)
|
||
}
|
||
}
|
||
tables := existingTableSet(t, c.db)
|
||
for _, table := range []string{"users", "web_sessions"} {
|
||
if !tables[table] {
|
||
t.Errorf("%s:迁移后应该有表 %s", c.name, table)
|
||
}
|
||
}
|
||
var version int
|
||
if err := c.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||
t.Fatalf("%s 读取 user_version 失败: %v", c.name, err)
|
||
}
|
||
if version != 6 {
|
||
t.Errorf("%s user_version = %d,期望 6", c.name, version)
|
||
}
|
||
}
|
||
}
|
||
|
||
// existingTableSet 返回库里当前存在的表名集合。
|
||
func existingTableSet(t *testing.T, db *sql.DB) map[string]bool {
|
||
t.Helper()
|
||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type = 'table'`)
|
||
if err != nil {
|
||
t.Fatalf("读取表清单失败: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
set := map[string]bool{}
|
||
for rows.Next() {
|
||
var name string
|
||
if err := rows.Scan(&name); err != nil {
|
||
t.Fatalf("读取表清单失败: %v", err)
|
||
}
|
||
set[name] = true
|
||
}
|
||
return set
|
||
}
|
||
|
||
// TestMigrate_v5会话表主键只允许一个用户名一行 验证 syb_session 的主键约束:
|
||
// 同一个用户名重复写入应该走 upsert(由 repository 层保证),底层主键必须
|
||
// 拒绝真正的重复插入,防止同一账号在表里出现两行、读的时候不知道信哪行。
|
||
func TestMigrate_v5会话表主键约束(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
const now = "2026-08-09T00:00:00Z"
|
||
insert := func() error {
|
||
_, err := db.Exec(`
|
||
INSERT INTO syb_session (username, cookies, expires_at, updated_at)
|
||
VALUES (?, ?, ?, ?)`, "tester", `[]`, now, now)
|
||
return err
|
||
}
|
||
if err := insert(); err != nil {
|
||
t.Fatalf("插入第一条会话失败: %v", err)
|
||
}
|
||
if err := insert(); err == nil {
|
||
t.Fatal("同一 username 重复插入应该被主键约束拒绝")
|
||
}
|
||
}
|
||
|
||
// TestMigrate_v5同步状态表只允许一行 验证 CHECK (id = 1) 确实生效——
|
||
// 这张表按设计只应该有一行(全局的"上次同步到哪"),如果这条 CHECK
|
||
// 被误删或写错,程序里到处用 id=1 查询的代码会开始读到错误的行。
|
||
func TestMigrate_v5同步状态表只允许id为1(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
const now = "2026-08-09T00:00:00Z"
|
||
if _, err := db.Exec(`
|
||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||
VALUES (1, NULL, ?)`, now); err != nil {
|
||
t.Fatalf("插入 id=1 失败: %v", err)
|
||
}
|
||
_, err := db.Exec(`
|
||
INSERT INTO syb_sync_state (id, last_synced_at, updated_at)
|
||
VALUES (2, NULL, ?)`, now)
|
||
if err == nil {
|
||
t.Fatal("id != 1 应该被 CHECK 约束拒绝")
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_缺少syb_session表时拒绝(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
if _, err := db.Exec(`DROP TABLE syb_session`); err != nil {
|
||
t.Fatalf("删表失败: %v", err)
|
||
}
|
||
err := CheckSchema(db)
|
||
if err == nil || !strings.Contains(err.Error(), "syb_session") {
|
||
t.Fatalf("缺 syb_session 表时应该拒绝启动并指出表名,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_缺少syb_sync_state表时拒绝(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
if _, err := db.Exec(`DROP TABLE syb_sync_state`); err != nil {
|
||
t.Fatalf("删表失败: %v", err)
|
||
}
|
||
err := CheckSchema(db)
|
||
if err == nil || !strings.Contains(err.Error(), "syb_sync_state") {
|
||
t.Fatalf("缺 syb_sync_state 表时应该拒绝启动并指出表名,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_缺少product_spec列时拒绝(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
// modernc.org/sqlite 支持 DROP COLUMN(SQLite ≥ 3.35),
|
||
// 用它来模拟"迁移没有完整落地、只有旧列"的库。
|
||
if _, err := db.Exec(`ALTER TABLE syb_orders DROP COLUMN product_spec`); err != nil {
|
||
t.Fatalf("模拟缺列失败: %v", err)
|
||
}
|
||
err := CheckSchema(db)
|
||
if err == nil || !strings.Contains(err.Error(), "product_spec") {
|
||
t.Fatalf("缺 product_spec 列时应该拒绝启动并指出列名,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// ── 最终 schema 的硬约束:不依赖路径比对 ──────────────
|
||
//
|
||
// TestMigrate_不同起点最终schema一致 证明的是"全新库"和"v2 老库"两条路径
|
||
// 走到最后**互相一致**。但 Migrate 让全新库也先走 v1/v2、再靠同一份
|
||
// migrateV3 收敛(见 Migrate 的注释),所以两条路径共用的正是 v3 这段代码。
|
||
// v3 自己要是写错了(CHECK 少约束了一个值、UNIQUE 丢了、主键退回单列),
|
||
// 两条路径会一起错,互相比对照样"一致",抓不出来。
|
||
//
|
||
// 这里改成直接对最终结构做**行为断言**:插一条数据看数据库报不报错,
|
||
// 不匹配 DDL 文本——字符串匹配的话,把 UNIQUE 换成等价的
|
||
// CREATE UNIQUE INDEX 写法就会被误判成失败,但那其实是对的写法。
|
||
func TestMigrate_最终schema符合设计要求(t *testing.T) {
|
||
t.Run("pdd_products_goods_id有UNIQUE约束", func(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
const now = "2026-08-01T00:00:00Z"
|
||
if _, err := db.Exec(`
|
||
INSERT INTO pdd_products (goods_id, url, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?)`, "PDD-DUP", "https://x.example/1", now, now); err != nil {
|
||
t.Fatalf("插入第一行失败: %v", err)
|
||
}
|
||
_, err := db.Exec(`
|
||
INSERT INTO pdd_products (goods_id, url, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?)`, "PDD-DUP", "https://x.example/2", now, now)
|
||
if err == nil {
|
||
t.Fatal("goods_id 重复应该被 UNIQUE 约束拒绝,但插入成功了——" +
|
||
"少了这条约束,同一个 PDD 商品会被存成好几行,采好几遍,映射说不清指向哪一行")
|
||
}
|
||
})
|
||
|
||
t.Run("pdd_products_collect_status只接受4个值且拒绝no_link", func(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
const now = "2026-08-01T00:00:00Z"
|
||
for i, status := range []string{"pending", "collecting", "collected", "failed"} {
|
||
goodsID := fmt.Sprintf("PDD-OK-%d", i)
|
||
if _, err := db.Exec(`
|
||
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)`, goodsID, "https://x.example/"+goodsID, status, now, now); err != nil {
|
||
t.Errorf("collect_status=%q 应该合法,插入失败: %v", status, err)
|
||
}
|
||
}
|
||
|
||
// `[必须]` 单独断言:pdd_products 里有这一行就说明链接已经填了,
|
||
// "未填链接"是蝦皮侧的状态(shopee_products.pdd_goods_id 为空)。
|
||
// 放回 no_link 会让两处状态重新打架,这正是 #16 要解决的问题之一。
|
||
_, err := db.Exec(`
|
||
INSERT INTO pdd_products (goods_id, url, collect_status, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)`, "PDD-NOLINK", "https://x.example/nolink", "no_link", now, now)
|
||
if err == nil {
|
||
t.Fatal("collect_status=no_link 应该被 CHECK 拒绝,但插入成功了")
|
||
}
|
||
})
|
||
|
||
t.Run("sku_mappings主键是shopee_sku_id和pdd_goods_id两列", func(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
const now = "2026-08-01T00:00:00Z"
|
||
|
||
if _, err := db.Exec(`
|
||
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?)`, "SP-PK", "测试商品", now, now); err != nil {
|
||
t.Fatalf("插入 shopee_products 失败: %v", err)
|
||
}
|
||
if _, err := db.Exec(`
|
||
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)`, "SKU-PK", "SP-PK", "黑色,M", now, now); err != nil {
|
||
t.Fatalf("插入 shopee_skus 失败: %v", err)
|
||
}
|
||
|
||
insert := func(pddGoodsID string) error {
|
||
_, err := db.Exec(`
|
||
INSERT INTO sku_mappings
|
||
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options, goods_id, mapped_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
"SKU-PK", pddGoodsID, `{"color":"黑色"}`, `{"color":"黑色"}`, "SP-PK", now)
|
||
return err
|
||
}
|
||
|
||
// 同一个蝦皮 SKU 换过 PDD 商品时,换之前(PDD-A)和换之后(PDD-B)
|
||
// 的映射要能同时存在——这正是主键带上 pdd_goods_id 的意义:
|
||
// 换成 B 不需要删 A 的映射,A 补货换回来时直接复用。
|
||
if err := insert("PDD-A"); err != nil {
|
||
t.Fatalf("插入第一条映射(PDD-A)失败: %v", err)
|
||
}
|
||
if err := insert("PDD-B"); err != nil {
|
||
t.Fatalf("同一个 shopee_sku_id、不同 pdd_goods_id 应该能共存"+
|
||
"(主键必须带上 pdd_goods_id),插入失败: %v", err)
|
||
}
|
||
|
||
// 完全相同的 (shopee_sku_id, pdd_goods_id) 才应该被主键拒绝。
|
||
if err := insert("PDD-A"); err == nil {
|
||
t.Fatal("(shopee_sku_id, pdd_goods_id) 重复应该被主键约束拒绝,但插入成功了")
|
||
}
|
||
|
||
var count int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id = ?`,
|
||
"SKU-PK").Scan(&count); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if count != 2 {
|
||
t.Errorf("应该有 2 条映射(PDD-A、PDD-B),实际 %d 条——"+
|
||
"如果主键退回单列 shopee_sku_id,第二条会插入失败", count)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ── 带数据的去重和状态映射 ────────────────────────────
|
||
|
||
func TestMigrate_老库两个蝦皮商品指向同一PDD链接_迁移后只有一行(t *testing.T) {
|
||
db := newV2DB(t)
|
||
insertOldShopeeProduct(t, db, "SP-A", "PDD-1", "https://a.example/1", "collected")
|
||
insertOldShopeeProduct(t, db, "SP-B", "PDD-1", "https://b.example/1?x=1", "collected")
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
var count int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, "PDD-1").Scan(&count); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if count != 1 {
|
||
t.Fatalf("两个蝦皮商品指向同一 PDD 链接,迁移后 pdd_products 应该只有 1 行,实际 %d 行", count)
|
||
}
|
||
|
||
var status string
|
||
if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-1").Scan(&status); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if status != "collected" {
|
||
t.Errorf("两行都是 collected,迁移后应该仍是 collected,实际 %q", status)
|
||
}
|
||
if status != "pending" && status != "collecting" && status != "collected" && status != "failed" {
|
||
t.Errorf("collect_status = %q 不是新 CHECK 允许的取值", status)
|
||
}
|
||
}
|
||
|
||
func TestMigrate_老库collecting映射成pending(t *testing.T) {
|
||
db := newV2DB(t)
|
||
insertOldShopeeProduct(t, db, "SP-C", "PDD-2", "https://x.example/2", "collecting")
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
var status string
|
||
if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-2").Scan(&status); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if status != "pending" {
|
||
t.Errorf("老库 collect_status=collecting,迁移后应为 pending"+
|
||
"(否则这个商品因为 MarkCollecting 只在 pending/failed 时成功,"+
|
||
"会永远建不了新的采集任务),实际 %q", status)
|
||
}
|
||
}
|
||
|
||
func TestMigrate_老库no_link映射成pending_不撞CHECK(t *testing.T) {
|
||
db := newV2DB(t)
|
||
// 正常情况下 no_link 不会同时带 pdd_goods_id(那是"未填链接"的状态),
|
||
// 但历史数据不保证一致,这里刻意构造这个边界组合:
|
||
// 确认它不会撞新 CHECK——新 pdd_products.collect_status 里没有 no_link。
|
||
insertOldShopeeProduct(t, db, "SP-D", "PDD-3", "https://x.example/3", "no_link")
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败(不应该撞 CHECK 约束): %v", err)
|
||
}
|
||
|
||
var status string
|
||
if err := db.QueryRow(`SELECT collect_status FROM pdd_products WHERE goods_id = ?`, "PDD-3").Scan(&status); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if status != "pending" {
|
||
t.Errorf("老库 collect_status=no_link,迁移后应为 pending,实际 %q", status)
|
||
}
|
||
}
|
||
|
||
// ── shopee_products 重建:列变化和外键安全 ────────────
|
||
|
||
func TestMigrate_shopee_products迁移后不再有旧的四个字段(t *testing.T) {
|
||
db := newV2DB(t)
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
cols := tableColumns(t, db, "shopee_products")
|
||
for _, gone := range []string{"pdd_data", "collect_status", "collect_error", "collected_at"} {
|
||
if cols[gone] {
|
||
t.Errorf("shopee_products 迁移后不应该还有列 %s", gone)
|
||
}
|
||
}
|
||
for _, keep := range []string{"goods_id", "title", "shopee_status", "main_sku_code",
|
||
"pdd_goods_url", "pdd_goods_id", "created_at", "updated_at"} {
|
||
if !cols[keep] {
|
||
t.Errorf("shopee_products 迁移后缺少列 %s", keep)
|
||
}
|
||
}
|
||
}
|
||
|
||
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||
t.Helper()
|
||
// table 只来自测试里写死的常量,不是外部输入,字符串拼接是安全的
|
||
// (PRAGMA 本身也不支持参数化,db.go 里 PRAGMA user_version 也是这么处理的)。
|
||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||
if err != nil {
|
||
t.Fatalf("读取 %s 的列失败: %v", table, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
cols := map[string]bool{}
|
||
for rows.Next() {
|
||
var cid, notnull, pk int
|
||
var name, ctype string
|
||
var dflt sql.NullString
|
||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||
t.Fatalf("读取 %s 的列失败: %v", table, err)
|
||
}
|
||
cols[name] = true
|
||
}
|
||
return cols
|
||
}
|
||
|
||
func TestMigrate_shopee_skus数据不被外键带走(t *testing.T) {
|
||
db := newV2DB(t)
|
||
insertOldShopeeProduct(t, db, "SP-E", "", "", "no_link")
|
||
const now = "2026-08-01T00:00:00Z"
|
||
_, err := db.Exec(`
|
||
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, parse_ok, created_at, updated_at)
|
||
VALUES (?, ?, ?, 1, ?, ?)`,
|
||
"SKU-1", "SP-E", "黑色,M", now, now)
|
||
if err != nil {
|
||
t.Fatalf("插入老 shopee_skus 失败: %v", err)
|
||
}
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
var count int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE sku_id = ?`, "SKU-1").Scan(&count); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if count != 1 {
|
||
t.Fatalf("重建 shopee_products 时把 shopee_skus 的数据带丢了,剩 %d 行", count)
|
||
}
|
||
|
||
var goodsID string
|
||
if err := db.QueryRow(`SELECT goods_id FROM shopee_skus WHERE sku_id = ?`, "SKU-1").Scan(&goodsID); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if goodsID != "SP-E" {
|
||
t.Errorf("shopee_skus.goods_id 应该还是 SP-E,实际 %q", goodsID)
|
||
}
|
||
}
|
||
|
||
// ── sku_mappings 丢弃并打日志 ─────────────────────────
|
||
|
||
func TestMigrate_旧sku_mappings有数据时丢弃并打日志(t *testing.T) {
|
||
db := newV2DB(t)
|
||
const now = "2026-08-01T00:00:00Z"
|
||
insertOldShopeeProduct(t, db, "SP-F", "", "", "no_link")
|
||
if _, err := db.Exec(`
|
||
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, parse_ok, created_at, updated_at)
|
||
VALUES (?, ?, ?, 1, ?, ?)`, "SKU-2", "SP-F", "黑色,M", now, now); err != nil {
|
||
t.Fatalf("插入老 shopee_skus 失败: %v", err)
|
||
}
|
||
if _, err := db.Exec(`
|
||
INSERT INTO sku_mappings (shopee_sku_id, goods_id, pdd_options, mapped_at, mapped_by)
|
||
VALUES (?, ?, ?, ?, ?)`, "SKU-2", "SP-F", `{"color":"黑色"}`, now, "tester"); err != nil {
|
||
t.Fatalf("插入老 sku_mappings 失败: %v", err)
|
||
}
|
||
|
||
var logBuf bytes.Buffer
|
||
origOutput := log.Writer()
|
||
log.SetOutput(&logBuf)
|
||
defer log.SetOutput(origOutput)
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
if !strings.Contains(logBuf.String(), "丢弃了 1 条旧规格映射") {
|
||
t.Errorf("日志里没找到丢弃提示,实际日志:\n%s", logBuf.String())
|
||
}
|
||
|
||
var count int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM sku_mappings`).Scan(&count); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
if count != 0 {
|
||
t.Errorf("旧 sku_mappings 应该被丢弃,实际还剩 %d 行", count)
|
||
}
|
||
|
||
// 新表必须支持新主键需要的两列,插入一条验证结构真的换过来了。
|
||
if _, err := db.Exec(`
|
||
INSERT INTO sku_mappings
|
||
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options, goods_id, mapped_at)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
"SKU-2", "PDD-9", `{"color":"黑色"}`, `{"color":"黑色"}`, "SP-F", now); err != nil {
|
||
t.Errorf("新 sku_mappings 应该支持 pdd_goods_id / pdd_option_key,插入失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestMigrate_旧sku_mappings为空时不打日志(t *testing.T) {
|
||
db := newV2DB(t)
|
||
|
||
var logBuf bytes.Buffer
|
||
origOutput := log.Writer()
|
||
log.SetOutput(&logBuf)
|
||
defer log.SetOutput(origOutput)
|
||
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
|
||
if strings.Contains(logBuf.String(), "丢弃了") {
|
||
t.Errorf("没有旧映射数据时不应该打丢弃日志,实际日志:\n%s", logBuf.String())
|
||
}
|
||
}
|
||
|
||
// ── 启动时 schema 自检 ────────────────────────────────
|
||
|
||
func TestCheckSchema_全新库通过(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
if err := CheckSchema(db); err != nil {
|
||
t.Errorf("全新迁移后的库应该通过自检: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_v2老库迁移后通过(t *testing.T) {
|
||
db := newV2DB(t)
|
||
if err := Migrate(db); err != nil {
|
||
t.Fatalf("迁移失败: %v", err)
|
||
}
|
||
if err := CheckSchema(db); err != nil {
|
||
t.Errorf("v2 老库迁移后应该通过自检: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_缺少v4关键列时拒绝(t *testing.T) {
|
||
db := newV2NewStructureDB(t)
|
||
if err := migrateV3(db); err != nil {
|
||
t.Fatalf("准备 v3 数据库失败: %v", err)
|
||
}
|
||
// 故意跳过 v4(不加 shop_name),但把 v5/v6 补上——否则 CheckSchema 会先
|
||
// 因为缺后续表报错,测不到本测试真正要覆盖的"缺 shop_name"这条路径。
|
||
if err := runSQLMigration(db, 5, migrationV5); err != nil {
|
||
t.Fatalf("准备 v5 数据库失败: %v", err)
|
||
}
|
||
if err := runSQLMigration(db, 6, migrationV6); err != nil {
|
||
t.Fatalf("准备 v6 数据库失败: %v", err)
|
||
}
|
||
|
||
err := CheckSchema(db)
|
||
if err == nil || !strings.Contains(err.Error(), "shop_name") {
|
||
t.Fatalf("缺少 shop_name 时应拒绝启动并指出列名,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCheckSchema_缺表时拒绝(t *testing.T) {
|
||
db := newFreshDB(t)
|
||
if _, err := db.Exec(`DROP TABLE pdd_products`); err != nil {
|
||
t.Fatalf("删表失败: %v", err)
|
||
}
|
||
|
||
err := CheckSchema(db)
|
||
if err == nil {
|
||
t.Fatal("缺表时 CheckSchema 应该返回错误,拒绝启动,而不是静默通过")
|
||
}
|
||
if !strings.Contains(err.Error(), "pdd_products") {
|
||
t.Errorf("错误信息应该指出缺的是哪张表,实际: %v", err)
|
||
}
|
||
}
|