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. }