package devices import ( "context" "database/sql" "fmt" "testing" "time" "github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/store" ) // -------------------------------------------------------------------------- // SQLite fixture for migration 000025 (users.max_devices_override). No // container needed — modernc.org/sqlite is pure Go — so these run in normal // CI alongside run_sqlite_test.sh's data-layer tests. // -------------------------------------------------------------------------- func openOverrideTestDB(t *testing.T) *sql.DB { t.Helper() cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"} db, err := store.Open(cfg) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) if err := store.MigrateUp(db, "sqlite"); err != nil { t.Fatalf("MigrateUp: %v", err) } return db } // insertOverrideTestUser inserts an active user with an optional // max_devices_override (NULL when override.Valid is false). func insertOverrideTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 { t.Helper() var ( res sql.Result err error ) if override.Valid { res, err = db.Exec( `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override) VALUES (?, ?, 'x', ?, 'active', ?)`, "uuid-"+email, email, "dp-"+email, override.Int64) } else { res, err = db.Exec( `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) VALUES (?, ?, 'x', ?, 'active')`, "uuid-"+email, email, "dp-"+email) } if err != nil { t.Fatalf("insert user %s: %v", email, err) } id, err := res.LastInsertId() if err != nil { t.Fatalf("last insert id: %v", err) } return id } // giveOverrideTestSubscription inserts an active subscription for userID on // the given seeded plan code (free/pro/team, from migration 000007). func giveOverrideTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) { t.Helper() var planID int64 if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil { t.Fatalf("plan lookup %s: %v", planCode, err) } if _, err := db.Exec( `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`, userID, planID, expiresAt.UTC(), source); err != nil { t.Fatalf("give subscription: %v", err) } } // insertOverrideTestDevice inserts an active (recently-seen) device row so it // counts toward CountActiveDevices. func insertOverrideTestDevice(t *testing.T, db *sql.DB, userID int64, uuidSuffix string) { t.Helper() if _, err := db.Exec( `INSERT INTO devices (uuid, user_id, name, platform, last_seen) VALUES (?, ?, ?, ?, ?)`, "dev-"+uuidSuffix, userID, "Device "+uuidSuffix, "ios", time.Now().UTC()); err != nil { t.Fatalf("insert device: %v", err) } } // -------------------------------------------------------------------------- // ResolvePlan: override applied at the single funnel (both the free fallback // and the subscription path flow through it). // -------------------------------------------------------------------------- // TestResolvePlan_FreeUserNoOverride: a free user with no override set gets // the plan's default cap (free = 1, per migration 000007 seed). func TestResolvePlan_FreeUserNoOverride(t *testing.T) { db := openOverrideTestDB(t) svc := NewService(NewStore(db), nil) userID := insertOverrideTestUser(t, db, "free-none@example.com", sql.NullInt64{}) plan, apiErr := svc.ResolvePlan(context.Background(), userID) if apiErr != nil { t.Fatalf("ResolvePlan: %v", apiErr) } if plan.PlanCode != "free" { t.Errorf("PlanCode = %q, want free", plan.PlanCode) } if plan.MaxDevices != 1 { t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", plan.MaxDevices) } } // TestResolvePlan_FreeUserWithOverride: same free user, override=6 → the // free-fallback path (GetFreePlan) must be overridden too. func TestResolvePlan_FreeUserWithOverride(t *testing.T) { db := openOverrideTestDB(t) svc := NewService(NewStore(db), nil) userID := insertOverrideTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true}) plan, apiErr := svc.ResolvePlan(context.Background(), userID) if apiErr != nil { t.Fatalf("ResolvePlan: %v", apiErr) } if plan.PlanCode != "free" { t.Errorf("PlanCode = %q, want free (override must not change plan identity)", plan.PlanCode) } if plan.MaxDevices != 6 { t.Errorf("MaxDevices = %d, want 6 (override)", plan.MaxDevices) } } // TestResolvePlan_ProSubscriptionOverrideWins: a pro-subscription user // (plan default max_devices=3) with override=2 — the subscription path // (resolveEffectivePlan's "best" branch) must also be overridden, even // though the override is *smaller* than the plan's own cap. func TestResolvePlan_ProSubscriptionOverrideWins(t *testing.T) { db := openOverrideTestDB(t) svc := NewService(NewStore(db), nil) userID := insertOverrideTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true}) giveOverrideTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour)) plan, apiErr := svc.ResolvePlan(context.Background(), userID) if apiErr != nil { t.Fatalf("ResolvePlan: %v", apiErr) } if plan.PlanCode != "pro" { t.Errorf("PlanCode = %q, want pro", plan.PlanCode) } if plan.MaxDevices != 2 { t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", plan.MaxDevices) } } // TestResolvePlan_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean // "no override" — the plan-derived cap must stand. func TestResolvePlan_NullOrZeroOverrideDoesNotApply(t *testing.T) { db := openOverrideTestDB(t) svc := NewService(NewStore(db), nil) nullUser := insertOverrideTestUser(t, db, "null-override@example.com", sql.NullInt64{}) zeroUser := insertOverrideTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true}) for _, tc := range []struct { name string userID int64 }{ {"NULL", nullUser}, {"zero", zeroUser}, } { t.Run(tc.name, func(t *testing.T) { plan, apiErr := svc.ResolvePlan(context.Background(), tc.userID) if apiErr != nil { t.Fatalf("ResolvePlan: %v", apiErr) } if plan.MaxDevices != 1 { t.Errorf("MaxDevices = %d, want 1 (no override should apply)", plan.MaxDevices) } }) } } // -------------------------------------------------------------------------- // CheckDeviceLimit: behavioral assertion using the existing rig (it calls // ResolvePlan internally) — override raises the effective cap so a device // count that would be rejected under the free default is no longer flagged. // -------------------------------------------------------------------------- func TestCheckDeviceLimit_OverrideAllowsMoreDevicesThanFreeCap(t *testing.T) { db := openOverrideTestDB(t) svc := NewService(NewStore(db), nil) userID := insertOverrideTestUser(t, db, "backstop@example.com", sql.NullInt64{Int64: 6, Valid: true}) // Free plan cap is 1; without the override 3 active devices would be // rejected (Over=true). The override raises the cap to 6. for i := 0; i < 3; i++ { insertOverrideTestDevice(t, db, userID, fmt.Sprintf("%d", i)) } status, apiErr := svc.CheckDeviceLimit(context.Background(), userID) if apiErr != nil { t.Fatalf("CheckDeviceLimit: %v", apiErr) } if status.MaxDevices != 6 { t.Errorf("MaxDevices = %d, want 6", status.MaxDevices) } if status.Over { t.Errorf("Over = true, want false (override=6 covers 3 active devices; free default of 1 would reject them)") } }