Files
pangolin/server/internal/store/sqlite_per_device_test.go
wangjia 636a3bbf2f feat(server/stats): stats-overhaul Phase2 — 每设备归因 + GB 综合配额
服务端记账从「账户」细到「每设备」(每设备独立 dp_uuid),配额单位分钟→GB
按账户综合卡控。000015_per_device_usage 迁移(mysql+sqlite 双份):devices.dp_uuid
+ usage_device_daily 表 + plans.daily_mb。handler_grpc 按 dp_uuid 回映射
(user_id,device_id) 双写账户+每设备;usage 服务/handler 暴露 /v1/usage(/devices)。
含 sqlite_per_device / usage handler 测试。

注:此迁移 prod 已 migrate up 运行、部署二进制已内嵌;本次补提交使 git 与线上一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:12:00 +08:00

294 lines
9.6 KiB
Go

package store_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/wangjia/pangolin/server/internal/nodes"
"github.com/wangjia/pangolin/server/internal/usage"
)
// seedDevice inserts a device row with a NULL dp_uuid (awaiting lazy mint).
func seedDevice(t *testing.T, db *sql.DB, id, userID int64, uuid string) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO devices (id, uuid, user_id, name, platform, created_at)
VALUES (?, ?, ?, ?, 'ios', ?)`,
id, uuid, userID, "dev"+uuid, time.Now().UTC()); err != nil {
t.Fatalf("seed device %s: %v", uuid, err)
}
}
// EnsureDeviceDpUUID mints a per-device dp_uuid, is idempotent for the same
// device, and yields distinct credentials for distinct devices.
func TestSQLite_EnsureDeviceDpUUID(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
seedUser(t, db, 1)
seedDevice(t, db, 10, 1, "dev-a")
seedDevice(t, db, 11, 1, "dev-b")
dpA, idA, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
if err != nil {
t.Fatalf("ensure dev-a: %v", err)
}
if dpA == "" || idA != 10 {
t.Fatalf("dev-a: dp=%q id=%d, want non-empty/10", dpA, idA)
}
// Idempotent: a second call returns the same dp_uuid (no re-mint).
dpA2, idA2, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
if err != nil {
t.Fatalf("ensure dev-a again: %v", err)
}
if dpA2 != dpA || idA2 != 10 {
t.Errorf("not idempotent: dp=%q (want %q) id=%d", dpA2, dpA, idA2)
}
// Distinct device → distinct dp_uuid.
dpB, idB, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-b")
if err != nil {
t.Fatalf("ensure dev-b: %v", err)
}
if dpB == dpA {
t.Errorf("dev-b dp_uuid collides with dev-a: %q", dpB)
}
if idB != 11 {
t.Errorf("dev-b id=%d, want 11", idB)
}
// Persisted to devices.dp_uuid.
var stored string
if err := db.QueryRow(`SELECT dp_uuid FROM devices WHERE id=10`).Scan(&stored); err != nil {
t.Fatalf("read stored dp_uuid: %v", err)
}
if stored != dpA {
t.Errorf("stored dp_uuid=%q, want %q", stored, dpA)
}
// Unknown device → error (not silently minted against nothing).
if _, _, err := ns.EnsureDeviceDpUUID(ctx, 1, "ghost"); err == nil {
t.Errorf("expected error for unknown device")
}
}
// UserDeviceByDpUUID prefers the per-device credential and falls back to the
// legacy account-level users.dp_uuid (deviceID=0).
func TestSQLite_UserDeviceByDpUUID(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
seedUser(t, db, 1) // seedUser sets users.dp_uuid='dp-u'
seedDevice(t, db, 10, 1, "dev-a")
dpA, _, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-a")
if err != nil {
t.Fatalf("ensure: %v", err)
}
// Per-device resolution.
uid, did, ok, err := ns.UserDeviceByDpUUID(ctx, dpA)
if err != nil || !ok {
t.Fatalf("resolve device dp: ok=%v err=%v", ok, err)
}
if uid != 1 || did != 10 {
t.Errorf("device dp → user=%d device=%d, want 1/10", uid, did)
}
// Legacy account fallback (no device dimension).
uid, did, ok, err = ns.UserDeviceByDpUUID(ctx, "dp-u")
if err != nil || !ok {
t.Fatalf("resolve account dp: ok=%v err=%v", ok, err)
}
if uid != 1 || did != 0 {
t.Errorf("account dp → user=%d device=%d, want 1/0", uid, did)
}
// Unknown dp_uuid.
if _, _, ok, _ := ns.UserDeviceByDpUUID(ctx, "nope"); ok {
t.Errorf("unknown dp_uuid resolved unexpectedly")
}
}
// AccumulateDeviceUsage sums within (device, date) and keeps devices separate.
func TestSQLite_AccumulateDeviceUsage(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
seedUser(t, db, 1)
seedDevice(t, db, 10, 1, "dev-a")
seedDevice(t, db, 11, 1, "dev-b")
day := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, day, 100, 200, 1); err != nil {
t.Fatalf("accum a1: %v", err)
}
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, day, 50, 25, 2); err != nil {
t.Fatalf("accum a2: %v", err)
}
if err := ns.AccumulateDeviceUsage(ctx, 1, 11, day, 7, 8, 9); err != nil {
t.Fatalf("accum b1: %v", err)
}
var up, down, mins int64
if err := db.QueryRow(
`SELECT bytes_up, bytes_down, minutes_used FROM usage_device_daily WHERE device_id=10`,
).Scan(&up, &down, &mins); err != nil {
t.Fatalf("read dev-a: %v", err)
}
if up != 150 || down != 225 || mins != 3 {
t.Errorf("dev-a usage up=%d down=%d mins=%d, want 150/225/3", up, down, mins)
}
if err := db.QueryRow(
`SELECT bytes_up, bytes_down, minutes_used FROM usage_device_daily WHERE device_id=11`,
).Scan(&up, &down, &mins); err != nil {
t.Fatalf("read dev-b: %v", err)
}
if up != 7 || down != 8 || mins != 9 {
t.Errorf("dev-b usage up=%d down=%d mins=%d, want 7/8/9", up, down, mins)
}
}
// AccountDayBytes returns the account's total (up+down) bytes for the day,
// the basis for the GB 综合配额 connect gate.
func TestSQLite_AccountDayBytes(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
seedUser(t, db, 1)
day := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
other := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC)
// No usage yet → 0.
if b, err := ns.AccountDayBytes(ctx, 1, day); err != nil || b != 0 {
t.Fatalf("empty: bytes=%d err=%v, want 0/nil", b, err)
}
if err := ns.AccumulateUsage(ctx, 1, day, 300, 700, 5); err != nil {
t.Fatalf("accum: %v", err)
}
if err := ns.AccumulateUsage(ctx, 1, day, 100, 100, 1); err != nil {
t.Fatalf("accum2: %v", err)
}
if b, err := ns.AccountDayBytes(ctx, 1, day); err != nil || b != 1200 {
t.Errorf("day bytes=%d err=%v, want 1200", b, err)
}
// A different date is isolated.
if b, err := ns.AccountDayBytes(ctx, 1, other); err != nil || b != 0 {
t.Errorf("other day bytes=%d, want 0", b)
}
}
// DeviceUsageRange aggregates usage_device_daily per device over a window,
// joins devices for display metadata, sums across days within the window,
// excludes out-of-window dates and zero-usage devices, and orders busiest-first.
func TestSQLite_DeviceUsageRange(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
us := usage.NewStore(db)
seedUser(t, db, 1)
seedDevice(t, db, 10, 1, "dev-a")
seedDevice(t, db, 11, 1, "dev-b")
seedDevice(t, db, 12, 1, "dev-quiet") // no usage → omitted
from := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
to := time.Date(2026, 6, 24, 0, 0, 0, 0, time.UTC)
inWindow1 := time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)
inWindow2 := time.Date(2026, 6, 23, 0, 0, 0, 0, time.UTC)
outWindow := time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)
// dev-a: two in-window days summed + one out-of-window day excluded.
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, inWindow1, 1000, 2000, 5); err != nil {
t.Fatalf("accum a1: %v", err)
}
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, inWindow2, 500, 500, 3); err != nil {
t.Fatalf("accum a2: %v", err)
}
if err := ns.AccumulateDeviceUsage(ctx, 1, 10, outWindow, 9999, 9999, 99); err != nil {
t.Fatalf("accum a-out: %v", err)
}
// dev-b: smaller total (so it sorts after dev-a).
if err := ns.AccumulateDeviceUsage(ctx, 1, 11, inWindow1, 100, 100, 1); err != nil {
t.Fatalf("accum b1: %v", err)
}
rows, err := us.DeviceUsageRange(ctx, 1, from, to)
if err != nil {
t.Fatalf("DeviceUsageRange: %v", err)
}
if len(rows) != 2 {
t.Fatalf("got %d device rows, want 2 (dev-quiet omitted)", len(rows))
}
// Busiest first: dev-a (4000 bytes) before dev-b (200 bytes).
if rows[0].UUID != "dev-a" {
t.Errorf("rows[0].UUID=%q, want dev-a (busiest first)", rows[0].UUID)
}
a := rows[0]
if a.BytesUp != 1500 || a.BytesDown != 2500 || a.MinutesUsed != 8 {
t.Errorf("dev-a sums up=%d down=%d min=%d, want 1500/2500/8 (out-of-window excluded)",
a.BytesUp, a.BytesDown, a.MinutesUsed)
}
if a.Name != "devdev-a" || a.Platform != "ios" {
t.Errorf("dev-a join meta name=%q platform=%q, want devdev-a/ios", a.Name, a.Platform)
}
if rows[1].UUID != "dev-b" || rows[1].BytesUp != 100 || rows[1].BytesDown != 100 {
t.Errorf("dev-b row wrong: %+v", rows[1])
}
// A different user sees none of this account's devices.
if _, err := db.Exec(
`INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at)
VALUES (2, 'u2-uuid', 'u2@example.com', 'x', 'dp-u2', 'active', ?)`,
time.Now().UTC()); err != nil {
t.Fatalf("seed user2: %v", err)
}
other, err := us.DeviceUsageRange(ctx, 2, from, to)
if err != nil {
t.Fatalf("DeviceUsageRange user2: %v", err)
}
if len(other) != 0 {
t.Errorf("user2 leaked %d rows, want 0", len(other))
}
}
// EntitlementForUser loads daily_mb: free fallback = 500; an active pro
// subscription = 102400 (from the 000015 seed).
func TestSQLite_EntitlementForUser_DailyMB(t *testing.T) {
ctx := context.Background()
db := openSQLite(t)
ns := nodes.NewSQLNodeStore(db)
seedUser(t, db, 1)
// No subscription → free defaults.
ent, err := ns.EntitlementForUser(ctx, 1)
if err != nil || ent == nil {
t.Fatalf("entitlement: %v", err)
}
if !ent.AdGate || !ent.DailyMB.Valid || ent.DailyMB.Int64 != 500 {
t.Errorf("free: adGate=%v dailyMB=%v/%d, want true/valid/500",
ent.AdGate, ent.DailyMB.Valid, ent.DailyMB.Int64)
}
// Active pro subscription (plan_id=2 from seed) → daily_mb 102400, no ad gate.
if _, err := db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
VALUES (1, 2, ?, 'code', ?)`,
time.Now().UTC().Add(24*time.Hour), time.Now().UTC()); err != nil {
t.Fatalf("seed subscription: %v", err)
}
ent, err = ns.EntitlementForUser(ctx, 1)
if err != nil || ent == nil {
t.Fatalf("entitlement pro: %v", err)
}
if ent.AdGate || !ent.DailyMB.Valid || ent.DailyMB.Int64 != 102400 {
t.Errorf("pro: adGate=%v dailyMB=%v/%d, want false/valid/102400",
ent.AdGate, ent.DailyMB.Valid, ent.DailyMB.Int64)
}
}