Initial sanitized code sync
This commit is contained in:
78
internal/db/crypto.go
Normal file
78
internal/db/crypto.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var encryptionKey []byte
|
||||
|
||||
func SetEncryptionKey(hexKey string) error {
|
||||
key, err := hex.DecodeString(hexKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode encryption key: %w", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return fmt.Errorf("encryption key must be 32 bytes (64 hex chars), got %d bytes", len(key))
|
||||
}
|
||||
encryptionKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
if len(encryptionKey) == 0 {
|
||||
return plaintext, nil
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return hex.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func Decrypt(ciphertextHex string) (string, error) {
|
||||
if len(encryptionKey) == 0 {
|
||||
return ciphertextHex, nil
|
||||
}
|
||||
ciphertext, err := hex.DecodeString(ciphertextHex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func HashSHA256(data string) string {
|
||||
h := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
101
internal/db/crypto_test.go
Normal file
101
internal/db/crypto_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashSHA256(t *testing.T) {
|
||||
h1 := HashSHA256("hello")
|
||||
h2 := HashSHA256("hello")
|
||||
if h1 != h2 {
|
||||
t.Fatal("same input should produce same hash")
|
||||
}
|
||||
if h1 == HashSHA256("world") {
|
||||
t.Fatal("different input should produce different hash")
|
||||
}
|
||||
if len(h1) != 64 {
|
||||
t.Fatalf("SHA-256 hex should be 64 chars, got %d", len(h1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptWithoutKey(t *testing.T) {
|
||||
old := encryptionKey
|
||||
encryptionKey = nil
|
||||
defer func() { encryptionKey = old }()
|
||||
|
||||
enc, err := Encrypt("plain")
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt without key: %v", err)
|
||||
}
|
||||
if enc != "plain" {
|
||||
t.Fatal("without key, Encrypt should return plaintext")
|
||||
}
|
||||
dec, err := Decrypt("plain")
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt without key: %v", err)
|
||||
}
|
||||
if dec != "plain" {
|
||||
t.Fatal("without key, Decrypt should return input as-is")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptWithKey(t *testing.T) {
|
||||
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
|
||||
t.Fatalf("set key: %v", err)
|
||||
}
|
||||
defer func() { encryptionKey = nil }()
|
||||
|
||||
original := "4242424242424242"
|
||||
enc, err := Encrypt(original)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if enc == original {
|
||||
t.Fatal("encrypted text should differ from plaintext")
|
||||
}
|
||||
|
||||
dec, err := Decrypt(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if dec != original {
|
||||
t.Fatalf("decrypted = %q, want %q", dec, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptNondeterministic(t *testing.T) {
|
||||
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
|
||||
t.Fatalf("set key: %v", err)
|
||||
}
|
||||
defer func() { encryptionKey = nil }()
|
||||
|
||||
e1, _ := Encrypt("same")
|
||||
e2, _ := Encrypt("same")
|
||||
if e1 == e2 {
|
||||
t.Fatal("GCM encryption should produce different ciphertext each time (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEncryptionKeyInvalidLength(t *testing.T) {
|
||||
if err := SetEncryptionKey("abcd"); err == nil {
|
||||
t.Fatal("expected error for short key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEncryptionKeyInvalidHex(t *testing.T) {
|
||||
if err := SetEncryptionKey("zzzz"); err == nil {
|
||||
t.Fatal("expected error for non-hex key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidCiphertext(t *testing.T) {
|
||||
if err := SetEncryptionKey("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); err != nil {
|
||||
t.Fatalf("set key: %v", err)
|
||||
}
|
||||
defer func() { encryptionKey = nil }()
|
||||
|
||||
_, err := Decrypt("00")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for short ciphertext")
|
||||
}
|
||||
}
|
||||
54
internal/db/db.go
Normal file
54
internal/db/db.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func Init(dbPath string) error {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get sql.DB: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&SystemConfig{},
|
||||
&EmailRecord{},
|
||||
&Task{},
|
||||
&TaskLog{},
|
||||
&CardCode{},
|
||||
&Card{},
|
||||
&Account{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
123
internal/db/models.go
Normal file
123
internal/db/models.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"uniqueIndex;size:100" json:"key"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
Group string `gorm:"size:50;index" json:"group"`
|
||||
Label string `gorm:"size:100" json:"label"`
|
||||
Type string `gorm:"size:20" json:"type"` // string, int, bool, password, textarea
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EmailRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"size:200;uniqueIndex" json:"email"`
|
||||
Status string `gorm:"size:20;index;default:in_use" json:"status"` // in_use, used, used_member, used_failed
|
||||
UsedByAccountID *uint `gorm:"index" json:"used_by_account_id"`
|
||||
UsedForRole string `gorm:"size:20" json:"used_for_role"` // owner, member
|
||||
TaskID string `gorm:"size:36;index" json:"task_id"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Type string `gorm:"size:20;index" json:"type"` // plus, team, both
|
||||
TotalCount int `json:"total_count"`
|
||||
DoneCount int `json:"done_count"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailCount int `json:"fail_count"`
|
||||
Status string `gorm:"size:20;index" json:"status"` // pending, running, stopping, stopped, interrupted, completed
|
||||
Config string `gorm:"type:text" json:"-"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
StoppedAt *time.Time `json:"stopped_at"`
|
||||
}
|
||||
|
||||
type TaskLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID string `gorm:"size:36;index" json:"task_id"`
|
||||
Index int `json:"index"`
|
||||
Email string `gorm:"size:200" json:"email"`
|
||||
Status string `gorm:"size:20" json:"status"` // step, success, failed, skipped
|
||||
Plan string `gorm:"size:20" json:"plan"` // plus, team, free
|
||||
Message string `gorm:"size:500" json:"message"`
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
Duration int `json:"duration"` // seconds
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CardCode struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Code string `gorm:"size:100;uniqueIndex" json:"code"`
|
||||
Status string `gorm:"size:20;index;default:unused" json:"status"` // unused, redeeming, redeemed, failed
|
||||
CardID *uint `gorm:"index" json:"card_id"`
|
||||
Error string `gorm:"size:200" json:"error"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
RedeemedAt *time.Time `json:"redeemed_at"`
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
NumberHash string `gorm:"size:64;uniqueIndex" json:"-"`
|
||||
NumberEnc string `gorm:"type:text" json:"-"`
|
||||
CVCEnc string `gorm:"type:text" json:"-"`
|
||||
ExpMonth string `gorm:"size:2" json:"exp_month"`
|
||||
ExpYear string `gorm:"size:4" json:"exp_year"`
|
||||
Name string `gorm:"size:100" json:"name"`
|
||||
|
||||
Country string `gorm:"size:10" json:"country"`
|
||||
Address string `gorm:"size:200" json:"address"`
|
||||
City string `gorm:"size:100" json:"city"`
|
||||
State string `gorm:"size:100" json:"state"`
|
||||
PostalCode string `gorm:"size:20" json:"postal_code"`
|
||||
|
||||
Source string `gorm:"size:20;index" json:"source"` // api, manual
|
||||
CardCodeID *uint `gorm:"index" json:"card_code_id"`
|
||||
|
||||
Status string `gorm:"size:20;index;default:available" json:"status"` // available, active, exhausted, rejected, disabled, expired
|
||||
BindCount int `gorm:"default:0" json:"bind_count"`
|
||||
MaxBinds int `gorm:"default:1" json:"max_binds"`
|
||||
ActivatedAt *time.Time `json:"activated_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
LastError string `gorm:"size:200" json:"last_error"`
|
||||
BoundAccounts string `gorm:"type:text" json:"bound_accounts"` // JSON array
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Transient fields for API response (not stored in DB)
|
||||
NumberLast4 string `gorm:"-" json:"number_last4,omitempty"`
|
||||
CVCPlain string `gorm:"-" json:"cvc_plain,omitempty"`
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID string `gorm:"size:36;index" json:"task_id"`
|
||||
Email string `gorm:"size:200;uniqueIndex" json:"email"`
|
||||
Password string `gorm:"size:100" json:"-"`
|
||||
Plan string `gorm:"size:20;index" json:"plan"` // plus, team_owner, team_member
|
||||
ParentID *uint `gorm:"index" json:"parent_id"`
|
||||
Parent *Account `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
SubAccounts []Account `gorm:"foreignKey:ParentID" json:"sub_accounts,omitempty"`
|
||||
|
||||
AccessToken string `gorm:"type:text" json:"-"`
|
||||
RefreshToken string `gorm:"type:text" json:"-"`
|
||||
IDToken string `gorm:"type:text" json:"-"`
|
||||
AccountID string `gorm:"size:100" json:"account_id"`
|
||||
DeviceID string `gorm:"size:100" json:"-"`
|
||||
UserID string `gorm:"size:100" json:"user_id"`
|
||||
|
||||
TeamWorkspaceID string `gorm:"size:100" json:"team_workspace_id"`
|
||||
WorkspaceToken string `gorm:"type:text" json:"-"`
|
||||
|
||||
Status string `gorm:"size:20;index;default:active" json:"status"` // active, free, plus, team, banned, unknown
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at"`
|
||||
Note string `gorm:"type:text" json:"note"`
|
||||
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
144
internal/db/query.go
Normal file
144
internal/db/query.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PaginationParams struct {
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
func (p *PaginationParams) Normalize() {
|
||||
if p.Page < 1 {
|
||||
p.Page = 1
|
||||
}
|
||||
if p.Size < 1 || p.Size > 100 {
|
||||
p.Size = 20
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PaginationParams) Offset() int {
|
||||
return (p.Page - 1) * p.Size
|
||||
}
|
||||
|
||||
type PaginatedResult struct {
|
||||
Items interface{} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
func Paginate(db *gorm.DB, p PaginationParams, dest interface{}) (*PaginatedResult, error) {
|
||||
p.Normalize()
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Offset(p.Offset()).Limit(p.Size).Find(dest).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PaginatedResult{
|
||||
Items: dest,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
Size: p.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Dashboard stats
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalAccounts int64 `json:"total_accounts"`
|
||||
PlusCount int64 `json:"plus_count"`
|
||||
TeamCount int64 `json:"team_count"`
|
||||
TodayRegistrations int64 `json:"today_registrations"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
ActiveTask *Task `json:"active_task"`
|
||||
RecentTasks []Task `json:"recent_tasks"`
|
||||
}
|
||||
|
||||
func GetDashboardStats(db *gorm.DB) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{}
|
||||
|
||||
db.Model(&Account{}).Count(&stats.TotalAccounts)
|
||||
db.Model(&Account{}).Where("plan = ?", "plus").Count(&stats.PlusCount)
|
||||
db.Model(&Account{}).Where("plan IN ?", []string{"team_owner", "team_member"}).Count(&stats.TeamCount)
|
||||
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
db.Model(&Account{}).Where("created_at >= ?", today).Count(&stats.TodayRegistrations)
|
||||
|
||||
var totalLogs, successLogs int64
|
||||
db.Model(&TaskLog{}).Count(&totalLogs)
|
||||
db.Model(&TaskLog{}).Where("status = ?", "success").Count(&successLogs)
|
||||
if totalLogs > 0 {
|
||||
stats.SuccessRate = float64(successLogs) / float64(totalLogs) * 100
|
||||
}
|
||||
|
||||
var activeTask Task
|
||||
if err := db.Where("status IN ?", []string{"running", "stopping"}).First(&activeTask).Error; err == nil {
|
||||
stats.ActiveTask = &activeTask
|
||||
}
|
||||
|
||||
db.Order("created_at DESC").Limit(5).Find(&stats.RecentTasks)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Card stats
|
||||
|
||||
type CardStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Available int64 `json:"available"`
|
||||
Active int64 `json:"active"`
|
||||
Exhausted int64 `json:"exhausted"`
|
||||
Rejected int64 `json:"rejected"`
|
||||
Expired int64 `json:"expired"`
|
||||
Disabled int64 `json:"disabled"`
|
||||
}
|
||||
|
||||
func GetCardStats(db *gorm.DB) *CardStats {
|
||||
s := &CardStats{}
|
||||
db.Model(&Card{}).Count(&s.Total)
|
||||
db.Model(&Card{}).Where("status = ?", "available").Count(&s.Available)
|
||||
db.Model(&Card{}).Where("status = ?", "active").Count(&s.Active)
|
||||
db.Model(&Card{}).Where("status = ?", "exhausted").Count(&s.Exhausted)
|
||||
db.Model(&Card{}).Where("status = ?", "rejected").Count(&s.Rejected)
|
||||
db.Model(&Card{}).Where("status = ?", "expired").Count(&s.Expired)
|
||||
db.Model(&Card{}).Where("status = ?", "disabled").Count(&s.Disabled)
|
||||
return s
|
||||
}
|
||||
|
||||
type CardCodeStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Unused int64 `json:"unused"`
|
||||
Redeemed int64 `json:"redeemed"`
|
||||
Failed int64 `json:"failed"`
|
||||
}
|
||||
|
||||
func GetCardCodeStats(db *gorm.DB) *CardCodeStats {
|
||||
s := &CardCodeStats{}
|
||||
db.Model(&CardCode{}).Count(&s.Total)
|
||||
db.Model(&CardCode{}).Where("status = ?", "unused").Count(&s.Unused)
|
||||
db.Model(&CardCode{}).Where("status = ?", "redeemed").Count(&s.Redeemed)
|
||||
db.Model(&CardCode{}).Where("status = ?", "failed").Count(&s.Failed)
|
||||
return s
|
||||
}
|
||||
|
||||
type EmailRecordStats struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
UsedMember int64 `json:"used_member"`
|
||||
UsedFailed int64 `json:"used_failed"`
|
||||
}
|
||||
|
||||
func GetEmailRecordStats(db *gorm.DB) *EmailRecordStats {
|
||||
s := &EmailRecordStats{}
|
||||
db.Model(&EmailRecord{}).Count(&s.Total)
|
||||
db.Model(&EmailRecord{}).Where("status = ?", "used").Count(&s.Used)
|
||||
db.Model(&EmailRecord{}).Where("status = ?", "used_member").Count(&s.UsedMember)
|
||||
db.Model(&EmailRecord{}).Where("status = ?", "used_failed").Count(&s.UsedFailed)
|
||||
return s
|
||||
}
|
||||
128
internal/db/query_test.go
Normal file
128
internal/db/query_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPaginationNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in PaginationParams
|
||||
wantPage int
|
||||
wantSize int
|
||||
}{
|
||||
{PaginationParams{0, 0}, 1, 20},
|
||||
{PaginationParams{-1, -5}, 1, 20},
|
||||
{PaginationParams{1, 200}, 1, 20},
|
||||
{PaginationParams{3, 10}, 3, 10},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt.in.Normalize()
|
||||
if tt.in.Page != tt.wantPage || tt.in.Size != tt.wantSize {
|
||||
t.Errorf("Normalize(%+v) = page=%d size=%d, want page=%d size=%d",
|
||||
tt.in, tt.in.Page, tt.in.Size, tt.wantPage, tt.wantSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationOffset(t *testing.T) {
|
||||
p := PaginationParams{Page: 3, Size: 10}
|
||||
if p.Offset() != 20 {
|
||||
t.Fatalf("Offset() = %d, want 20", p.Offset())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginate(t *testing.T) {
|
||||
d := setupTestDB(t)
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
d.Create(&EmailRecord{Email: "test" + string(rune('A'+i)) + "@x.com", Status: "used"})
|
||||
}
|
||||
|
||||
var records []EmailRecord
|
||||
result, err := Paginate(d.Model(&EmailRecord{}), PaginationParams{Page: 2, Size: 10}, &records)
|
||||
if err != nil {
|
||||
t.Fatalf("Paginate: %v", err)
|
||||
}
|
||||
if result.Total != 25 {
|
||||
t.Fatalf("Total = %d, want 25", result.Total)
|
||||
}
|
||||
if result.Page != 2 || result.Size != 10 {
|
||||
t.Fatalf("Page=%d Size=%d, want 2/10", result.Page, result.Size)
|
||||
}
|
||||
if len(records) != 10 {
|
||||
t.Fatalf("got %d records, want 10", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCardStats(t *testing.T) {
|
||||
d := setupTestDB(t)
|
||||
d.Create(&Card{NumberHash: "h1", Status: "available", MaxBinds: 1})
|
||||
d.Create(&Card{NumberHash: "h2", Status: "active", MaxBinds: 1})
|
||||
d.Create(&Card{NumberHash: "h3", Status: "exhausted", MaxBinds: 1})
|
||||
d.Create(&Card{NumberHash: "h4", Status: "rejected", MaxBinds: 1})
|
||||
d.Create(&Card{NumberHash: "h5", Status: "disabled", MaxBinds: 1})
|
||||
|
||||
s := GetCardStats(d)
|
||||
if s.Total != 5 {
|
||||
t.Fatalf("Total = %d, want 5", s.Total)
|
||||
}
|
||||
if s.Available != 1 || s.Active != 1 || s.Exhausted != 1 || s.Rejected != 1 || s.Disabled != 1 {
|
||||
t.Fatalf("stats mismatch: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCardCodeStats(t *testing.T) {
|
||||
d := setupTestDB(t)
|
||||
d.Create(&CardCode{Code: "c1", Status: "unused"})
|
||||
d.Create(&CardCode{Code: "c2", Status: "unused"})
|
||||
d.Create(&CardCode{Code: "c3", Status: "redeemed"})
|
||||
d.Create(&CardCode{Code: "c4", Status: "failed"})
|
||||
|
||||
s := GetCardCodeStats(d)
|
||||
if s.Total != 4 || s.Unused != 2 || s.Redeemed != 1 || s.Failed != 1 {
|
||||
t.Fatalf("stats mismatch: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEmailRecordStats(t *testing.T) {
|
||||
d := setupTestDB(t)
|
||||
d.Create(&EmailRecord{Email: "a@x.com", Status: "used"})
|
||||
d.Create(&EmailRecord{Email: "b@x.com", Status: "used_member"})
|
||||
d.Create(&EmailRecord{Email: "c@x.com", Status: "used_failed"})
|
||||
d.Create(&EmailRecord{Email: "d@x.com", Status: "in_use"})
|
||||
|
||||
s := GetEmailRecordStats(d)
|
||||
if s.Total != 4 || s.Used != 1 || s.UsedMember != 1 || s.UsedFailed != 1 {
|
||||
t.Fatalf("stats mismatch: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDashboardStats(t *testing.T) {
|
||||
d := setupTestDB(t)
|
||||
d.Create(&Account{Email: "p1@x.com", Plan: "plus", Status: "active"})
|
||||
d.Create(&Account{Email: "p2@x.com", Plan: "plus", Status: "active"})
|
||||
d.Create(&Account{Email: "t1@x.com", Plan: "team_owner", Status: "active"})
|
||||
d.Create(&Account{Email: "m1@x.com", Plan: "team_member", Status: "active"})
|
||||
|
||||
d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p1@x.com"})
|
||||
d.Create(&TaskLog{TaskID: "t1", Status: "success", Email: "p2@x.com"})
|
||||
d.Create(&TaskLog{TaskID: "t1", Status: "failed", Email: "f@x.com"})
|
||||
|
||||
stats, err := GetDashboardStats(d)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDashboardStats: %v", err)
|
||||
}
|
||||
if stats.TotalAccounts != 4 {
|
||||
t.Fatalf("TotalAccounts = %d, want 4", stats.TotalAccounts)
|
||||
}
|
||||
if stats.PlusCount != 2 {
|
||||
t.Fatalf("PlusCount = %d, want 2", stats.PlusCount)
|
||||
}
|
||||
if stats.TeamCount != 2 {
|
||||
t.Fatalf("TeamCount = %d, want 2", stats.TeamCount)
|
||||
}
|
||||
// success rate: 2/3 * 100 = 66.66...
|
||||
if stats.SuccessRate < 66 || stats.SuccessRate > 67 {
|
||||
t.Fatalf("SuccessRate = %.2f, want ~66.67", stats.SuccessRate)
|
||||
}
|
||||
}
|
||||
28
internal/db/testhelper_test.go
Normal file
28
internal/db/testhelper_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database for testing.
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
d, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
if err := d.AutoMigrate(
|
||||
&SystemConfig{}, &EmailRecord{}, &Task{}, &TaskLog{},
|
||||
&CardCode{}, &Card{}, &Account{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
DB = d
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user