From 439c0ea5da97731a988c393c3d5d348b99c9323d Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 00:01:43 +0800 Subject: [PATCH] =?UTF-8?q?test(server/db):=20=E5=A4=9A=E5=BA=93(4/4)?= =?UTF-8?q?=E2=80=94=20SQLite=20=E5=AE=9E=E5=BA=93=E6=B5=8B=E8=AF=95=20+?= =?UTF-8?q?=20dialect=20SQL=20=E6=96=AD=E8=A8=80=20+=20=E5=8F=8C=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/store SQLite 实库行为测试(免 docker、真连引擎):用量累加 upsert、 节点累加、directory_version 自增、幂等 no-op、凭据 upsert、兑换码全流程 (锁 + 标记已用 + 建订阅 + Go 端续期) - internal/db dialect SQL 逐字断言(MySQL/SQLite 两侧生成串),无需起 MySQL 即可锁定 MySQL 侧 SQL 与重构前一致 - run_sqlite_test.sh:双引擎测试矩阵的 SQLite 半边(零 docker) Co-Authored-By: Claude Opus 4.8 --- server/internal/db/dialect_test.go | 71 ++++++ server/internal/store/sqlite_stores_test.go | 247 ++++++++++++++++++++ server/run_sqlite_test.sh | 10 + 3 files changed, 328 insertions(+) create mode 100644 server/internal/db/dialect_test.go create mode 100644 server/internal/store/sqlite_stores_test.go create mode 100755 server/run_sqlite_test.sh diff --git a/server/internal/db/dialect_test.go b/server/internal/db/dialect_test.go new file mode 100644 index 0000000..866cc6b --- /dev/null +++ b/server/internal/db/dialect_test.go @@ -0,0 +1,71 @@ +package db + +import "testing" + +// These pin the exact SQL each dialect generates. The MySQL side is the part we +// can't exercise against a live server without docker, so asserting the strings +// here proves MySQL output is byte-for-byte what the store layer used before the +// refactor (the SQLite half is additionally exercised against a real engine). + +func TestMySQLDialect_SQL(t *testing.T) { + d := MySQLDialect{} + + if got := d.LockForUpdate(); got != "FOR UPDATE" { + t.Errorf("LockForUpdate = %q, want %q", got, "FOR UPDATE") + } + + // No-op upsert (insert-or-ignore) — matches the old provision_idempotency SQL. + if got := d.Upsert([]string{"idempotency_key"}); got != "ON DUPLICATE KEY UPDATE idempotency_key = idempotency_key" { + t.Errorf("noop upsert = %q", got) + } + + // Accumulate upsert — EXCLUDED.col must rewrite to VALUES(col). + got := d.Upsert([]string{"user_id", "date"}, + "bytes_up = bytes_up + EXCLUDED.bytes_up", + "minutes_used = minutes_used + EXCLUDED.minutes_used") + want := "ON DUPLICATE KEY UPDATE bytes_up = bytes_up + VALUES(bytes_up), minutes_used = minutes_used + VALUES(minutes_used)" + if got != want { + t.Errorf("accumulate upsert:\n got=%q\nwant=%q", got, want) + } + + // version bump + if got := d.Upsert([]string{"id"}, "version = version + 1"); got != "ON DUPLICATE KEY UPDATE version = version + 1" { + t.Errorf("bump upsert = %q", got) + } +} + +func TestSQLiteDialect_SQL(t *testing.T) { + d := SQLiteDialect{} + + if got := d.LockForUpdate(); got != "" { + t.Errorf("LockForUpdate = %q, want empty (BEGIN IMMEDIATE handles locking)", got) + } + + if got := d.Upsert([]string{"idempotency_key"}); got != "ON CONFLICT(idempotency_key) DO NOTHING" { + t.Errorf("noop upsert = %q", got) + } + + got := d.Upsert([]string{"user_id", "date"}, + "bytes_up = bytes_up + EXCLUDED.bytes_up", + "minutes_used = minutes_used + EXCLUDED.minutes_used") + want := "ON CONFLICT(user_id, date) DO UPDATE SET bytes_up = bytes_up + EXCLUDED.bytes_up, minutes_used = minutes_used + EXCLUDED.minutes_used" + if got != want { + t.Errorf("accumulate upsert:\n got=%q\nwant=%q", got, want) + } + + if got := d.Upsert([]string{"id"}, "version = version + 1"); got != "ON CONFLICT(id) DO UPDATE SET version = version + 1" { + t.Errorf("bump upsert = %q", got) + } +} + +func TestDialectFor(t *testing.T) { + if DialectFor("mysql").Name() != "mysql" { + t.Error("DialectFor(mysql)") + } + if DialectFor("sqlite").Name() != "sqlite" { + t.Error("DialectFor(sqlite)") + } + if DialectFor("").Name() != "mysql" { + t.Error("DialectFor(empty) should default to mysql") + } +} diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go new file mode 100644 index 0000000..4d65c02 --- /dev/null +++ b/server/internal/store/sqlite_stores_test.go @@ -0,0 +1,247 @@ +package store_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/config" + dbx "github.com/wangjia/pangolin/server/internal/db" + "github.com/wangjia/pangolin/server/internal/nodes" + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" + "github.com/wangjia/pangolin/server/internal/provision" + "github.com/wangjia/pangolin/server/internal/store" + "github.com/wangjia/pangolin/server/internal/usage" +) + +// openSQLite returns a freshly-migrated in-memory SQLite DB. +func openSQLite(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// These tests exercise the dialect-sensitive store methods (upserts, row locks, +// Go-side date math) against real SQLite — the behavioral proof that P2/P3 work. + +func TestSQLite_UsageAccumulate(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + us := usage.NewStore(db) + day := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC) + + if err := us.AggregateUsage(ctx, 1, day, 100, 200, 5); err != nil { + t.Fatalf("aggregate 1: %v", err) + } + if err := us.AggregateUsage(ctx, 1, day, 50, 25, 3); err != nil { + t.Fatalf("aggregate 2: %v", err) + } + + var up, down, mins int64 + if err := db.QueryRow( + `SELECT bytes_up, bytes_down, minutes_used FROM usage_daily WHERE user_id=1`, + ).Scan(&up, &down, &mins); err != nil { + t.Fatalf("read: %v", err) + } + if up != 150 || down != 225 || mins != 8 { + t.Errorf("accumulate upsert wrong: up=%d down=%d mins=%d, want 150/225/8", up, down, mins) + } +} + +func TestSQLite_NodeAccumulateUsage(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + ns := nodes.NewSQLNodeStore(db) + day := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC) + + if err := ns.AccumulateUsage(ctx, 7, day, 10, 20, 1); err != nil { + t.Fatalf("accumulate 1: %v", err) + } + if err := ns.AccumulateUsage(ctx, 7, day, 5, 5, 2); err != nil { + t.Fatalf("accumulate 2: %v", err) + } + var up, down, mins int64 + if err := db.QueryRow( + `SELECT bytes_up, bytes_down, minutes_used FROM usage_daily WHERE user_id=7`, + ).Scan(&up, &down, &mins); err != nil { + t.Fatalf("read: %v", err) + } + if up != 15 || down != 25 || mins != 3 { + t.Errorf("node accumulate wrong: up=%d down=%d mins=%d, want 15/25/3", up, down, mins) + } +} + +func TestSQLite_DirectoryVersionBump(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + d := dbx.DialectForDB(db) + + // Migration 7 seeds (id=1, version=1). Bump twice → 3. + for i := 0; i < 2; i++ { + if err := store.BumpDirectoryVersion(ctx, db, d); err != nil { + t.Fatalf("bump %d: %v", i, err) + } + } + var v int64 + if err := db.QueryRow(`SELECT version FROM directory_version WHERE id=1`).Scan(&v); err != nil { + t.Fatalf("read: %v", err) + } + if v != 3 { + t.Errorf("version = %d, want 3", v) + } +} + +func TestSQLite_IdempotencyNoopUpsert(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + ps := provision.NewMySQLStore(db) + + if err := ps.SaveIdempotency(ctx, "k1", "uuid-a"); err != nil { + t.Fatalf("save 1: %v", err) + } + // Second save with same key must be a no-op (insert-or-ignore), keeping uuid-a. + if err := ps.SaveIdempotency(ctx, "k1", "uuid-b"); err != nil { + t.Fatalf("save 2: %v", err) + } + got, ok, err := ps.LookupIdempotency(ctx, "k1") + if err != nil { + t.Fatalf("lookup: %v", err) + } + if !ok || got != "uuid-a" { + t.Errorf("idempotency no-op failed: got=%q ok=%v, want uuid-a", got, ok) + } +} + +func TestSQLite_PersistCredentialUpsert(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + seedNode(t, db, 1) + ns := nodes.NewSQLNodeStore(db) + + exp1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + cred := &agentv1.Credential{DpUUID: "dp-1", Protocol: 3, Flow: "xtls-rprx-vision"} + if err := ns.PersistCredential(ctx, 1, cred, exp1); err != nil { + t.Fatalf("persist 1: %v", err) + } + // Upsert same (node_id, dp_uuid) with a later expiry → row updated, not duplicated. + exp2 := exp1.Add(24 * time.Hour) + if err := ns.PersistCredential(ctx, 1, cred, exp2); err != nil { + t.Fatalf("persist 2: %v", err) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM connect_credentials WHERE node_id=1 AND dp_uuid='dp-1'`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("credential rows = %d, want 1 (upsert, not insert)", n) + } +} + +func TestSQLite_CodesRedeemFlow(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + seedUser(t, db, 1) + cs := codes.NewStore(db) + + // Seed a batch + an 'unused' code for plan 'pro' (seeded id resolved via store). + planID, err := cs.GetPlanID(ctx, codes.PlanPro) + if err != nil { + t.Fatalf("plan id: %v", err) + } + batchID, err := cs.CreateBatch(ctx, codes.ChannelManual, "tester", "") + if err != nil { + t.Fatalf("create batch: %v", err) + } + const hash = "abc123hash" + if err := cs.CreateCode(ctx, hash, planID, 30, batchID); err != nil { + t.Fatalf("create code: %v", err) + } + + // Redeem inside a tx: lock the code (FOR UPDATE on mysql / BEGIN IMMEDIATE on + // sqlite), mark redeemed, create a subscription with Go-computed expiry. + tx, err := cs.BeginTx(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + cr, err := cs.FindCodeByHashForUpdate(ctx, tx, hash) + if err != nil || cr == nil { + _ = tx.Rollback() + t.Fatalf("find for update: cr=%v err=%v", cr, err) + } + if cr.Status != "unused" { + _ = tx.Rollback() + t.Fatalf("code status = %q, want unused", cr.Status) + } + if err := cs.MarkRedeemed(ctx, tx, cr.ID, 1); err != nil { + _ = tx.Rollback() + t.Fatalf("mark redeemed: %v", err) + } + subID, err := cs.CreateSubscription(ctx, tx, 1, planID, 30, time.Time{}) + if err != nil { + _ = tx.Rollback() + t.Fatalf("create sub: %v", err) + } + // Extend by 10 more days (Go-side max(expires,now)+days). + if err := cs.ExtendSubscription(ctx, tx, subID, 10); err != nil { + _ = tx.Rollback() + t.Fatalf("extend: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + // Verify: code redeemed; subscription expires ~40 days out. + var status string + if err := db.QueryRow(`SELECT status FROM codes WHERE id=?`, cr.ID).Scan(&status); err != nil { + t.Fatalf("read code: %v", err) + } + if status != "redeemed" { + t.Errorf("code status = %q, want redeemed", status) + } + var expires time.Time + if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE id=?`, subID).Scan(&expires); err != nil { + t.Fatalf("read sub: %v", err) + } + wantMin := time.Now().UTC().AddDate(0, 0, 39) + if expires.Before(wantMin) { + t.Errorf("subscription expires_at = %v, want ≥ ~40 days out (%v)", expires, wantMin) + } +} + +// --- seed helpers (raw SQL, satisfy foreign keys) --- + +func seedUser(t *testing.T, db *sql.DB, id int64) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at) + VALUES (?, ?, ?, 'x', ?, 'active', ?)`, + id, "u-uuid", "u@example.com", "dp-u", time.Now().UTC()); err != nil { + t.Fatalf("seed user: %v", err) + } +} + +func seedNode(t *testing.T, db *sql.DB, id int64) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO providers (id, name, api_kind, regions, pool, enabled) + VALUES (1, 'p', 'fake', '[]', 'consumable', 1)`); err != nil { + t.Fatalf("seed provider: %v", err) + } + if _, err := db.Exec( + `INSERT INTO nodes (id, uuid, region, name_zh, name_en, role, tier, endpoint, + reality_pbk, reality_sni, provider_id, status, weight, created_at) + VALUES (?, 'n-uuid', 'HK', 'zh', 'en', 'entry', 'pro', '1.2.3.4:443', + 'pbk', 'www.apple.com', 1, 'up', 100, ?)`, + id, time.Now().UTC()); err != nil { + t.Fatalf("seed node: %v", err) + } +} diff --git a/server/run_sqlite_test.sh b/server/run_sqlite_test.sh new file mode 100755 index 0000000..19c2829 --- /dev/null +++ b/server/run_sqlite_test.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Run the SQLite-backed data-layer tests. Unlike run_mysql_test.sh these need no +# container — modernc.org/sqlite is pure Go and the DB is in-memory — so this is +# the SQLite half of the dual-engine test matrix and runs in normal CI. +set -e + +echo "Running SQLite migration + store dialect tests..." +go test ./internal/store/ ./internal/db/ \ + -run 'TestSQLite|TestSQLiteMigrateUpDown' \ + -v -count=1 -timeout 120s