Files
pangolin/server/internal/store/pay_migration_rehearsal_test.go
T
wangjia 390b8b8f84 chore(server): pay v2 接入终验 + OpenAPI 登记 + 交付说明
全量矩阵本地全绿(build/vet/test/run_sqlite_test.sh + 新增 SQLite 文件库带数据
升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM;client analyze/test 功能全绿,
27 个既有 golden 像素噪声与本次接入无关)。server/api/openapi.yaml 登记 6 个新
Pay 端点(catalog/orders/orderNo/retry/cancel/webhook)+ schema,通过结构校验。

顺手修复 run_mysql_test.sh 缺 multiStatements=true 的既有 bug(与 pay-v2 无关)。
新增 pay_migration_rehearsal_test.go 验证 000021 升级不丢行/AUTOINCREMENT 续序,
并确认一处安全特性:已有 source='pay' 行后 down 会被 CHECK 约束正确拒绝。

交付说明 docs/pay-v2-integration-delivery.html(含测试矩阵/联调 checklist/部署
附录)已登记 docs/index.html,记录一处既有但未修的部署前置阻断项(go.mod 的
wangjia/codes 本地路径 replace 会阻断异机构建,超出本任务授权范围)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 00:34:10 +08:00

280 lines
10 KiB
Go

package store_test
import (
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"github.com/golang-migrate/migrate/v4"
migratesqlite "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
"github.com/wangjia/pangolin/server/migrations"
)
// migratorAt builds a golang-migrate instance against a file-backed SQLite DB
// and steps it to exactly `version` (unlike store.MigrateUp/Down, which always
// target head/0). This is Task 8 Step 2's upgrade rehearsal: a real file DB
// (not :memory:) simulating a production sqlite store carrying pre-000021 rows
// through the 000021 subscriptions-table rebuild.
func migratorAt(t *testing.T, database *sql.DB, version uint) {
t.Helper()
src, err := iofs.New(migrations.SQLiteFS, "sqlite")
if err != nil {
t.Fatalf("iofs source: %v", err)
}
defer src.Close()
mdriver, err := migratesqlite.WithInstance(database, &migratesqlite.Config{})
if err != nil {
t.Fatalf("sqlite migrate driver: %v", err)
}
m, err := migrate.NewWithInstance("iofs", src, "sqlite", mdriver)
if err != nil {
t.Fatalf("new migrator: %v", err)
}
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
t.Fatalf("migrate to version %d: %v", version, err)
}
}
// insertLegacySubRow inserts one subscriptions row with a pre-000021 source
// value ('trial' or 'code') for a fresh user, returning the assigned user_id
// and subscription id.
func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source string) (userID, subID int64) {
t.Helper()
res, err := database.Exec(
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) VALUES (?, ?, 'x', ?, 'active')`,
"u-"+uuidSuffix, uuidSuffix+"@example.com", "dp-"+uuidSuffix,
)
if err != nil {
t.Fatalf("insert user(%s): %v", source, err)
}
userID, err = res.LastInsertId()
if err != nil {
t.Fatalf("user LastInsertId: %v", err)
}
var planID int64
if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil {
t.Fatalf("lookup pro plan id: %v", err)
}
res, err = database.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`,
userID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), source,
)
if err != nil {
t.Fatalf("insert subscription(source=%s): %v", source, err)
}
subID, err = res.LastInsertId()
if err != nil {
t.Fatalf("subscription LastInsertId: %v", err)
}
return userID, subID
}
// TestSQLitePayMigrationRehearsal_UpgradeWithData is Task 8 Step 2: rehearse
// the 000021 upgrade (subscriptions table rebuild for source='pay') against a
// file-backed SQLite DB pre-loaded with real 'trial'/'code' rows, and assert
// no rows are lost and the id/AUTOINCREMENT sequence is preserved.
func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) {
dsn := filepath.Join(t.TempDir(), "pay_upgrade_rehearsal.db")
database, err := store.Open(&config.Config{Driver: "sqlite", DSN: dsn})
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer database.Close()
// 1. Up to 000020 (pre-pay baseline; plans already seeded by 000007).
migratorAt(t, database, 20)
trialUserID, trialSubID := insertLegacySubRow(t, database, "trial1", "trial")
codeUserID, codeSubID := insertLegacySubRow(t, database, "code1", "code")
_ = trialUserID
_ = codeUserID
// 2. Up to 000021 — subscriptions_new rebuild + pay_purchases creation.
migratorAt(t, database, 21)
v, dirty, err := store.MigrateVersion(database, "sqlite")
if err != nil {
t.Fatalf("MigrateVersion: %v", err)
}
if dirty || v != 21 {
t.Fatalf("after up to 21: version=%d dirty=%v, want 21/false", v, dirty)
}
// 3. Row count and ids preserved across the rebuild.
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil {
t.Fatalf("count subscriptions: %v", err)
}
if count != 2 {
t.Errorf("subscriptions count after 000021 = %d, want 2 (rows lost in rebuild)", count)
}
for _, want := range []struct {
id int64
source string
}{{trialSubID, "trial"}, {codeSubID, "code"}} {
var gotSource string
if err := database.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, want.id).Scan(&gotSource); err != nil {
t.Errorf("subscription id=%d missing after 000021: %v", want.id, err)
continue
}
if gotSource != want.source {
t.Errorf("subscription id=%d source = %q, want %q", want.id, gotSource, want.source)
}
}
// 4. New source='pay' value now accepted, and AUTOINCREMENT continues
// (not reset to 1 by the table rebuild).
var planID int64
if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil {
t.Fatalf("lookup pro plan id: %v", err)
}
res, err := database.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, 'pay')`,
trialUserID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"),
)
if err != nil {
t.Fatalf("insert source='pay' subscription after 000021: %v", err)
}
paySubID, err := res.LastInsertId()
if err != nil {
t.Fatalf("pay subscription LastInsertId: %v", err)
}
if paySubID <= codeSubID {
t.Errorf("pay subscription id=%d did not continue AUTOINCREMENT sequence (prior max id=%d)", paySubID, codeSubID)
}
// 5. pay_purchases table exists and is empty (fresh table from 000021).
var payCount int
if err := database.QueryRow(`SELECT COUNT(*) FROM pay_purchases`).Scan(&payCount); err != nil {
t.Fatalf("count pay_purchases (table should exist post-000021): %v", err)
}
if payCount != 0 {
t.Errorf("pay_purchases count = %d, want 0 (fresh table)", payCount)
}
}
// TestSQLitePayMigrationRehearsal_DownUpIdempotent covers the second half of
// Task 8 Step 2: with only pre-000021 ('trial'/'code') data present, down
// (rollback 000021) then up (re-apply) must be idempotent and lossless.
//
// It also documents an intentional safety property: once a source='pay' row
// exists, 000021's down.sql (which rebuilds subscriptions with the stricter
// CHECK (source IN ('trial','code'))) correctly REFUSES to downgrade rather
// than silently dropping paid-subscription rows — see the trailing assertion.
func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) {
dsn := filepath.Join(t.TempDir(), "pay_downup_rehearsal.db")
database, err := store.Open(&config.Config{Driver: "sqlite", DSN: dsn})
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer database.Close()
migratorAt(t, database, 20)
_, trialSubID := insertLegacySubRow(t, database, "trial2", "trial")
_, codeSubID := insertLegacySubRow(t, database, "code2", "code")
migratorAt(t, database, 21)
// down: 000021 -> 000020. No 'pay' rows exist yet, so this must succeed
// and preserve the trial/code rows with their original ids.
migratorAt(t, database, 20)
v, dirty, err := store.MigrateVersion(database, "sqlite")
if err != nil {
t.Fatalf("MigrateVersion after down: %v", err)
}
if dirty || v != 20 {
t.Fatalf("after down to 20: version=%d dirty=%v, want 20/false", v, dirty)
}
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil {
t.Fatalf("count subscriptions after down: %v", err)
}
if count != 2 {
t.Errorf("subscriptions count after down to 000020 = %d, want 2 (rows lost on downgrade)", count)
}
for _, id := range []int64{trialSubID, codeSubID} {
var exists int
if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE id = ?`, id).Scan(&exists); err != nil || exists != 1 {
t.Errorf("subscription id=%d missing after down to 000020 (err=%v)", id, err)
}
}
var hasPayTable int
err = database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='pay_purchases'`).Scan(&hasPayTable)
if err != nil || hasPayTable != 0 {
t.Errorf("pay_purchases table still present after down to 000020 (err=%v)", err)
}
// up: 000020 -> 000021 again — idempotent re-apply, same rows/ids.
migratorAt(t, database, 21)
v, dirty, err = store.MigrateVersion(database, "sqlite")
if err != nil {
t.Fatalf("MigrateVersion after re-up: %v", err)
}
if dirty || v != 21 {
t.Fatalf("after re-up to 21: version=%d dirty=%v, want 21/false", v, dirty)
}
if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil {
t.Fatalf("count subscriptions after re-up: %v", err)
}
if count != 2 {
t.Errorf("subscriptions count after down+up cycle = %d, want 2", count)
}
// Safety-net documentation: once a source='pay' row exists, down must be
// refused (CHECK (source IN ('trial','code')) on the down-rebuilt table),
// not silently drop it. This is NOT a bug — see 000021.down.sql.
var planID int64
if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil {
t.Fatalf("lookup pro plan id: %v", err)
}
var uid int64
if err := database.QueryRow(`SELECT user_id FROM subscriptions WHERE id = ?`, trialSubID).Scan(&uid); err != nil {
t.Fatalf("lookup trial subscription user_id: %v", err)
}
if _, err := database.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, 'pay')`,
uid, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"),
); err != nil {
t.Fatalf("insert source='pay' subscription: %v", err)
}
src, err := iofs.New(migrations.SQLiteFS, "sqlite")
if err != nil {
t.Fatalf("iofs source: %v", err)
}
defer src.Close()
mdriver, err := migratesqlite.WithInstance(database, &migratesqlite.Config{})
if err != nil {
t.Fatalf("sqlite migrate driver: %v", err)
}
m, err := migrate.NewWithInstance("iofs", src, "sqlite", mdriver)
if err != nil {
t.Fatalf("new migrator: %v", err)
}
downErr := m.Migrate(20)
if downErr == nil {
t.Error("expected down to 000020 to FAIL once a source='pay' row exists " +
"(subscriptions_old CHECK (source IN ('trial','code'))); it succeeded instead " +
"— either the safety property regressed or a 'pay' row was silently dropped")
} else {
t.Logf("down to 000020 correctly refused with a source='pay' row present: %v", downErr)
}
// This DB file (and any dirty migration-version bookkeeping from the
// refused down above) is discarded with t.TempDir() at test end.
}