├── internal ├── infrastructure │ ├── logsWriter │ │ ├── logTypes │ │ │ └── types.go │ │ ├── logWriterInterface │ │ │ └── inerfaces.go │ │ ├── logFormatter │ │ │ └── formatter.go │ │ ├── logsWriter.go │ │ └── writer │ │ │ └── writer.go │ └── gitVersion │ │ ├── gitTypes │ │ └── types.go │ │ ├── gitInterfaces │ │ └── interfaces.go │ │ └── gv.go ├── service │ ├── giftService │ │ ├── giftTypes │ │ │ └── types.go │ │ ├── cache │ │ │ ├── idCache │ │ │ │ ├── cache.go │ │ │ │ └── cache_test.go │ │ │ └── giftCache │ │ │ │ ├── utils.go │ │ │ │ ├── cache.go │ │ │ │ └── cache_test.go │ │ ├── accountManager │ │ │ ├── interfaces.go │ │ │ ├── manager.go │ │ │ └── manager_test.go │ │ ├── giftBuyer │ │ │ ├── paymentProcessor │ │ │ │ ├── payment.go │ │ │ │ └── paymentProcessor_test.go │ │ │ ├── atomicCounter │ │ │ │ ├── counter.go │ │ │ │ ├── counter_new_test.go │ │ │ │ └── counter_test.go │ │ │ ├── purchaseProcessor │ │ │ │ ├── purchase.go │ │ │ │ └── purchase_test.go │ │ │ ├── invoiceCreator │ │ │ │ ├── invoiceCreator_test.go │ │ │ │ └── invoiceCreator.go │ │ │ └── giftBuyerMonitoring │ │ │ │ └── monitor.go │ │ ├── rateLimiter │ │ │ ├── rateLimiter.go │ │ │ └── rateLimiter_test.go │ │ ├── giftManager │ │ │ ├── manager_test.go │ │ │ └── manager.go │ │ ├── giftNotification │ │ │ ├── notification_test.go │ │ │ └── notification.go │ │ ├── giftValidator │ │ │ ├── validator_test.go │ │ │ └── validator.go │ │ └── giftMonitor │ │ │ └── monitor.go │ └── authService │ │ ├── apiChecker │ │ └── checker.go │ │ ├── sessions │ │ ├── helpers.go │ │ └── sessions.go │ │ ├── authInterfaces │ │ └── interfaces.go │ │ ├── authManager.go │ │ └── authManager_test.go ├── config │ ├── loadConfig.go │ ├── config_example.json │ └── appConfig.go └── usecase │ ├── factory.go │ ├── usecase.go │ └── factory_test.go ├── .gitignore ├── pkg ├── utils │ └── math.go ├── errors │ └── common_test.go └── logger │ └── logger_test.go ├── .github └── workflows │ └── ci.yml ├── go.mod ├── cmd └── main.go └── LICENSE /internal/infrastructure/logsWriter/logTypes/types.go: -------------------------------------------------------------------------------- 1 | package logTypes 2 | 3 | type LogEntry struct { 4 | Timestamp string `json:"timestamp"` 5 | Level string `json:"level"` 6 | Message string `json:"message"` 7 | } 8 | -------------------------------------------------------------------------------- /internal/infrastructure/gitVersion/gitTypes/types.go: -------------------------------------------------------------------------------- 1 | package gittypes 2 | 3 | type GitHubRelease struct { 4 | TagName string `json:"tag_name"` 5 | Name string `json:"name"` 6 | Body string `json:"body"` 7 | HTMLURL string `json:"html_url"` 8 | Draft bool `json:"draft"` 9 | } 10 | -------------------------------------------------------------------------------- /internal/infrastructure/logsWriter/logWriterInterface/inerfaces.go: -------------------------------------------------------------------------------- 1 | package logWriterInterface 2 | 3 | import "gift-buyer/internal/infrastructure/logsWriter/logTypes" 4 | 5 | type LogFormatter interface { 6 | Format(entry *logTypes.LogEntry) ([]byte, error) 7 | } 8 | 9 | type LogWriter interface { 10 | WriteToFile(entry *logTypes.LogEntry) (err error) 11 | } 12 | -------------------------------------------------------------------------------- /internal/infrastructure/gitVersion/gitInterfaces/interfaces.go: -------------------------------------------------------------------------------- 1 | package gitInterfaces 2 | 3 | import gittypes "gift-buyer/internal/infrastructure/gitVersion/gitTypes" 4 | 5 | type GitVersionController interface { 6 | GetLatestVersion() (*gittypes.GitHubRelease, error) 7 | GetCurrentVersion() (string, error) 8 | CompareVersions(localVersion, remoteVersion string) (bool, error) 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | config_test.json 3 | *secrets* 4 | *cache.json 5 | *session.json 6 | 7 | dist/ 8 | *.exe 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | coverage.out 14 | coverage.html 15 | *.test 16 | 17 | *.tmp 18 | *.temp 19 | *.log 20 | *.jsonl 21 | 22 | .vscode/ 23 | .idea/ 24 | *.swp 25 | *.swo 26 | *~ 27 | 28 | .DS_Store 29 | Thumbs.db 30 | 31 | vendor/ 32 | 33 | .env 34 | .env.local -------------------------------------------------------------------------------- /internal/service/giftService/giftTypes/types.go: -------------------------------------------------------------------------------- 1 | package giftTypes 2 | 3 | import "github.com/gotd/td/tg" 4 | 5 | type GiftResult struct { 6 | GiftID int64 7 | Success bool 8 | Err error 9 | } 10 | 11 | type GiftSummary struct { 12 | GiftID int64 13 | Requested int64 14 | Success int64 15 | } 16 | 17 | type GiftRequire struct { 18 | Gift *tg.StarGift 19 | // Receiver []string 20 | ReceiverType []int 21 | CountForBuy int64 22 | Hide bool 23 | } 24 | -------------------------------------------------------------------------------- /internal/infrastructure/logsWriter/logFormatter/formatter.go: -------------------------------------------------------------------------------- 1 | package logFormatter 2 | 3 | import ( 4 | "encoding/json" 5 | "gift-buyer/internal/infrastructure/logsWriter/logTypes" 6 | "time" 7 | ) 8 | 9 | type logFormatterImpl struct { 10 | level string 11 | } 12 | 13 | func NewLogFormatter(level string) *logFormatterImpl { 14 | return &logFormatterImpl{ 15 | level: level, 16 | } 17 | } 18 | 19 | func (l *logFormatterImpl) Format(entry *logTypes.LogEntry) ([]byte, error) { 20 | entryCopy := *entry 21 | entryCopy.Timestamp = time.Now().Format(time.RFC3339) 22 | 23 | if entryCopy.Level == "" { 24 | entryCopy.Level = l.level 25 | } 26 | 27 | jsonBytes, err := json.Marshal(entryCopy) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | jsonBytes = append(jsonBytes, '\n') 33 | return jsonBytes, nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/utils/math.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | mathRand "math/rand" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | func cryptoSeed() int64 { 12 | var seed int64 13 | if err := binary.Read(rand.Reader, binary.BigEndian, &seed); err != nil { 14 | return time.Now().UnixNano() 15 | } 16 | return seed 17 | } 18 | 19 | var fastRand = mathRand.New(mathRand.NewSource(cryptoSeed())) 20 | 21 | // selectRandomElementFast - максимально быстрый выбор случайного элемента 22 | func SelectRandomElementFast[T any](slice []T) T { 23 | if len(slice) == 0 { 24 | var zero T 25 | return zero 26 | } 27 | return slice[fastRand.Intn(len(slice))] 28 | } 29 | 30 | func RandString5(lenght int) string { 31 | const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 32 | var sb strings.Builder 33 | sb.Grow(lenght) 34 | 35 | for i := 0; i < lenght; i++ { 36 | sb.WriteByte(letters[fastRand.Intn(len(letters))]) 37 | } 38 | return sb.String() 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.24' 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: 25 | ~/.cache/go-build 26 | ~/go/pkg/mod 27 | key: ${{ runner.os }}-go-${{ hashfiles('**/go.sum')}} 28 | 29 | - name: Install dependencies 30 | run: go mod download 31 | 32 | - name: Run tests 33 | run: go test ./... 34 | 35 | - name: Build 36 | run: go build -o ci_pipeline ./cmd/main.go 37 | -------------------------------------------------------------------------------- /internal/service/authService/apiChecker/checker.go: -------------------------------------------------------------------------------- 1 | package apiChecker 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "gift-buyer/pkg/logger" 7 | "time" 8 | 9 | "github.com/gotd/td/tg" 10 | ) 11 | 12 | type apiCheckerImpl struct { 13 | api *tg.Client 14 | ticker *time.Ticker 15 | } 16 | 17 | func NewApiChecker(api *tg.Client, ticker *time.Ticker) *apiCheckerImpl { 18 | return &apiCheckerImpl{ 19 | api: api, 20 | ticker: ticker, 21 | } 22 | } 23 | 24 | func (f *apiCheckerImpl) Run(ctx context.Context) error { 25 | return f.ping(ctx) 26 | } 27 | 28 | func (f *apiCheckerImpl) ping(ctx context.Context) error { 29 | if f.api == nil { 30 | return errors.New("API client is nil") 31 | } 32 | 33 | api, err := f.api.AccountGetAuthorizations(ctx) 34 | if err != nil { 35 | return err 36 | } 37 | if len(api.Authorizations) == 0 { 38 | return errors.New("no authorizations found") 39 | } 40 | return nil 41 | } 42 | 43 | func (f *apiCheckerImpl) Stop() { 44 | if f.ticker != nil { 45 | f.ticker.Stop() 46 | logger.GlobalLogger.Info("API checker ticker stopped") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/service/authService/sessions/helpers.go: -------------------------------------------------------------------------------- 1 | package sessions 2 | 3 | // func (f *sessionManagerImpl) CreateDeviceConfig() telegram.DeviceConfig { 4 | // config := telegram.DeviceConfig{} 5 | 6 | // config.SetDefaults() 7 | 8 | // if f.cfg.DeviceModel != "" { 9 | // config.DeviceModel = f.cfg.DeviceModel 10 | // } else { 11 | // config.DeviceModel = "MacBook Pro M1 Pro" 12 | // } 13 | 14 | // if f.cfg.SystemVersion != "" { 15 | // config.SystemVersion = f.cfg.SystemVersion 16 | // } else { 17 | // config.SystemVersion = "macOS 14.1" 18 | // } 19 | 20 | // if f.cfg.AppVersion != "" { 21 | // config.AppVersion = f.cfg.AppVersion 22 | // } else { 23 | // config.AppVersion = "11.9 (272031) APP_STORE" 24 | // } 25 | 26 | // if f.cfg.SystemLangCode != "" { 27 | // config.SystemLangCode = f.cfg.SystemLangCode 28 | // } else { 29 | // config.SystemLangCode = "en" 30 | // } 31 | 32 | // if f.cfg.LangCode != "" { 33 | // config.LangCode = f.cfg.LangCode 34 | // } else { 35 | // config.LangCode = "en" 36 | // } 37 | 38 | // if f.cfg.LangPack != "" { 39 | // config.LangPack = f.cfg.LangPack 40 | // } else { 41 | // config.LangPack = "macos" 42 | // } 43 | 44 | // return config 45 | // } 46 | -------------------------------------------------------------------------------- /internal/service/giftService/cache/idCache/cache.go: -------------------------------------------------------------------------------- 1 | package idCache 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | 7 | "github.com/gotd/td/tg" 8 | ) 9 | 10 | type idCacheImpl struct { 11 | users map[string]*tg.User 12 | channels map[string]*tg.Channel 13 | mu sync.RWMutex 14 | } 15 | 16 | func NewIDCache() *idCacheImpl { 17 | return &idCacheImpl{ 18 | users: make(map[string]*tg.User), 19 | channels: make(map[string]*tg.Channel), 20 | } 21 | } 22 | 23 | func (c *idCacheImpl) SetUser(key string, user *tg.User) { 24 | if user == nil { 25 | return 26 | } 27 | c.mu.Lock() 28 | defer c.mu.Unlock() 29 | c.users[key] = user 30 | } 31 | 32 | func (c *idCacheImpl) GetUser(key string) (*tg.User, error) { 33 | c.mu.RLock() 34 | defer c.mu.RUnlock() 35 | user, ok := c.users[key] 36 | if !ok { 37 | return nil, errors.New("user not found") 38 | } 39 | return user, nil 40 | } 41 | 42 | func (c *idCacheImpl) SetChannel(key string, channel *tg.Channel) { 43 | if channel == nil { 44 | return 45 | } 46 | c.mu.Lock() 47 | defer c.mu.Unlock() 48 | c.channels[key] = channel 49 | } 50 | 51 | func (c *idCacheImpl) GetChannel(key string) (*tg.Channel, error) { 52 | c.mu.RLock() 53 | defer c.mu.RUnlock() 54 | channel, ok := c.channels[key] 55 | if !ok { 56 | return nil, errors.New("channel not found") 57 | } 58 | return channel, nil 59 | } 60 | -------------------------------------------------------------------------------- /internal/infrastructure/logsWriter/logsWriter.go: -------------------------------------------------------------------------------- 1 | package logsWriter 2 | 3 | import ( 4 | "fmt" 5 | "gift-buyer/internal/infrastructure/logsWriter/logTypes" 6 | "gift-buyer/internal/infrastructure/logsWriter/logWriterInterface" 7 | "gift-buyer/pkg/logger" 8 | ) 9 | 10 | type logsWriterImpl struct { 11 | writer logWriterInterface.LogWriter 12 | logFlag bool 13 | } 14 | 15 | func NewLogger( 16 | writer logWriterInterface.LogWriter, 17 | logFlag bool, 18 | ) *logsWriterImpl { 19 | return &logsWriterImpl{ 20 | writer: writer, 21 | logFlag: logFlag, 22 | } 23 | } 24 | 25 | func (l *logsWriterImpl) LogInfo(message string) { 26 | l.writer.WriteToFile(&logTypes.LogEntry{ 27 | Message: message, 28 | }) 29 | l.logInfoToTerminal(message) 30 | } 31 | 32 | func (l *logsWriterImpl) LogError(message string) { 33 | l.writer.WriteToFile(&logTypes.LogEntry{ 34 | Message: message, 35 | }) 36 | l.logErrorToTerminal(message) 37 | } 38 | 39 | func (l *logsWriterImpl) LogErrorf(format string, args ...interface{}) { 40 | l.LogError(fmt.Sprintf(format, args...)) 41 | l.logErrorToTerminal(fmt.Sprintf(format, args...)) 42 | } 43 | 44 | func (l *logsWriterImpl) logInfoToTerminal(message string) { 45 | if l.logFlag { 46 | logger.GlobalLogger.Infof(message) 47 | } 48 | } 49 | 50 | func (l *logsWriterImpl) logErrorToTerminal(message string) { 51 | if l.logFlag { 52 | logger.GlobalLogger.Errorf(message) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/infrastructure/logsWriter/writer/writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "gift-buyer/internal/infrastructure/logsWriter/logTypes" 7 | "gift-buyer/internal/infrastructure/logsWriter/logWriterInterface" 8 | "gift-buyer/pkg/logger" 9 | "os" 10 | "sync" 11 | ) 12 | 13 | type writerImpl struct { 14 | File *os.File 15 | mu sync.Mutex 16 | level string 17 | formatter logWriterInterface.LogFormatter 18 | } 19 | 20 | func NewLogsWriter(level string, formatter logWriterInterface.LogFormatter) *writerImpl { 21 | file, err := os.OpenFile(fmt.Sprintf("%s_logs.jsonl", level), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 22 | if err != nil { 23 | logger.GlobalLogger.Fatalf("Failed to open log file: %v", err) 24 | } 25 | 26 | writer := &writerImpl{ 27 | File: file, 28 | level: level, 29 | formatter: formatter, 30 | } 31 | 32 | return writer 33 | } 34 | 35 | func (l *writerImpl) WriteToFile(entry *logTypes.LogEntry) (err error) { 36 | bytes, err := l.formatter.Format(entry) 37 | if err != nil { 38 | return errors.New("failed to marshal to json") 39 | } 40 | 41 | if err := l.write(bytes); err != nil { 42 | return errors.New("failed to write logs to file") 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (l *writerImpl) write(bytes []byte) error { 49 | l.mu.Lock() 50 | defer l.mu.Unlock() 51 | _, err := l.File.Write(bytes) 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /internal/service/giftService/accountManager/interfaces.go: -------------------------------------------------------------------------------- 1 | package accountManager 2 | 3 | import "github.com/gotd/td/tg" 4 | 5 | // UserCache defines the interface for caching user information. 6 | // It provides persistent storage for user data to avoid redundant API calls 7 | // and maintain state across application restarts. 8 | type UserCache interface { 9 | // SetUser stores a user in the cache with the specified key. 10 | // 11 | // Parameters: 12 | // - key: unique identifier for the user (username or ID) 13 | // - user: the user object to cache 14 | SetUser(key string, user *tg.User) 15 | 16 | // GetUser retrieves a cached user by their key. 17 | // 18 | // Parameters: 19 | // - key: unique identifier of the user to retrieve 20 | // 21 | // Returns: 22 | // - *tg.User: the cached user object, nil if not found 23 | // - error: retrieval error (currently always nil) 24 | GetUser(key string) (*tg.User, error) 25 | } 26 | 27 | type ChannelCache interface { 28 | // SetChannel stores a channel in the cache with the specified key. 29 | // 30 | // Parameters: 31 | // - key: unique identifier for the channel (username or ID) 32 | // - channel: the channel object to cache 33 | SetChannel(key string, channel *tg.Channel) 34 | 35 | // GetChannel retrieves a cached channel by its key. 36 | // 37 | // Parameters: 38 | // - key: unique identifier of the channel to retrieve 39 | // 40 | // Returns: 41 | // - *tg.Channel: the cached channel object, nil if not found 42 | // - error: retrieval error (currently always nil) 43 | GetChannel(key string) (*tg.Channel, error) 44 | } 45 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/paymentProcessor/payment.go: -------------------------------------------------------------------------------- 1 | package paymentProcessor 2 | 3 | import ( 4 | "context" 5 | "gift-buyer/internal/service/giftService/giftInterfaces" 6 | "gift-buyer/internal/service/giftService/giftTypes" 7 | "gift-buyer/pkg/errors" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/gotd/td/tg" 12 | ) 13 | 14 | type PaymentProcessorImpl struct { 15 | api *tg.Client 16 | invoiceCreator giftInterfaces.InvoiceCreator 17 | rateLimiter giftInterfaces.RateLimiter 18 | requestCounter int64 19 | } 20 | 21 | func NewPaymentProcessor(api *tg.Client, invoiceCreator giftInterfaces.InvoiceCreator, rateLimiter giftInterfaces.RateLimiter) *PaymentProcessorImpl { 22 | return &PaymentProcessorImpl{ 23 | api: api, 24 | invoiceCreator: invoiceCreator, 25 | rateLimiter: rateLimiter, 26 | } 27 | } 28 | 29 | func (pp *PaymentProcessorImpl) CreatePaymentForm(ctx context.Context, gift *giftTypes.GiftRequire) (tg.PaymentsPaymentFormClass, *tg.InputInvoiceStarGift, error) { 30 | jitter := time.Duration(atomic.AddInt64(&pp.requestCounter, 1)%100) * time.Millisecond 31 | time.Sleep(jitter) 32 | 33 | invoice, err := pp.invoiceCreator.CreateInvoice(gift) 34 | if err != nil { 35 | return nil, nil, errors.Wrap(err, "failed to create invoice") 36 | } 37 | 38 | if err := pp.rateLimiter.Acquire(ctx); err != nil { 39 | return nil, nil, errors.Wrap(err, "failed to wait for rate limit") 40 | } 41 | paymentFormRequest := &tg.PaymentsGetPaymentFormRequest{ 42 | Invoice: invoice, 43 | } 44 | paymentForm, err := pp.api.PaymentsGetPaymentForm(ctx, paymentFormRequest) 45 | if err != nil { 46 | return nil, nil, errors.Wrap(err, "failed to get payment form") 47 | } 48 | 49 | return paymentForm, invoice, nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/service/giftService/rateLimiter/rateLimiter.go: -------------------------------------------------------------------------------- 1 | package rateLimiter 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // rateLimiter implements a token bucket rate limiter for API calls 10 | type rateLimiterImpl struct { 11 | tokens chan struct{} 12 | ticker *time.Ticker 13 | maxTokens int 14 | mu sync.Mutex 15 | closed bool 16 | } 17 | 18 | // newRateLimiter creates a new rate limiter with specified rate (requests per second) 19 | func NewRateLimiter(rps int) *rateLimiterImpl { 20 | var ticker *time.Ticker 21 | if rps > 0 { 22 | ticker = time.NewTicker(time.Second / time.Duration(rps)) 23 | } else { 24 | ticker = time.NewTicker(time.Hour) 25 | } 26 | 27 | rl := &rateLimiterImpl{ 28 | tokens: make(chan struct{}, rps), 29 | ticker: ticker, 30 | maxTokens: rps, 31 | } 32 | 33 | // Заполняем канал начальными токенами 34 | for i := 0; i < rps; i++ { 35 | rl.tokens <- struct{}{} 36 | } 37 | 38 | if rps > 0 { 39 | go rl.refillTokens() 40 | } 41 | 42 | return rl 43 | } 44 | 45 | func (rl *rateLimiterImpl) Acquire(ctx context.Context) error { 46 | select { 47 | case <-rl.tokens: 48 | return nil 49 | case <-ctx.Done(): 50 | return ctx.Err() 51 | } 52 | } 53 | 54 | func (rl *rateLimiterImpl) refillTokens() { 55 | for range rl.ticker.C { 56 | rl.mu.Lock() 57 | if rl.closed { 58 | rl.mu.Unlock() 59 | return 60 | } 61 | 62 | select { 63 | case rl.tokens <- struct{}{}: 64 | default: 65 | // Bucket is full, skip 66 | } 67 | rl.mu.Unlock() 68 | } 69 | } 70 | 71 | func (rl *rateLimiterImpl) Close() { 72 | rl.mu.Lock() 73 | defer rl.mu.Unlock() 74 | 75 | if !rl.closed { 76 | rl.closed = true 77 | rl.ticker.Stop() 78 | // Не закрываем канал, так как он может использоваться в тестах 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/config/loadConfig.go: -------------------------------------------------------------------------------- 1 | // Package config provides application configuration management. 2 | // It handles loading, parsing and validating application configuration from JSON files. 3 | package config 4 | 5 | import ( 6 | "encoding/json" 7 | "gift-buyer/pkg/errors" 8 | "gift-buyer/pkg/logger" 9 | "os" 10 | ) 11 | 12 | // LoadConfig loads and parses the application configuration from the specified JSON file. 13 | // It reads the configuration file, unmarshals the JSON content, and returns the parsed 14 | // configuration structure. 15 | // 16 | // The configuration file should be in JSON format and contain all required settings 17 | // including Telegram credentials, gift criteria, and operational parameters. 18 | // 19 | // Parameters: 20 | // - path: filesystem path to the configuration JSON file 21 | // 22 | // Returns: 23 | // - *AppConfig: parsed configuration structure containing all application settings 24 | // - error: configuration loading or parsing error, wrapped with context information 25 | // 26 | // Example usage: 27 | // 28 | // cfg, err := LoadConfig("config/app.json") 29 | // if err != nil { 30 | // log.Fatalf("Failed to load config: %v", err) 31 | // } 32 | // 33 | // Possible errors: 34 | // - ErrConfigRead: when the configuration file cannot be read 35 | // - ErrConfigParse: when the JSON content cannot be parsed 36 | func LoadConfig(path string) (*AppConfig, error) { 37 | logger.GlobalLogger.Debugf("Loading config from: %s", path) 38 | 39 | data, err := os.ReadFile(path) 40 | if err != nil { 41 | logger.GlobalLogger.Errorf("Failed to read config file: %v", err) 42 | return nil, errors.Wrap(errors.ErrConfigRead, err.Error()) 43 | } 44 | 45 | appConfig := &AppConfig{} 46 | if err := json.Unmarshal(data, appConfig); err != nil { 47 | logger.GlobalLogger.Errorf("Failed to unmarshal config: %v", err) 48 | return nil, errors.Wrap(errors.ErrConfigParse, err.Error()) 49 | } 50 | return appConfig, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/atomicCounter/counter.go: -------------------------------------------------------------------------------- 1 | // Package giftBuyer provides gift purchasing functionality for the gift buying system. 2 | package atomicCounter 3 | 4 | import ( 5 | "sync/atomic" 6 | ) 7 | 8 | // atomicCounter provides thread-safe counting with configurable maximum limits. 9 | // It uses atomic operations to ensure concurrent safety when tracking purchase counts 10 | // and enforcing maximum purchase limits across multiple goroutines. 11 | type atomicCounter struct { 12 | // count stores the current count value using atomic operations 13 | count int64 14 | 15 | // max defines the maximum allowed count value 16 | max int64 17 | } 18 | 19 | // newAtomicCounter creates a new atomic counter with the specified maximum limit. 20 | // 21 | // Parameters: 22 | // - max: maximum count value allowed 23 | // 24 | // Returns: 25 | // - *atomicCounter: initialized counter instance 26 | func NewAtomicCounter(max int64) *atomicCounter { 27 | return &atomicCounter{ 28 | count: 0, 29 | max: max, 30 | } 31 | } 32 | 33 | // TryIncrement attempts to increment the counter if it hasn't reached the maximum. 34 | // This operation is atomic and thread-safe, making it suitable for concurrent use. 35 | // 36 | // Returns: 37 | // - bool: true if increment was successful, false if maximum limit reached 38 | func (ac *atomicCounter) TryIncrement() bool { 39 | for { 40 | current := atomic.LoadInt64(&ac.count) 41 | if current >= ac.max { 42 | return false 43 | } 44 | if atomic.CompareAndSwapInt64(&ac.count, current, current+1) { 45 | return true 46 | } 47 | } 48 | } 49 | 50 | // Decrement decreases the counter by one. 51 | // This operation is atomic and thread-safe. 52 | func (ac *atomicCounter) Decrement() { 53 | atomic.AddInt64(&ac.count, -1) 54 | } 55 | 56 | // Get returns the current count value. 57 | // This operation is atomic and thread-safe. 58 | // 59 | // Returns: 60 | // - int64: current count value 61 | func (ac *atomicCounter) Get() int64 { 62 | return atomic.LoadInt64(&ac.count) 63 | } 64 | 65 | // GetMax returns the maximum allowed count value. 66 | // 67 | // Returns: 68 | // - int64: maximum count limit 69 | func (ac *atomicCounter) GetMax() int64 { 70 | return ac.max 71 | } 72 | -------------------------------------------------------------------------------- /internal/service/giftService/giftManager/manager_test.go: -------------------------------------------------------------------------------- 1 | package giftManager 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gotd/td/tg" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewGiftManager(t *testing.T) { 11 | // Create a nil client for testing constructor 12 | var client *tg.Client 13 | manager := NewGiftManager(client) 14 | 15 | assert.NotNil(t, manager) 16 | 17 | // Verify it implements the interface 18 | assert.NotNil(t, manager) 19 | } 20 | 21 | func TestGiftManagerImpl_Structure(t *testing.T) { 22 | var client *tg.Client 23 | manager := NewGiftManager(client) 24 | 25 | assert.Equal(t, client, manager.api) 26 | } 27 | 28 | func TestGiftManagerImpl_InterfaceCompliance(t *testing.T) { 29 | var client *tg.Client 30 | manager := NewGiftManager(client) 31 | 32 | // Test that it implements all required interface methods 33 | assert.NotNil(t, manager.GetAvailableGifts) 34 | } 35 | 36 | func TestGiftManagerImpl_GetAvailableGifts_NilClient(t *testing.T) { 37 | var client *tg.Client 38 | manager := NewGiftManager(client) 39 | 40 | // This will panic or return error with nil client, which is expected 41 | // We're just testing the method exists and can be called 42 | assert.NotPanics(t, func() { 43 | // Don't actually call with nil client as it will panic 44 | // Just verify the method signature exists 45 | _ = manager.GetAvailableGifts 46 | }) 47 | } 48 | 49 | func TestGiftManagerImpl_MethodSignatures(t *testing.T) { 50 | // Test that the manager has the correct method signatures 51 | var client *tg.Client 52 | manager := NewGiftManager(client) 53 | 54 | // Verify GetAvailableGifts signature 55 | getAvailableGifts := manager.GetAvailableGifts 56 | assert.NotNil(t, getAvailableGifts) 57 | } 58 | 59 | func TestGiftManagerImpl_TypeAssertions(t *testing.T) { 60 | var client *tg.Client 61 | manager := NewGiftManager(client) 62 | 63 | // Test type assertions 64 | assert.NotNil(t, manager) 65 | 66 | // Test that api field is accessible 67 | assert.Equal(t, client, manager.api) 68 | } 69 | 70 | func TestGiftManagerImpl_ZeroValues(t *testing.T) { 71 | // Test with zero values 72 | manager := &giftManagerImpl{} 73 | assert.NotNil(t, manager) 74 | assert.Nil(t, manager.api) 75 | } 76 | 77 | func TestGiftManagerImpl_FieldAccess(t *testing.T) { 78 | var client *tg.Client 79 | impl := &giftManagerImpl{api: client} 80 | 81 | assert.Equal(t, client, impl.api) 82 | 83 | // Test field modification 84 | var newClient *tg.Client 85 | impl.api = newClient 86 | assert.Equal(t, newClient, impl.api) 87 | } 88 | -------------------------------------------------------------------------------- /internal/service/giftService/giftManager/manager.go: -------------------------------------------------------------------------------- 1 | // Package giftManager provides gift management functionality for the gift buying system. 2 | // It handles communication with the Telegram API to retrieve available star gifts 3 | // and manages the conversion of API responses to internal data structures. 4 | package giftManager 5 | 6 | import ( 7 | "context" 8 | "gift-buyer/pkg/errors" 9 | 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | // giftManagerImpl implements the Giftmanager interface for managing gift operations. 14 | // It provides methods to retrieve available gifts from the Telegram API and 15 | // handles the parsing of API responses into usable data structures. 16 | type giftManagerImpl struct { 17 | // api is the Telegram client used for API communication 18 | api *tg.Client 19 | } 20 | 21 | // NewGiftManager creates a new GiftManager instance with the specified Telegram API client. 22 | // The manager will use this client to communicate with Telegram's gift API endpoints. 23 | // 24 | // Parameters: 25 | // - api: configured Telegram API client for making requests 26 | // 27 | // Returns: 28 | // - giftInterfaces.Giftmanager: configured gift manager instance 29 | func NewGiftManager(api *tg.Client) *giftManagerImpl { 30 | return &giftManagerImpl{api: api} 31 | } 32 | 33 | // GetAvailableGifts retrieves all currently available star gifts from Telegram. 34 | // It makes an API call to fetch the gift catalog and parses the response 35 | // to extract individual StarGift objects. 36 | // 37 | // The method handles: 38 | // - API communication with Telegram's gift endpoints 39 | // - Response parsing and type validation 40 | // - Conversion of API response to internal gift structures 41 | // 42 | // Parameters: 43 | // - ctx: context for request cancellation and timeout control 44 | // 45 | // Returns: 46 | // - []*tg.StarGift: slice of available star gifts from Telegram 47 | // - error: API communication error, parsing error, or unexpected response type 48 | // 49 | // Possible errors: 50 | // - Network communication errors with Telegram API 51 | // - Unexpected response type from the API 52 | // - Context cancellation or timeout 53 | func (gm *giftManagerImpl) GetAvailableGifts(ctx context.Context) ([]*tg.StarGift, error) { 54 | gifts, err := gm.api.PaymentsGetStarGifts(ctx, 0) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | starGifts, ok := gifts.(*tg.PaymentsStarGifts) 60 | if !ok { 61 | return nil, errors.Wrap(errors.New("unexpected response type"), "unexpected response type") 62 | } 63 | 64 | giftList := make([]*tg.StarGift, 0, len(starGifts.Gifts)) 65 | for _, gift := range starGifts.Gifts { 66 | if starGift, ok := gift.(*tg.StarGift); ok { 67 | giftList = append(giftList, starGift) 68 | } 69 | } 70 | return giftList, nil 71 | } 72 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gift-buyer 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.3.1 7 | github.com/go-git/go-git/v5 v5.16.2 8 | github.com/google/uuid v1.6.0 9 | github.com/gotd/td v0.129.0 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/stretchr/testify v1.10.0 12 | ) 13 | 14 | require ( 15 | dario.cat/mergo v1.0.0 // indirect 16 | github.com/Microsoft/go-winio v0.6.2 // indirect 17 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 18 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 19 | github.com/cloudflare/circl v1.6.1 // indirect 20 | github.com/coder/websocket v1.8.13 // indirect 21 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/dlclark/regexp2 v1.11.5 // indirect 24 | github.com/emirpasic/gods v1.18.1 // indirect 25 | github.com/fatih/color v1.18.0 // indirect 26 | github.com/ghodss/yaml v1.0.0 // indirect 27 | github.com/go-faster/errors v0.7.1 // indirect 28 | github.com/go-faster/jx v1.1.0 // indirect 29 | github.com/go-faster/xor v1.0.0 // indirect 30 | github.com/go-faster/yaml v0.4.6 // indirect 31 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 32 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 33 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 34 | github.com/gotd/ige v0.2.2 // indirect 35 | github.com/gotd/neo v0.1.5 // indirect 36 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 37 | github.com/kevinburke/ssh_config v1.2.0 // indirect 38 | github.com/klauspost/compress v1.18.0 // indirect 39 | github.com/mattn/go-colorable v0.1.14 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/ogen-go/ogen v1.14.0 // indirect 42 | github.com/pjbgf/sha1cd v0.3.2 // indirect 43 | github.com/pmezard/go-difflib v1.0.0 // indirect 44 | github.com/segmentio/asm v1.2.0 // indirect 45 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 46 | github.com/skeema/knownhosts v1.3.1 // indirect 47 | github.com/stretchr/objx v0.5.2 // indirect 48 | github.com/xanzy/ssh-agent v0.3.3 // indirect 49 | go.opentelemetry.io/otel v1.37.0 // indirect 50 | go.opentelemetry.io/otel/metric v1.37.0 // indirect 51 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 52 | go.uber.org/atomic v1.11.0 // indirect 53 | go.uber.org/multierr v1.11.0 // indirect 54 | go.uber.org/zap v1.27.0 // indirect 55 | golang.org/x/crypto v0.40.0 // indirect 56 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 57 | golang.org/x/mod v0.26.0 // indirect 58 | golang.org/x/net v0.42.0 // indirect 59 | golang.org/x/sync v0.16.0 // indirect 60 | golang.org/x/sys v0.34.0 // indirect 61 | golang.org/x/text v0.27.0 // indirect 62 | golang.org/x/tools v0.35.0 // indirect 63 | gopkg.in/warnings.v0 v0.1.2 // indirect 64 | gopkg.in/yaml.v2 v2.4.0 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | rsc.io/qr v0.2.0 // indirect 67 | ) 68 | -------------------------------------------------------------------------------- /internal/infrastructure/gitVersion/gv.go: -------------------------------------------------------------------------------- 1 | package gitVersion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "gift-buyer/internal/infrastructure/gitVersion/gitInterfaces" 7 | gittypes "gift-buyer/internal/infrastructure/gitVersion/gitTypes" 8 | "net/http" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/Masterminds/semver/v3" 13 | "github.com/go-git/go-git/v5" 14 | "github.com/go-git/go-git/v5/plumbing" 15 | ) 16 | 17 | type GitVersionControllerImpl struct { 18 | owner string 19 | repoName string 20 | apiLink string 21 | } 22 | 23 | func NewGitVersionController(owner, repoName, apiLink string) gitInterfaces.GitVersionController { 24 | return &GitVersionControllerImpl{ 25 | owner: owner, 26 | repoName: repoName, 27 | apiLink: apiLink, 28 | } 29 | } 30 | 31 | func (gvc *GitVersionControllerImpl) GetLatestVersion() (*gittypes.GitHubRelease, error) { 32 | return gvc.getLatestGitHubRelease() 33 | } 34 | 35 | func (gvc *GitVersionControllerImpl) GetCurrentVersion() (string, error) { 36 | repo, err := git.PlainOpen(".") 37 | if err != nil { 38 | return "", err 39 | } 40 | 41 | return gvc.getLatestLocalTag(repo) 42 | } 43 | 44 | func (gvc *GitVersionControllerImpl) CompareVersions(localVersion, remoteVersion string) (bool, error) { 45 | if localVersion == "" || remoteVersion == "" { 46 | return false, fmt.Errorf("local or remote version is empty") 47 | } 48 | 49 | local, err := semver.NewVersion(localVersion) 50 | if err != nil { 51 | return false, err 52 | } 53 | 54 | remoteVersion = strings.TrimPrefix(remoteVersion, "v") 55 | remote, err := semver.NewVersion(remoteVersion) 56 | if err != nil { 57 | return false, err 58 | } 59 | 60 | return remote.GreaterThan(local), nil 61 | } 62 | 63 | func (gvc *GitVersionControllerImpl) getLatestLocalTag(repo *git.Repository) (string, error) { 64 | refIter, err := repo.Tags() 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | var versions []*semver.Version 70 | 71 | refIter.ForEach(func(ref *plumbing.Reference) error { 72 | if ref.Name().IsTag() { 73 | tagName := ref.Name().Short() 74 | v, err := semver.NewVersion(strings.TrimPrefix(tagName, "v")) 75 | if err == nil { 76 | versions = append(versions, v) 77 | } 78 | } 79 | return nil 80 | }) 81 | 82 | if len(versions) == 0 { 83 | return "", fmt.Errorf("no valid tags found") 84 | } 85 | 86 | sort.Sort(sort.Reverse(semver.Collection(versions))) 87 | return versions[0].String(), nil 88 | } 89 | 90 | func (gvc *GitVersionControllerImpl) getLatestGitHubRelease() (*gittypes.GitHubRelease, error) { 91 | resp, err := http.Get(fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", gvc.owner, gvc.repoName)) 92 | if err != nil { 93 | return nil, err 94 | } 95 | defer resp.Body.Close() 96 | 97 | var release gittypes.GitHubRelease 98 | if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { 99 | return nil, err 100 | } 101 | 102 | return &release, nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/service/giftService/giftNotification/notification_test.go: -------------------------------------------------------------------------------- 1 | package giftNotification 2 | 3 | import ( 4 | "gift-buyer/internal/config" 5 | "gift-buyer/internal/infrastructure/logsWriter/logTypes" 6 | "testing" 7 | 8 | "github.com/gotd/td/tg" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // MockLogsWriter для тестирования 13 | type MockLogsWriter struct{} 14 | 15 | func (m *MockLogsWriter) Write(entry *logTypes.LogEntry) error { 16 | return nil 17 | } 18 | 19 | func (m *MockLogsWriter) LogError(message string) {} 20 | 21 | func (m *MockLogsWriter) LogErrorf(format string, args ...interface{}) {} 22 | 23 | func (m *MockLogsWriter) LogInfo(message string) {} 24 | 25 | func TestNewNotification(t *testing.T) { 26 | mockClient := &tg.Client{} 27 | mockConfig := &config.TgSettings{ 28 | NotificationChatID: 12345, 29 | TgBotKey: "test_bot_token", 30 | } 31 | mockLogsWriter := &MockLogsWriter{} 32 | 33 | service := NewNotification(mockClient, mockConfig, mockLogsWriter) 34 | 35 | assert.NotNil(t, service) 36 | } 37 | 38 | func TestNotificationService_Interface_Compliance(t *testing.T) { 39 | mockClient := &tg.Client{} 40 | mockConfig := &config.TgSettings{ 41 | NotificationChatID: 12345, 42 | TgBotKey: "test_bot_token", 43 | } 44 | mockLogsWriter := &MockLogsWriter{} 45 | 46 | service := NewNotification(mockClient, mockConfig, mockLogsWriter) 47 | 48 | // Verify that the service implements the NotificationService interface 49 | // This is a compile-time check, but we can also verify at runtime 50 | assert.NotNil(t, service) 51 | } 52 | 53 | func TestNotificationService_Structure(t *testing.T) { 54 | mockClient := &tg.Client{} 55 | mockConfig := &config.TgSettings{ 56 | NotificationChatID: 12345, 57 | TgBotKey: "test_bot_token", 58 | } 59 | mockLogsWriter := &MockLogsWriter{} 60 | 61 | service := NewNotification(mockClient, mockConfig, mockLogsWriter) 62 | 63 | // Cast to concrete type to verify internal structure 64 | assert.Equal(t, mockClient, service.Bot) 65 | assert.Equal(t, mockConfig, service.Config) 66 | } 67 | 68 | func TestNotificationService_NilClient(t *testing.T) { 69 | mockConfig := &config.TgSettings{ 70 | NotificationChatID: 12345, 71 | TgBotKey: "test_bot_token", 72 | } 73 | mockLogsWriter := &MockLogsWriter{} 74 | 75 | // Test with nil client - should not panic during creation 76 | service := NewNotification(nil, mockConfig, mockLogsWriter) 77 | assert.NotNil(t, service) 78 | 79 | // Cast to concrete type to verify nil client is stored 80 | assert.Nil(t, service.Bot) 81 | assert.Equal(t, mockConfig, service.Config) 82 | } 83 | 84 | func TestNotificationService_NilConfig(t *testing.T) { 85 | mockClient := &tg.Client{} 86 | mockLogsWriter := &MockLogsWriter{} 87 | 88 | // Test with nil config - should not panic during creation 89 | service := NewNotification(mockClient, nil, mockLogsWriter) 90 | assert.NotNil(t, service) 91 | 92 | // Cast to concrete type to verify nil config is stored 93 | assert.Equal(t, mockClient, service.Bot) 94 | assert.Nil(t, service.Config) 95 | } 96 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the entry point for the Gift Buyer application. 2 | // Gift Buyer is an automated system for monitoring and purchasing Telegram Star Gifts 3 | // based on configurable criteria such as price range, supply limits, and total star cap. 4 | // 5 | // The application connects to Telegram API, monitors available gifts, validates them 6 | // against user-defined criteria, and automatically purchases eligible gifts. 7 | // 8 | // Usage: 9 | // 10 | // go run cmd/main.go 11 | // 12 | // Configuration is loaded from internal/config/config.json file. 13 | package main 14 | 15 | import ( 16 | "context" 17 | "gift-buyer/internal/config" 18 | "gift-buyer/internal/usecase" 19 | "gift-buyer/pkg/logger" 20 | "os" 21 | "os/signal" 22 | "path/filepath" 23 | "runtime" 24 | "syscall" 25 | "time" 26 | ) 27 | 28 | // main is the entry point of the Gift Buyer application. 29 | // It initializes the logger, loads configuration, creates the gift service, 30 | // and handles graceful shutdown on system signals. 31 | func main() { 32 | logger.Init("debug") 33 | 34 | _, b, _, _ := runtime.Caller(0) 35 | basepath := filepath.Dir(b) 36 | configPath := filepath.Join(basepath, "..", "internal", "config", "config.json") 37 | 38 | cfg, err := config.LoadConfig(configPath) 39 | if err != nil { 40 | logger.GlobalLogger.Fatalf("Failed to load config: %v", err) 41 | } 42 | 43 | logLevel := logger.ParseLevel(cfg.LoggerLevel) 44 | logger.Init(logLevel) 45 | 46 | service, err := usecase.NewFactory(&cfg.SoftConfig).CreateSystem() 47 | if err != nil { 48 | logger.GlobalLogger.Fatalf("Failed to init telegram client: %v", err) 49 | } 50 | 51 | if err = service.SetIds(context.Background()); err != nil { 52 | logger.GlobalLogger.Fatalf("Failed to set IDs: %v", err) 53 | } 54 | 55 | go func() { 56 | logger.GlobalLogger.Info("Starting gift service...") 57 | service.Start() 58 | }() 59 | 60 | go func() { 61 | logger.GlobalLogger.Info("Starting update checker...") 62 | service.CheckForUpdates() 63 | }() 64 | 65 | logger.GlobalLogger.Info("Gift buyer service started. Press Ctrl+C to stop.") 66 | gracefulShutdown(service) 67 | logger.GlobalLogger.Info("Application terminated") 68 | } 69 | 70 | // gracefulShutdown handles the graceful shutdown of the gift service. 71 | // It listens for SIGINT and SIGTERM signals and provides a 30-second timeout 72 | // for the service to stop gracefully before forcing termination. 73 | // 74 | // Parameters: 75 | // - service: The GiftService instance to be stopped gracefully 76 | func gracefulShutdown(service usecase.UseCase) { 77 | sigChan := make(chan os.Signal, 1) 78 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 79 | 80 | <-sigChan 81 | logger.GlobalLogger.Info("Received shutdown signal, stopping service...") 82 | 83 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) 84 | defer shutdownCancel() 85 | 86 | done := make(chan struct{}) 87 | go func() { 88 | service.Stop() 89 | close(done) 90 | }() 91 | 92 | select { 93 | case <-done: 94 | logger.GlobalLogger.Info("Service stopped gracefully") 95 | case <-shutdownCtx.Done(): 96 | logger.GlobalLogger.Warn("Shutdown timeout exceeded, forcing exit") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License 2 | 3 | Copyright (c) 2024 [Nikita D./Chief SSQ] 4 | 5 | This work is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License. 6 | 7 | TERMS AND CONDITIONS: 8 | 9 | 1. DEFINITIONS 10 | - "Licensed Material" means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 11 | - "Commercial Use" means primarily intended for or directed towards commercial advantage or monetary compensation. 12 | - "Derivative Works" means material that is based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified. 13 | 14 | 2. GRANT OF RIGHTS 15 | Subject to the terms and conditions of this License, the Licensor hereby grants you a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to: 16 | - Share: copy and redistribute the material in any medium or format 17 | 18 | Under the following terms: 19 | - Attribution: You must give appropriate credit, provide a link to the license, and indicate if changes were made. 20 | - NonCommercial: You may not use the material for commercial purposes. 21 | - NoDerivatives: If you remix, transform, or build upon the material, you may not distribute the modified material. 22 | 23 | 3. RESTRICTIONS 24 | You may NOT: 25 | - Use this software for commercial purposes including but not limited to: 26 | * Selling the software or any derivative works 27 | * Using the software in a commercial product or service 28 | * Offering the software as a paid service 29 | - Create derivative works, modifications, or forks for distribution 30 | - Sublicense or transfer your rights under this license 31 | 32 | 4. ATTRIBUTION REQUIREMENTS 33 | If you share the Licensed Material, you must: 34 | - Retain copyright notices and license information 35 | - Provide attribution to the original author 36 | - Include a link to this license 37 | - Indicate if the Licensed Material is not modified 38 | 39 | 5. DISCLAIMER 40 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | 42 | 6. TERMINATION 43 | This license automatically terminates if you violate any of its terms. Upon termination, you must cease all use and distribution of the Licensed Material. 44 | 45 | 7. LEGAL ENFORCEMENT 46 | This license is governed by international copyright law and has legal enforceability in jurisdictions that recognize Creative Commons licenses. Violations may result in legal action for copyright infringement. 47 | 48 | For more information about this license, visit: 49 | https://creativecommons.org/licenses/by-nc-nd/4.0/ 50 | 51 | To view a copy of this license, visit: 52 | https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/purchaseProcessor/purchase.go: -------------------------------------------------------------------------------- 1 | package purchaseProcessor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gift-buyer/internal/service/giftService/giftInterfaces" 7 | "gift-buyer/internal/service/giftService/giftTypes" 8 | "gift-buyer/pkg/errors" 9 | 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | type PurchaseProcessorImpl struct { 14 | api *tg.Client 15 | paymentProcessor giftInterfaces.PaymentProcessor 16 | } 17 | 18 | func NewPurchaseProcessor(api *tg.Client, paymentProcessor giftInterfaces.PaymentProcessor) *PurchaseProcessorImpl { 19 | return &PurchaseProcessorImpl{ 20 | api: api, 21 | paymentProcessor: paymentProcessor, 22 | } 23 | } 24 | 25 | // purchaseGift executes the actual gift purchase through Telegram's payment API. 26 | // It creates an invoice, retrieves the payment form, and processes the star payment. 27 | // 28 | // The purchase process: 29 | // 1. Creates an invoice for the gift 30 | // 2. Retrieves the payment form from Telegram 31 | // 3. Processes the payment based on form type 32 | // 4. Handles different payment form variations 33 | // 34 | // Parameters: 35 | // - ctx: context for request cancellation and timeout control 36 | // - gift: the star gift to purchase 37 | // 38 | // Returns: 39 | // - error: payment processing error or API communication failure 40 | func (pp *PurchaseProcessorImpl) PurchaseGift(ctx context.Context, gift *giftTypes.GiftRequire) error { 41 | if !pp.validatePurchase(gift.Gift) { 42 | return errors.New("insufficient balance to buy gift") 43 | } 44 | 45 | paymentForm, invoice, err := pp.paymentProcessor.CreatePaymentForm(ctx, gift) 46 | if err != nil { 47 | return errors.Wrap(err, "failed to send stars form") 48 | } 49 | 50 | switch form := paymentForm.(type) { 51 | case *tg.PaymentsPaymentFormStars: 52 | return pp.sendStarsForm(ctx, invoice, form.FormID) 53 | case *tg.PaymentsPaymentFormStarGift: 54 | return pp.sendStarsForm(ctx, invoice, form.FormID) 55 | case *tg.PaymentsPaymentForm: 56 | return errors.New("regular payment form not supported for star gifts") 57 | default: 58 | return errors.Wrap(errors.New("unexpected payment form type"), 59 | fmt.Sprintf("unexpected payment form type: %T", paymentForm)) 60 | } 61 | } 62 | 63 | func (pp *PurchaseProcessorImpl) sendStarsForm(ctx context.Context, invoice *tg.InputInvoiceStarGift, id int64) error { 64 | sendStarsRequest := &tg.PaymentsSendStarsFormRequest{ 65 | FormID: id, 66 | Invoice: invoice, 67 | } 68 | 69 | _, err := pp.api.PaymentsSendStarsForm(ctx, sendStarsRequest) 70 | if err != nil { 71 | return errors.Wrap(err, "failed to send payment") 72 | } 73 | return nil 74 | } 75 | 76 | // validatePurchase checks if a purchase can proceed by validating the user's balance. 77 | // It ensures sufficient stars are available before attempting the actual purchase. 78 | // 79 | // Parameters: 80 | // - ctx: context for request cancellation and timeout control 81 | // - gift: the star gift to validate for purchase 82 | // 83 | // Returns: 84 | // - error: validation error if insufficient balance or balance check fails 85 | func (pp *PurchaseProcessorImpl) validatePurchase(gift *tg.StarGift) bool { 86 | if pp.api == nil { 87 | return false 88 | } 89 | 90 | balance, err := pp.api.PaymentsGetStarsStatus(context.Background(), &tg.PaymentsGetStarsStatusRequest{ 91 | Peer: &tg.InputPeerSelf{}, 92 | }) 93 | if err != nil { 94 | return false 95 | } 96 | return balance.Balance.GetAmount() >= gift.Stars 97 | } 98 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/invoiceCreator/invoiceCreator_test.go: -------------------------------------------------------------------------------- 1 | package invoiceCreator 2 | 3 | import ( 4 | "testing" 5 | 6 | "gift-buyer/internal/service/giftService/giftTypes" 7 | 8 | "github.com/gotd/td/tg" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | ) 12 | 13 | // MockUserCache для тестирования 14 | type MockUserCache struct { 15 | mock.Mock 16 | } 17 | 18 | func (m *MockUserCache) SetUser(key string, user *tg.User) { 19 | m.Called(key, user) 20 | } 21 | 22 | func (m *MockUserCache) GetUser(id string) (*tg.User, error) { 23 | args := m.Called(id) 24 | if args.Get(0) == nil { 25 | return nil, args.Error(1) 26 | } 27 | return args.Get(0).(*tg.User), args.Error(1) 28 | } 29 | 30 | func (m *MockUserCache) SetChannel(key string, channel *tg.Channel) { 31 | m.Called(key, channel) 32 | } 33 | 34 | func (m *MockUserCache) GetChannel(id string) (*tg.Channel, error) { 35 | args := m.Called(id) 36 | if args.Get(0) == nil { 37 | return nil, args.Error(1) 38 | } 39 | return args.Get(0).(*tg.Channel), args.Error(1) 40 | } 41 | 42 | func createTestGift(id int64, stars int64) *tg.StarGift { 43 | return &tg.StarGift{ 44 | ID: id, 45 | Stars: stars, 46 | } 47 | } 48 | 49 | func createTestGiftRequire(gift *tg.StarGift, receiverType []int) *giftTypes.GiftRequire { 50 | return &giftTypes.GiftRequire{ 51 | Gift: gift, 52 | ReceiverType: receiverType, 53 | CountForBuy: 1, 54 | Hide: true, 55 | } 56 | } 57 | 58 | func TestNewInvoiceCreator(t *testing.T) { 59 | mockCache := &MockUserCache{} 60 | userReceiver := []string{"123456"} 61 | channelReceiver := []string{"789012"} 62 | 63 | creator := NewInvoiceCreator(userReceiver, channelReceiver, mockCache) 64 | 65 | assert.NotNil(t, creator) 66 | assert.Equal(t, userReceiver, creator.userReceiver) 67 | assert.Equal(t, channelReceiver, creator.channelReceiver) 68 | assert.Equal(t, mockCache, creator.idCache) 69 | } 70 | 71 | func TestInvoiceCreatorImpl_CreateInvoice(t *testing.T) { 72 | t.Run("создание инвойса", func(t *testing.T) { 73 | mockCache := &MockUserCache{} 74 | 75 | creator := NewInvoiceCreator( 76 | []string{"123456"}, 77 | []string{"789012"}, 78 | mockCache, 79 | ) 80 | 81 | gift := createTestGift(1, 100) 82 | giftRequire := createTestGiftRequire(gift, []int{0}) 83 | 84 | // Тестируем создание инвойса для self (type 0) 85 | invoice, err := creator.CreateInvoice(giftRequire) 86 | 87 | assert.NoError(t, err) 88 | assert.NotNil(t, invoice) 89 | assert.Equal(t, gift.ID, invoice.GiftID) 90 | assert.NotEmpty(t, invoice.Message.Text) 91 | 92 | // Проверяем что peer установлен 93 | assert.NotNil(t, invoice.Peer) 94 | }) 95 | } 96 | 97 | func TestInvoiceCreatorImpl_SelfPurchase(t *testing.T) { 98 | t.Run("создание инвойса для себя", func(t *testing.T) { 99 | mockCache := &MockUserCache{} 100 | creator := NewInvoiceCreator([]string{}, []string{}, mockCache) 101 | 102 | gift := createTestGift(1, 100) 103 | giftRequire := createTestGiftRequire(gift, []int{0}) 104 | invoice, err := creator.selfPurchase(giftRequire) 105 | 106 | assert.NoError(t, err) 107 | assert.NotNil(t, invoice) 108 | assert.Equal(t, gift.ID, invoice.GiftID) 109 | assert.True(t, invoice.HideName) 110 | assert.NotEmpty(t, invoice.Message.Text) 111 | 112 | // Проверяем что peer это InputPeerSelf 113 | _, ok := invoice.Peer.(*tg.InputPeerSelf) 114 | assert.True(t, ok) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /internal/service/giftService/accountManager/manager.go: -------------------------------------------------------------------------------- 1 | // Package accountManager manages accounts and their validation.s 2 | package accountManager 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "gift-buyer/pkg/errors" 8 | "gift-buyer/pkg/logger" 9 | "strings" 10 | 11 | "github.com/gotd/td/tg" 12 | ) 13 | 14 | type accountManagerImpl struct { 15 | api *tg.Client 16 | usernames, channelNames []string 17 | userCache UserCache 18 | channelCache ChannelCache 19 | } 20 | 21 | func NewAccountManager(api *tg.Client, usernames, channelNames []string, userCache UserCache, channelCache ChannelCache) *accountManagerImpl { 22 | return &accountManagerImpl{ 23 | api: api, 24 | usernames: usernames, 25 | channelNames: channelNames, 26 | userCache: userCache, 27 | channelCache: channelCache, 28 | } 29 | } 30 | 31 | func (am *accountManagerImpl) SetIds(ctx context.Context) error { 32 | if am.api == nil { 33 | return errors.New("API client is nil") 34 | } 35 | 36 | if len(am.usernames) > 0 { 37 | if err := am.loadUsersToCache(ctx); err != nil { 38 | return errors.Wrap(err, "failed to load users to cache") 39 | } 40 | } 41 | 42 | if len(am.channelNames) > 0 { 43 | if err := am.loadChannelsToCache(ctx); err != nil { 44 | return errors.Wrap(err, "failed to load channels to cache") 45 | } 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (am *accountManagerImpl) loadUsersToCache(ctx context.Context) error { 52 | if am.api == nil { 53 | return errors.New("API client is nil") 54 | } 55 | 56 | for _, username := range am.usernames { 57 | withoutTag := strings.TrimPrefix(username, "@") 58 | 59 | res, err := am.api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ 60 | Username: withoutTag, 61 | }) 62 | if err != nil { 63 | return errors.Wrap(err, "failed to resolve username") 64 | } 65 | for _, user := range res.Users { 66 | if u, ok := user.(*tg.User); ok { 67 | am.userCache.SetUser(withoutTag, u) 68 | } 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func (am *accountManagerImpl) loadChannelsToCache(ctx context.Context) error { 76 | cachedCount := 0 77 | notFoundChannels := []string{} 78 | 79 | for _, channelName := range am.channelNames { 80 | withoutTag := strings.TrimPrefix(channelName, "@") 81 | 82 | channel, err := am.loadSingleChannel(ctx, withoutTag) 83 | if err != nil { 84 | logger.GlobalLogger.Errorf("failed to load channel %s: %v", channelName, err) 85 | notFoundChannels = append(notFoundChannels, channelName) 86 | continue 87 | } 88 | 89 | am.channelCache.SetChannel(withoutTag, channel) 90 | cachedCount++ 91 | } 92 | 93 | if len(notFoundChannels) > 0 { 94 | logger.GlobalLogger.Warnf("Channels not found or inaccessible: %v", notFoundChannels) 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (am *accountManagerImpl) loadSingleChannel(ctx context.Context, channelName string) (*tg.Channel, error) { 101 | if am.api == nil { 102 | return nil, errors.New("API client is nil") 103 | } 104 | res, err := am.api.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ 105 | Username: channelName, 106 | }) 107 | if err != nil { 108 | return nil, errors.Wrap(err, "failed to resolve username") 109 | } 110 | for _, channel := range res.Chats { 111 | if c, ok := channel.(*tg.Channel); ok { 112 | am.channelCache.SetChannel(channelName, c) 113 | return c, nil 114 | } 115 | } 116 | 117 | return nil, errors.New(fmt.Sprintf("channel %s not found in response", channelName)) 118 | } 119 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/purchaseProcessor/purchase_test.go: -------------------------------------------------------------------------------- 1 | package purchaseProcessor 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gift-buyer/internal/service/giftService/giftTypes" 8 | 9 | "github.com/gotd/td/tg" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | // MockPaymentProcessor для тестирования 15 | type MockPaymentProcessor struct { 16 | mock.Mock 17 | } 18 | 19 | func (m *MockPaymentProcessor) CreatePaymentForm(ctx context.Context, gift *giftTypes.GiftRequire) (tg.PaymentsPaymentFormClass, *tg.InputInvoiceStarGift, error) { 20 | args := m.Called(ctx, gift) 21 | if args.Get(0) == nil || args.Get(1) == nil { 22 | return nil, nil, args.Error(2) 23 | } 24 | return args.Get(0).(tg.PaymentsPaymentFormClass), args.Get(1).(*tg.InputInvoiceStarGift), args.Error(2) 25 | } 26 | 27 | func createTestGift(id int64, stars int64) *tg.StarGift { 28 | return &tg.StarGift{ 29 | ID: id, 30 | Stars: stars, 31 | } 32 | } 33 | 34 | func createTestGiftRequire(gift *tg.StarGift) *giftTypes.GiftRequire { 35 | return &giftTypes.GiftRequire{ 36 | Gift: gift, 37 | ReceiverType: []int{0}, 38 | CountForBuy: 1, 39 | Hide: true, 40 | } 41 | } 42 | 43 | func createTestInvoice(giftID int64) *tg.InputInvoiceStarGift { 44 | return &tg.InputInvoiceStarGift{ 45 | Peer: &tg.InputPeerSelf{}, 46 | GiftID: giftID, 47 | Message: tg.TextWithEntities{ 48 | Text: "Test message", 49 | }, 50 | } 51 | } 52 | 53 | func TestNewPurchaseProcessor(t *testing.T) { 54 | mockPaymentProcessor := &MockPaymentProcessor{} 55 | 56 | processor := NewPurchaseProcessor(nil, mockPaymentProcessor) 57 | 58 | assert.NotNil(t, processor) 59 | assert.Nil(t, processor.api) 60 | assert.Equal(t, mockPaymentProcessor, processor.paymentProcessor) 61 | } 62 | 63 | func TestPurchaseProcessorImpl_PurchaseGift_ErrorCases(t *testing.T) { 64 | t.Run("ошибка при недостаточном балансе", func(t *testing.T) { 65 | mockPaymentProcessor := &MockPaymentProcessor{} 66 | 67 | processor := &PurchaseProcessorImpl{ 68 | api: nil, 69 | paymentProcessor: mockPaymentProcessor, 70 | } 71 | 72 | gift := createTestGift(1, 100) 73 | giftRequire := createTestGiftRequire(gift) 74 | 75 | // Настраиваем мок на случай если CreatePaymentForm все-таки вызовется 76 | mockPaymentProcessor.On("CreatePaymentForm", mock.Anything, mock.Anything).Return(nil, nil, assert.AnError) 77 | 78 | ctx := context.Background() 79 | err := processor.PurchaseGift(ctx, giftRequire) 80 | 81 | assert.Error(t, err) 82 | // Проверяем что есть ошибка (любая) 83 | assert.NotNil(t, err) 84 | }) 85 | } 86 | 87 | func TestPurchaseProcessorImpl_SendStarsForm(t *testing.T) { 88 | t.Run("тестирование с nil API", func(t *testing.T) { 89 | processor := &PurchaseProcessorImpl{ 90 | api: nil, 91 | paymentProcessor: nil, 92 | } 93 | 94 | invoice := createTestInvoice(1) 95 | ctx := context.Background() 96 | 97 | // С nil API должна возникнуть паника или ошибка 98 | assert.Panics(t, func() { 99 | processor.sendStarsForm(ctx, invoice, 12345) 100 | }) 101 | }) 102 | } 103 | 104 | func TestPurchaseProcessorImpl_ValidatePurchase_NilAPI(t *testing.T) { 105 | t.Run("валидация с nil API", func(t *testing.T) { 106 | processor := &PurchaseProcessorImpl{ 107 | api: nil, 108 | paymentProcessor: nil, 109 | } 110 | 111 | gift := createTestGift(1, 100) 112 | 113 | // С nil API валидация должна вернуть false 114 | result := processor.validatePurchase(gift) 115 | assert.False(t, result) 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /internal/service/giftService/cache/giftCache/utils.go: -------------------------------------------------------------------------------- 1 | // Package giftCache provides persistent caching functionality for the gift buying system. 2 | package giftCache 3 | 4 | import ( 5 | "encoding/json" 6 | "gift-buyer/pkg/logger" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | // CachedGift represents a simplified gift structure for JSON serialization. 14 | // It contains only the essential fields needed for persistence and comparison, 15 | // reducing storage overhead and avoiding complex nested structures. 16 | type CachedGift struct { 17 | // ID is the unique identifier of the gift 18 | ID int64 `json:"id"` 19 | 20 | // Stars is the price of the gift in Telegram stars 21 | Stars int64 `json:"stars"` 22 | } 23 | 24 | // loadFromFile loads cached gift data from the cache.json file. 25 | // It reads the JSON file, parses the cached gifts, and populates the in-memory cache. 26 | // If the file doesn't exist or contains invalid data, it logs a warning and continues 27 | // with an empty cache. 28 | // 29 | // The method is called during cache initialization to restore previously cached gifts. 30 | // It reconstructs StarGift objects from the simplified CachedGift structures. 31 | func (gc *GiftCacheImpl) loadFromFile() { 32 | data, err := os.ReadFile("cache.json") 33 | if err != nil { 34 | if !os.IsNotExist(err) { 35 | logger.GlobalLogger.Warnf("Failed to read cache file: %v", err) 36 | } 37 | return 38 | } 39 | 40 | var cachedGifts map[string]CachedGift 41 | if err := json.Unmarshal(data, &cachedGifts); err != nil { 42 | logger.GlobalLogger.Warnf("Failed to unmarshal cache file: %v", err) 43 | return 44 | } 45 | 46 | gc.mu.Lock() 47 | count := 0 48 | for _, cached := range cachedGifts { 49 | gift := &tg.StarGift{ 50 | ID: cached.ID, 51 | Stars: cached.Stars, 52 | } 53 | gc.cache[cached.ID] = gift 54 | count++ 55 | } 56 | gc.mu.Unlock() 57 | 58 | logger.GlobalLogger.Infof("Loaded %d gifts from cache file", count) 59 | } 60 | 61 | // saveToFile persists the current cache state to the cache.json file. 62 | // It merges new gifts with existing cached data to avoid overwriting 63 | // previously saved gifts, then writes the updated data to disk. 64 | // 65 | // The method: 66 | // 1. Reads existing cache file to preserve previously saved data 67 | // 2. Identifies new gifts that haven't been saved yet 68 | // 3. Merges new gifts with existing cached data 69 | // 4. Writes the complete dataset to cache.json 70 | // 71 | // Only new gifts are added to the file to optimize I/O operations. 72 | // If no new gifts are found, the save operation is skipped. 73 | func (gc *GiftCacheImpl) saveToFile() { 74 | var existingCache map[string]CachedGift 75 | if data, err := os.ReadFile("cache.json"); err == nil { 76 | if unmarshalErr := json.Unmarshal(data, &existingCache); unmarshalErr != nil { 77 | logger.GlobalLogger.Warnf("Failed to unmarshal existing cache: %v", unmarshalErr) 78 | } 79 | } 80 | if existingCache == nil { 81 | existingCache = make(map[string]CachedGift) 82 | } 83 | 84 | gc.mu.RLock() 85 | newGifts := 0 86 | for id, gift := range gc.cache { 87 | key := strconv.FormatInt(id, 10) 88 | if _, exists := existingCache[key]; !exists { 89 | existingCache[key] = CachedGift{ 90 | ID: gift.ID, 91 | Stars: gift.Stars, 92 | } 93 | newGifts++ 94 | } 95 | } 96 | gc.mu.RUnlock() 97 | 98 | if newGifts == 0 { 99 | return 100 | } 101 | 102 | jsonData, err := json.MarshalIndent(existingCache, "", " ") 103 | if err != nil { 104 | logger.GlobalLogger.Errorf("Failed to marshal cache: %v", err) 105 | return 106 | } 107 | 108 | if err := os.WriteFile("cache.json", jsonData, 0600); err != nil { 109 | logger.GlobalLogger.Errorf("Failed to write cache to file: %v", err) 110 | return 111 | } 112 | 113 | logger.GlobalLogger.Infof("Saved %d new gifts to cache file", newGifts) 114 | } 115 | -------------------------------------------------------------------------------- /pkg/errors/common_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | text string 14 | expected string 15 | }{ 16 | { 17 | name: "creates error with simple text", 18 | text: "test error", 19 | expected: "test error", 20 | }, 21 | { 22 | name: "creates error with empty text", 23 | text: "", 24 | expected: "", 25 | }, 26 | { 27 | name: "creates error with special characters", 28 | text: "error: failed to process $data with 100% accuracy", 29 | expected: "error: failed to process $data with 100% accuracy", 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | err := New(tt.text) 36 | assert.Error(t, err) 37 | assert.Equal(t, tt.expected, err.Error()) 38 | }) 39 | } 40 | } 41 | 42 | func TestWrap(t *testing.T) { 43 | originalErr := errors.New("original error") 44 | 45 | tests := []struct { 46 | name string 47 | err error 48 | context string 49 | expected string 50 | isNil bool 51 | }{ 52 | { 53 | name: "wraps error with context", 54 | err: originalErr, 55 | context: "operation failed", 56 | expected: "operation failed: original error", 57 | isNil: false, 58 | }, 59 | { 60 | name: "returns nil when wrapping nil error", 61 | err: nil, 62 | context: "some context", 63 | expected: "", 64 | isNil: true, 65 | }, 66 | { 67 | name: "wraps error with empty context", 68 | err: originalErr, 69 | context: "", 70 | expected: ": original error", 71 | isNil: false, 72 | }, 73 | } 74 | 75 | for _, tt := range tests { 76 | t.Run(tt.name, func(t *testing.T) { 77 | result := Wrap(tt.err, tt.context) 78 | if tt.isNil { 79 | assert.Nil(t, result) 80 | } else { 81 | assert.Error(t, result) 82 | assert.Equal(t, tt.expected, result.Error()) 83 | assert.True(t, errors.Is(result, tt.err)) 84 | } 85 | }) 86 | } 87 | } 88 | 89 | func TestPredefinedErrors(t *testing.T) { 90 | tests := []struct { 91 | name string 92 | err error 93 | text string 94 | }{ 95 | {"ErrNotFound", ErrNotFound, "not found"}, 96 | {"ErrUnsupportedType", ErrUnsupportedType, "unsupported type"}, 97 | {"ErrExitProgram", ErrExitProgram, "exit program"}, 98 | {"ErrValueEmpty", ErrValueEmpty, "value empty"}, 99 | {"ErrSelection", ErrSelection, "selection error"}, 100 | {"ErrUnexpectedType", ErrUnexpectedType, "unexpected type"}, 101 | {"ErrConnectionFailed", ErrConnectionFailed, "connection failed"}, 102 | {"ErrRequestFailed", ErrRequestFailed, "request failed"}, 103 | {"ErrResponseParsing", ErrResponseParsing, "failed to parse response"}, 104 | {"ErrStatusCode", ErrStatusCode, "unexpected status code"}, 105 | {"ErrRateLimitReached", ErrRateLimitReached, "rate limit reached"}, 106 | {"ErrInvalidParams", ErrInvalidParams, "invalid or missing parameters"}, 107 | {"ErrNoCreatedValue", ErrNoCreatedValue, "no parameter has been created"}, 108 | {"ErrFailedInit", ErrFailedInit, "failed to initialize"}, 109 | {"ErrConfigRead", ErrConfigRead, "failed to read config"}, 110 | {"ErrConfigParse", ErrConfigParse, "failed to parse config"}, 111 | {"ErrConfigSave", ErrConfigSave, "failed to save config"}, 112 | {"ErrInvalidConfig", ErrInvalidConfig, "invalid configuration"}, 113 | } 114 | 115 | for _, tt := range tests { 116 | t.Run(tt.name, func(t *testing.T) { 117 | assert.Error(t, tt.err) 118 | assert.Equal(t, tt.text, tt.err.Error()) 119 | }) 120 | } 121 | } 122 | 123 | func TestErrorWrapping(t *testing.T) { 124 | baseErr := ErrNotFound 125 | wrappedErr := Wrap(baseErr, "user lookup failed") 126 | 127 | assert.Error(t, wrappedErr) 128 | assert.Equal(t, "user lookup failed: not found", wrappedErr.Error()) 129 | assert.True(t, errors.Is(wrappedErr, baseErr)) 130 | } 131 | 132 | func TestMultipleWrapping(t *testing.T) { 133 | baseErr := ErrConnectionFailed 134 | firstWrap := Wrap(baseErr, "database connection") 135 | secondWrap := Wrap(firstWrap, "service initialization") 136 | 137 | assert.Error(t, secondWrap) 138 | assert.Equal(t, "service initialization: database connection: connection failed", secondWrap.Error()) 139 | assert.True(t, errors.Is(secondWrap, baseErr)) 140 | assert.True(t, errors.Is(secondWrap, firstWrap)) 141 | } 142 | -------------------------------------------------------------------------------- /internal/config/config_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment_main": "Конфигурация Gift Buyer - автоматической системы покупки Telegram подарков", 3 | "_comment_logging": "Уровень логирования: debug, info, warn, error", 4 | "logger_level": "debug", 5 | 6 | "soft_config": { 7 | "_comment_tg_settings": "=== НАСТРОЙКИ TELEGRAM ===", 8 | "tg_settings": { 9 | "_comment_api": "Обязательные параметры API из my.telegram.org", 10 | "app_id": 1234567890, 11 | "api_hash": "qwertyuiop[]asdfghjkl;'zxcvbnm,./", 12 | "phone": "+71234567890", 13 | "password": "", 14 | 15 | "_comment_notifications": "=== УВЕДОМЛЕНИЯ И ЛОГИРОВАНИЕ ===", 16 | "_comment_bot": "ВАЖНО: Настройте бота для получения уведомлений об ошибках, статусе покупок и процессе переподключения", 17 | "tg_bot_key": "1234567890:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 18 | "_comment_datacenter": "Датацентр Telegram (0=авто, 1-5=конкретный ДЦ). Рекомендуется 5 если ДЦ2 лагает", 19 | "datacenter": 4, 20 | "_comment_chat": "Ваш User ID для отправки уведомлений (получить у @userinfobot)", 21 | "notification_chat_id": 1234567890 22 | }, 23 | 24 | "_comment_logging_system": "=== СИСТЕМА ЛОГИРОВАНИЯ ===", 25 | "_comment_log_flag": "Флаг для записи логов как в файл, так и в консоль (true/false)", 26 | "log_flag": true, 27 | 28 | "_comment_updates": "=== СИСТЕМА ОБНОВЛЕНИЙ ===", 29 | "update_ticker": 60, 30 | "repo_owner": "ssq0-0", 31 | "repo_name": "Gift-buyer", 32 | "api_link": "https://api.github.com", 33 | 34 | "_comment_criteria": "=== КРИТЕРИИ ПОКУПКИ С ПРИОРИТИЗАЦИЕЙ ===", 35 | "criterias": [ 36 | { 37 | "_comment": "Дешевые подарки - отправляются пользователям", 38 | "min_price": 10, 39 | "max_price": 100, 40 | "total_supply": 100000000, 41 | "count": 10, 42 | "hide": false, 43 | "receiver_type": [1] 44 | }, 45 | { 46 | "_comment": "Дорогие подарки - отправляются себе или в каналы", 47 | "min_price": 500, 48 | "max_price": 1000, 49 | "total_supply": 50000, 50 | "count": 5, 51 | "receiver_type": [0, 2] 52 | } 53 | ], 54 | 55 | "_comment_gift_params": "=== ПАРАМЕТРЫ ПОДАРКОВ ===", 56 | "gift_param": { 57 | "_comment_star_cap": "Максимальная капитализация в звездах", 58 | "total_star_cap": 1000000000000, 59 | "_comment_limited": "Покупать только ограниченные подарки (true) или неограниченные (false)", 60 | "limited_status": true, 61 | "_comment_release": "Покупать только подарки от кого-то конкретного (true/false)", 62 | "release_by": false, 63 | "_comment_test": "Тестовый режим - отключает проверки лимитов и ограничений (true/false)", 64 | "test_mode": false, 65 | "_comment_premium": "Покупать только премиум подарки (true/false)", 66 | "only_premium": false 67 | }, 68 | 69 | "_comment_receivers": "=== ПОЛУЧАТЕЛИ ПОДАРКОВ (ТЕГИ) ===", 70 | "receiver": { 71 | "_comment_users": "Теги пользователей (без @). Оставить пустой массив, если не нужно покупать подарки пользователям", 72 | "user_receiver_id": ["username1", "username2", "username3"], 73 | "_comment_channels": "Теги каналов (без @). Оставить пустой массив, если не нужно покупать подарки в каналы", 74 | "channel_receiver_id": ["channel1", "channel2", "channel3"] 75 | }, 76 | 77 | "_comment_performance": "=== ПРОИЗВОДИТЕЛЬНОСТЬ И НАДЕЖНОСТЬ ===", 78 | "_comment_monitoring": "Интервал мониторинга в секундах", 79 | "ticker": 2.0, 80 | "_comment_limits": "Глобальные ограничения на покупки", 81 | "max_buy_count": 100, 82 | "_comment_retry": "РЕКОМЕНДУЕТСЯ: 5+ попыток, 2-3 сек задержка, макс 30 RPS", 83 | "retry_count": 5, 84 | "retry_delay": 2.5, 85 | "_comment_concurrency": "Параллельная обработка", 86 | "concurrency_gift_count": 10, 87 | "concurrent_operations": 300, 88 | "rpc_rate_limit": 20, 89 | "_comment_prioritization": "Приоритизация. Покупает последовательно от самого дорогого к дешевому. Никакой параллельности и конкурентности, только медленная и последовательная покупка.", 90 | "prioritization": false 91 | } 92 | } -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/giftBuyerMonitoring/monitor.go: -------------------------------------------------------------------------------- 1 | package giftBuyerMonitoring 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gift-buyer/internal/service/giftService/giftInterfaces" 7 | "gift-buyer/internal/service/giftService/giftTypes" 8 | "gift-buyer/pkg/errors" 9 | 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | type GiftBuyerMonitoringImpl struct { 14 | api *tg.Client 15 | notification giftInterfaces.NotificationService 16 | infoLogsWriter giftInterfaces.InfoLogger 17 | errorLogsWriter giftInterfaces.ErrorLogger 18 | } 19 | 20 | func NewGiftBuyerMonitoring(api *tg.Client, notification giftInterfaces.NotificationService, infoLogsWriter giftInterfaces.InfoLogger, errorLogsWriter giftInterfaces.ErrorLogger) *GiftBuyerMonitoringImpl { 21 | return &GiftBuyerMonitoringImpl{ 22 | api: api, 23 | notification: notification, 24 | infoLogsWriter: infoLogsWriter, 25 | errorLogsWriter: errorLogsWriter, 26 | } 27 | } 28 | 29 | func (gm *GiftBuyerMonitoringImpl) MonitorProcess(ctx context.Context, resultsCh chan giftTypes.GiftResult, doneChan chan struct{}, gifts []*giftTypes.GiftRequire) { 30 | summaries := make(map[int64]*giftTypes.GiftSummary) 31 | errorCounts := make(map[string]int64) 32 | for _, require := range gifts { 33 | summaries[require.Gift.ID] = &giftTypes.GiftSummary{ 34 | GiftID: require.Gift.ID, 35 | Requested: require.CountForBuy, 36 | Success: 0, 37 | } 38 | } 39 | 40 | for { 41 | select { 42 | case <-ctx.Done(): 43 | return 44 | case <-doneChan: 45 | mostFrequentError := gm.getMostFrequentError(errorCounts) 46 | gm.sendNotify(ctx, summaries, mostFrequentError) 47 | return 48 | case result, ok := <-resultsCh: 49 | if !ok { 50 | return 51 | } 52 | 53 | if result.Success { 54 | summaries[result.GiftID].Success++ 55 | gm.infoLogsWriter.LogInfo(fmt.Sprintf("Successfully purchased gift %d", result.GiftID)) 56 | } else if result.Err != nil { 57 | errorCounts[result.Err.Error()]++ 58 | gm.errorLogsWriter.LogError(fmt.Sprintf("Failed to purchase gift %d: %v", result.GiftID, result.Err)) 59 | } 60 | 61 | } 62 | } 63 | } 64 | 65 | func (gm *GiftBuyerMonitoringImpl) getMostFrequentError(errorCounts map[string]int64) error { 66 | if len(errorCounts) == 0 { 67 | return nil 68 | } 69 | 70 | var mostFrequentError string 71 | var maxCount int64 72 | 73 | for errorMsg, count := range errorCounts { 74 | if count > maxCount { 75 | maxCount = count 76 | mostFrequentError = errorMsg 77 | } 78 | } 79 | 80 | return errors.New(mostFrequentError) 81 | } 82 | 83 | func (gm *GiftBuyerMonitoringImpl) sendNotify(ctx context.Context, summaries map[int64]*giftTypes.GiftSummary, mostFrequentError error) { 84 | totalSuccess := int64(0) 85 | totalRequested := int64(0) 86 | 87 | for _, summary := range summaries { 88 | totalSuccess += summary.Success 89 | totalRequested += summary.Requested 90 | } 91 | 92 | if gm.notification.SetBot() { 93 | if totalSuccess == totalRequested { 94 | gm.notification.SendBuyStatus(ctx, 95 | fmt.Sprintf("✅ Успешно куплено %d подарков", totalSuccess), nil) 96 | } else if totalSuccess > 0 { 97 | message := fmt.Sprintf("⚠️ Частично выполнено: %d/%d подарков куплено", 98 | totalSuccess, totalRequested) 99 | gm.notification.SendBuyStatus(ctx, message, nil) 100 | } else { 101 | message := fmt.Sprintf("❌ Не удалось купить ни одного подарка из %d", totalRequested) 102 | errorToSend := mostFrequentError 103 | if errorToSend == nil { 104 | errorToSend = errors.New("все покупки неудачны") 105 | } 106 | gm.notification.SendBuyStatus(ctx, message, errorToSend) 107 | } 108 | } else { 109 | if totalSuccess == totalRequested { 110 | gm.infoLogsWriter.LogInfo(fmt.Sprintf("✅ Successfully bought all %d gifts", totalSuccess)) 111 | } else if totalSuccess > 0 { 112 | gm.infoLogsWriter.LogInfo(fmt.Sprintf("⚠️ Partially completed: %d/%d gifts bought", totalSuccess, totalRequested)) 113 | } else { 114 | gm.errorLogsWriter.LogError(fmt.Sprintf("❌ Failed to buy any gifts out of %d requested", totalRequested)) 115 | } 116 | 117 | for _, summary := range summaries { 118 | if summary.Success > 0 { 119 | gm.infoLogsWriter.LogInfo(fmt.Sprintf("Successfully bought %d/%d x gift %d", 120 | summary.Success, summary.Requested, summary.GiftID)) 121 | } else { 122 | gm.errorLogsWriter.LogError(fmt.Sprintf("Failed to buy %d/%d x gift %d", 123 | summary.Success, summary.Requested, summary.GiftID)) 124 | } 125 | } 126 | if mostFrequentError != nil { 127 | gm.errorLogsWriter.LogError(fmt.Sprintf("Most frequent error during purchase: %v", mostFrequentError)) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /internal/service/authService/authInterfaces/interfaces.go: -------------------------------------------------------------------------------- 1 | package authInterfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gotd/td/telegram" 7 | "github.com/gotd/td/tg" 8 | ) 9 | 10 | type AuthManager interface { 11 | // CreateDeviceConfig creates a new device configuration for the Telegram client 12 | // 13 | // Returns: 14 | // - telegram.DeviceConfig: device configuration for the Telegram client 15 | CreateDeviceConfig() telegram.DeviceConfig 16 | 17 | // InitClient initializes the Telegram client 18 | // Parameters: 19 | // - client: Telegram client to initialize 20 | // - ctx: context for cancellation and timeout control 21 | // 22 | // Returns: 23 | // - *tg.Client: authenticated Telegram API client 24 | // - error: authentication error, network error, or timeout 25 | InitClient(client *telegram.Client, ctx context.Context) (*tg.Client, error) 26 | 27 | // CreateBotClient creates a new bot client for the Telegram client 28 | // Parameters: 29 | // - ctx: context for cancellation and timeout control 30 | // 31 | // Returns: 32 | // - *tg.Client: authenticated Telegram API client 33 | // - error: authentication error, network error, or timeout 34 | CreateBotClient(ctx context.Context) (*tg.Client, error) 35 | 36 | // Stop stops the API checker 37 | Stop() 38 | 39 | // SetMonitor устанавливает монитор подарков для управления его состоянием 40 | // во время переподключения 41 | SetMonitor(monitor GiftMonitorAndAuthController) 42 | 43 | // SetGlobalCancel устанавливает функцию для остановки всей программы 44 | // при критических ошибках переподключения 45 | SetGlobalCancel(cancel context.CancelFunc) 46 | } 47 | 48 | // SessionManager interface defines the methods for managing Telegram sessions 49 | type SessionManager interface { 50 | // InitUserAPI initializes the Telegram client 51 | // Parameters: 52 | // - client: Telegram client to initialize 53 | // - ctx: context for cancellation and timeout control 54 | // 55 | // Returns: 56 | // - *tg.Client: authenticated Telegram API client 57 | // - error: authentication error, network error, or timeout 58 | InitUserAPI(client *telegram.Client, ctx context.Context) (*tg.Client, error) 59 | 60 | // InitBotAPI creates a new bot client for the Telegram client 61 | // Parameters: 62 | // - ctx: context for cancellation and timeout control 63 | // 64 | // Returns: 65 | // - *tg.Client: authenticated Telegram API client 66 | // - error: authentication error, network error, or timeout 67 | InitBotAPI(ctx context.Context) (*tg.Client, error) 68 | } 69 | 70 | // ApiChecker interface defines the methods for checking the Telegram API 71 | type ApiChecker interface { 72 | // Run checks the Telegram API continuously 73 | // Parameters: 74 | // - ctx: context for cancellation and timeout control 75 | // 76 | // Returns: 77 | // - error: error if the API is not working 78 | Run(ctx context.Context) error 79 | 80 | // // CheckApi checks the Telegram API once 81 | // // Parameters: 82 | // // - ctx: context for cancellation and timeout control 83 | // // 84 | // // Returns: 85 | // // - error: error if the API is not working 86 | // CheckApi(ctx context.Context) error 87 | 88 | // Stop stops the API checker 89 | Stop() 90 | } 91 | 92 | // GiftMonitorManager defines the interface for managing gift monitoring. 93 | // It provides methods to pause, resume, and check the status of the gift monitoring process. 94 | type GiftMonitorAndAuthController interface { 95 | // Pause pauses the gift monitoring process. 96 | Pause() 97 | 98 | // Resume resumes the gift monitoring process. 99 | Resume() 100 | 101 | // IsPaused returns the status of the gift monitoring process. 102 | IsPaused() bool 103 | } 104 | 105 | // InfoLogger defines the interface for logging information. 106 | // It provides methods to log information messages. 107 | type InfoLogger interface { 108 | // LogInfo logs an information message. 109 | // 110 | // Parameters: 111 | // - message: the information message to log 112 | LogInfo(message string) 113 | } 114 | 115 | // ErrorLogger defines the interface for logging errors. 116 | // It provides methods to log errors and formatted errors. 117 | type ErrorLogger interface { 118 | // LogError logs an error message. 119 | // 120 | // Parameters: 121 | // - message: the error message to log 122 | LogError(message string) 123 | 124 | // LogErrorf logs a formatted error message. 125 | // It formats the message using the provided format and arguments. 126 | // 127 | // Parameters: 128 | // - format: the format string for the error message 129 | // - args: arguments to be formatted into the message 130 | LogErrorf(format string, args ...interface{}) 131 | } 132 | -------------------------------------------------------------------------------- /internal/service/giftService/accountManager/manager_test.go: -------------------------------------------------------------------------------- 1 | package accountManager 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/gotd/td/tg" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewAccountManager(t *testing.T) { 12 | userReceiverIDs := []string{"123456789"} 13 | channelReceiverIDs := []string{"987654321"} 14 | userCache := &MockUserCache{} 15 | channelCache := &MockChannelCache{} 16 | 17 | manager := NewAccountManager(nil, userReceiverIDs, channelReceiverIDs, userCache, channelCache) 18 | 19 | assert.NotNil(t, manager) 20 | } 21 | 22 | func TestNewAccountManager_EmptyIDs(t *testing.T) { 23 | userCache := &MockUserCache{} 24 | channelCache := &MockChannelCache{} 25 | 26 | manager := NewAccountManager(nil, []string{}, []string{}, userCache, channelCache) 27 | 28 | assert.NotNil(t, manager) 29 | } 30 | 31 | func TestNewAccountManager_NilCache(t *testing.T) { 32 | userReceiverIDs := []string{"123456789"} 33 | channelReceiverIDs := []string{"987654321"} 34 | 35 | manager := NewAccountManager(nil, userReceiverIDs, channelReceiverIDs, nil, nil) 36 | 37 | assert.NotNil(t, manager) 38 | } 39 | 40 | func TestAccountManager_SetIds_NilAPI(t *testing.T) { 41 | userCache := &MockUserCache{} 42 | channelCache := &MockChannelCache{} 43 | manager := NewAccountManager(nil, []string{"123456789"}, []string{"987654321"}, userCache, channelCache) 44 | 45 | ctx := context.Background() 46 | 47 | // Должен вернуть ошибку без паники при nil API 48 | assert.NotPanics(t, func() { 49 | err := manager.SetIds(ctx) 50 | assert.Error(t, err) 51 | }) 52 | } 53 | 54 | func TestAccountManager_SetIds_EmptyIDs(t *testing.T) { 55 | api := &tg.Client{} 56 | userCache := &MockUserCache{} 57 | channelCache := &MockChannelCache{} 58 | manager := NewAccountManager(api, []string{}, []string{}, userCache, channelCache) 59 | 60 | ctx := context.Background() 61 | err := manager.SetIds(ctx) 62 | 63 | assert.NoError(t, err) // Should succeed with empty IDs 64 | } 65 | 66 | func TestAccountManager_SetIds_ContextCancellation(t *testing.T) { 67 | userCache := &MockUserCache{} 68 | channelCache := &MockChannelCache{} 69 | manager := NewAccountManager(nil, []string{"123456789"}, []string{"987654321"}, userCache, channelCache) 70 | 71 | ctx, cancel := context.WithCancel(context.Background()) 72 | cancel() // Cancel immediately 73 | 74 | err := manager.SetIds(ctx) 75 | 76 | assert.Error(t, err) 77 | // С nil API мы получим ошибку "API client is nil", а не context.Canceled 78 | assert.Contains(t, err.Error(), "API client is nil") 79 | } 80 | 81 | func TestAccountManager_Structure(t *testing.T) { 82 | userReceiverIDs := []string{"123456789", "987654321"} 83 | channelReceiverIDs := []string{"111222333", "444555666"} 84 | userCache := &MockUserCache{} 85 | channelCache := &MockChannelCache{} 86 | api := &tg.Client{} 87 | 88 | manager := NewAccountManager(api, userReceiverIDs, channelReceiverIDs, userCache, channelCache) 89 | 90 | assert.NotNil(t, manager) 91 | } 92 | 93 | func TestAccountManager_InterfaceCompliance(t *testing.T) { 94 | userCache := &MockUserCache{} 95 | channelCache := &MockChannelCache{} 96 | manager := NewAccountManager(nil, []string{}, []string{}, userCache, channelCache) 97 | 98 | // Verify that the manager has the SetIds method 99 | assert.NotNil(t, manager.SetIds) 100 | } 101 | 102 | func TestAccountManager_LoadUsersToCache_NilAPI(t *testing.T) { 103 | userCache := &MockUserCache{} 104 | channelCache := &MockChannelCache{} 105 | manager := NewAccountManager(nil, []string{"123456789"}, []string{}, userCache, channelCache) 106 | 107 | ctx := context.Background() 108 | 109 | // Должен вернуть ошибку без паники при nil API 110 | assert.NotPanics(t, func() { 111 | err := manager.SetIds(ctx) 112 | assert.Error(t, err) 113 | }) 114 | } 115 | 116 | func TestAccountManager_LoadChannelsToCache_NilAPI(t *testing.T) { 117 | userCache := &MockUserCache{} 118 | channelCache := &MockChannelCache{} 119 | manager := NewAccountManager(nil, []string{}, []string{"987654321"}, userCache, channelCache) 120 | 121 | ctx := context.Background() 122 | 123 | // Должен вернуть ошибку без паники при nil API 124 | assert.NotPanics(t, func() { 125 | err := manager.SetIds(ctx) 126 | assert.Error(t, err) 127 | }) 128 | } 129 | 130 | // Mock implementations for testing 131 | 132 | type MockUserCache struct{} 133 | 134 | func (m *MockUserCache) SetUser(key string, user *tg.User) { 135 | // Mock implementation 136 | } 137 | 138 | func (m *MockUserCache) GetUser(id string) (*tg.User, error) { 139 | return nil, assert.AnError 140 | } 141 | 142 | type MockChannelCache struct{} 143 | 144 | func (m *MockChannelCache) SetChannel(key string, channel *tg.Channel) { 145 | // Mock implementation 146 | } 147 | 148 | func (m *MockChannelCache) GetChannel(id string) (*tg.Channel, error) { 149 | return nil, assert.AnError 150 | } 151 | -------------------------------------------------------------------------------- /internal/service/giftService/cache/giftCache/cache.go: -------------------------------------------------------------------------------- 1 | // Package giftCache provides persistent caching functionality for the gift buying system. 2 | // It implements thread-safe caching of gift data with automatic persistence to disk, 3 | // enabling the system to maintain state across restarts and avoid redundant processing. 4 | package giftCache 5 | 6 | import ( 7 | "gift-buyer/internal/service/giftService/giftInterfaces" 8 | "sync" 9 | "time" 10 | 11 | "github.com/gotd/td/tg" 12 | ) 13 | 14 | // GiftCacheImpl implements the GiftCache interface for thread-safe gift caching. 15 | // It provides in-memory storage with automatic periodic persistence to disk, 16 | // ensuring data durability and fast access to cached gift information. 17 | type GiftCacheImpl struct { 18 | // cache stores the in-memory gift data indexed by gift ID 19 | cache map[int64]*tg.StarGift 20 | 21 | // stopCh signals the periodic save goroutine to stop 22 | stopCh chan struct{} 23 | 24 | // interval defines how often the cache is persisted to disk 25 | interval time.Duration 26 | 27 | // mu provides thread-safe access to the cache map 28 | mu sync.RWMutex 29 | } 30 | 31 | // NewGiftCache creates a new GiftCache instance with automatic persistence. 32 | // It initializes the cache, loads existing data from disk, and starts 33 | // a background goroutine for periodic saving. 34 | // 35 | // The cache automatically: 36 | // - Loads existing data from cache.json on startup 37 | // - Saves new data to disk every 5 seconds 38 | // - Provides thread-safe concurrent access 39 | // 40 | // Returns: 41 | // - giftInterfaces.GiftCache: configured and initialized gift cache instance 42 | func NewGiftCache() giftInterfaces.GiftCache { 43 | gc := &GiftCacheImpl{ 44 | cache: make(map[int64]*tg.StarGift), 45 | stopCh: make(chan struct{}), 46 | interval: 5 * time.Second, 47 | } 48 | 49 | gc.loadFromFile() 50 | 51 | go gc.startPeriodicSave() 52 | 53 | return gc 54 | } 55 | 56 | // startPeriodicSave runs a background goroutine that periodically saves the cache to disk. 57 | // It saves the cache at the configured interval and performs a final save when stopped. 58 | // This goroutine runs until the stopCh channel is closed. 59 | func (gc *GiftCacheImpl) startPeriodicSave() { 60 | ticker := time.NewTicker(gc.interval) 61 | defer ticker.Stop() 62 | 63 | for { 64 | select { 65 | case <-ticker.C: 66 | gc.saveToFile() 67 | case <-gc.stopCh: 68 | gc.saveToFile() 69 | return 70 | } 71 | } 72 | } 73 | 74 | // SetGift stores a gift in the cache with the specified ID as the key. 75 | // This operation is thread-safe and will trigger persistence on the next save cycle. 76 | // 77 | // Parameters: 78 | // - id: unique identifier for the gift (typically gift.ID) 79 | // - gift: the star gift object to cache 80 | func (gc *GiftCacheImpl) SetGift(id int64, gift *tg.StarGift) { 81 | gc.mu.Lock() 82 | defer gc.mu.Unlock() 83 | 84 | gc.cache[id] = gift 85 | } 86 | 87 | // GetGift retrieves a cached gift by its ID. 88 | // This operation is thread-safe and uses a read lock for optimal performance. 89 | // 90 | // Parameters: 91 | // - id: unique identifier of the gift to retrieve 92 | // 93 | // Returns: 94 | // - *tg.StarGift: the cached gift object, nil if not found 95 | // - error: always nil in current implementation 96 | func (gc *GiftCacheImpl) GetGift(id int64) (*tg.StarGift, error) { 97 | gc.mu.RLock() 98 | defer gc.mu.RUnlock() 99 | 100 | gift, exists := gc.cache[id] 101 | if !exists { 102 | return nil, nil 103 | } 104 | return gift, nil 105 | } 106 | 107 | // GetAllGifts returns a copy of all cached gifts. 108 | // This operation is thread-safe and returns a new map to prevent external modification. 109 | // 110 | // Returns: 111 | // - map[int64]*tg.StarGift: map of gift IDs to gift objects (copy of internal cache) 112 | func (gc *GiftCacheImpl) GetAllGifts() map[int64]*tg.StarGift { 113 | gc.mu.RLock() 114 | defer gc.mu.RUnlock() 115 | 116 | result := make(map[int64]*tg.StarGift, len(gc.cache)) 117 | for id, gift := range gc.cache { 118 | result[id] = gift 119 | } 120 | return result 121 | } 122 | 123 | // HasGift checks if a gift with the specified ID exists in the cache. 124 | // This operation is thread-safe and uses a read lock for optimal performance. 125 | // 126 | // Parameters: 127 | // - id: unique identifier of the gift to check 128 | // 129 | // Returns: 130 | // - bool: true if the gift exists in cache, false otherwise 131 | func (gc *GiftCacheImpl) HasGift(id int64) bool { 132 | gc.mu.RLock() 133 | defer gc.mu.RUnlock() 134 | 135 | _, exists := gc.cache[id] 136 | return exists 137 | } 138 | 139 | // DeleteGift removes a gift from the cache. 140 | // This operation is thread-safe and will be reflected in the next save cycle. 141 | // 142 | // Parameters: 143 | // - id: unique identifier of the gift to remove 144 | func (gc *GiftCacheImpl) DeleteGift(id int64) { 145 | gc.mu.Lock() 146 | defer gc.mu.Unlock() 147 | 148 | delete(gc.cache, id) 149 | } 150 | 151 | // Clear removes all gifts from the cache. 152 | // This operation is thread-safe and creates a new empty cache map. 153 | func (gc *GiftCacheImpl) Clear() { 154 | gc.mu.Lock() 155 | defer gc.mu.Unlock() 156 | 157 | gc.cache = make(map[int64]*tg.StarGift) 158 | } 159 | -------------------------------------------------------------------------------- /internal/service/giftService/cache/idCache/cache_test.go: -------------------------------------------------------------------------------- 1 | package idCache 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gotd/td/tg" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewIDCache(t *testing.T) { 11 | cache := NewIDCache() 12 | 13 | assert.NotNil(t, cache) 14 | // Проверяем методы интерфейса 15 | assert.NotNil(t, cache.SetUser) 16 | assert.NotNil(t, cache.GetUser) 17 | assert.NotNil(t, cache.SetChannel) 18 | assert.NotNil(t, cache.GetChannel) 19 | } 20 | 21 | func TestIDCacheImpl_SetAndGetUser(t *testing.T) { 22 | cache := NewIDCache() 23 | 24 | user := &tg.User{ 25 | ID: 123456789, 26 | FirstName: "Test", 27 | LastName: "User", 28 | Username: "testuser", 29 | } 30 | 31 | // Set user 32 | cache.SetUser("testuser", user) 33 | 34 | // Get user 35 | retrievedUser, err := cache.GetUser("testuser") 36 | assert.NoError(t, err) 37 | assert.NotNil(t, retrievedUser) 38 | assert.Equal(t, user.ID, retrievedUser.ID) 39 | assert.Equal(t, user.FirstName, retrievedUser.FirstName) 40 | assert.Equal(t, user.LastName, retrievedUser.LastName) 41 | assert.Equal(t, user.Username, retrievedUser.Username) 42 | } 43 | 44 | func TestIDCacheImpl_GetNonExistentUser(t *testing.T) { 45 | cache := NewIDCache() 46 | 47 | user, err := cache.GetUser("nonexistentuser") 48 | assert.Error(t, err) 49 | assert.Nil(t, user) 50 | assert.Contains(t, err.Error(), "user not found") 51 | } 52 | 53 | func TestIDCacheImpl_SetAndGetChannel(t *testing.T) { 54 | cache := NewIDCache() 55 | 56 | channel := &tg.Channel{ 57 | ID: 987654321, 58 | Title: "Test Channel", 59 | Username: "testchannel", 60 | } 61 | 62 | // Set channel 63 | cache.SetChannel("testchannel", channel) 64 | 65 | // Get channel by username 66 | retrievedChannel, err := cache.GetChannel("testchannel") 67 | assert.NoError(t, err) 68 | assert.NotNil(t, retrievedChannel) 69 | assert.Equal(t, channel.ID, retrievedChannel.ID) 70 | assert.Equal(t, channel.Title, retrievedChannel.Title) 71 | assert.Equal(t, channel.Username, retrievedChannel.Username) 72 | } 73 | 74 | func TestIDCacheImpl_GetNonExistentChannel(t *testing.T) { 75 | cache := NewIDCache() 76 | 77 | channel, err := cache.GetChannel("nonexistentchannel") 78 | assert.Error(t, err) 79 | assert.Nil(t, channel) 80 | assert.Contains(t, err.Error(), "channel not found") 81 | } 82 | 83 | func TestIDCacheImpl_SetNilUser(t *testing.T) { 84 | cache := NewIDCache() 85 | 86 | // Should not panic with nil user 87 | assert.NotPanics(t, func() { 88 | cache.SetUser("testkey", nil) 89 | }) 90 | } 91 | 92 | func TestIDCacheImpl_SetNilChannel(t *testing.T) { 93 | cache := NewIDCache() 94 | 95 | // Should not panic with nil channel 96 | assert.NotPanics(t, func() { 97 | cache.SetChannel("testkey", nil) 98 | }) 99 | } 100 | 101 | func TestIDCacheImpl_OverwriteUser(t *testing.T) { 102 | cache := NewIDCache() 103 | 104 | user1 := &tg.User{ 105 | ID: 123456789, 106 | FirstName: "First", 107 | LastName: "User", 108 | Username: "testuser", 109 | } 110 | 111 | user2 := &tg.User{ 112 | ID: 123456789, 113 | FirstName: "Second", 114 | LastName: "User", 115 | Username: "testuser", 116 | } 117 | 118 | // Set first user 119 | cache.SetUser("testuser", user1) 120 | 121 | // Overwrite with second user 122 | cache.SetUser("testuser", user2) 123 | 124 | // Should get the second user 125 | retrievedUser, err := cache.GetUser("testuser") 126 | assert.NoError(t, err) 127 | assert.Equal(t, "Second", retrievedUser.FirstName) 128 | } 129 | 130 | func TestIDCacheImpl_OverwriteChannel(t *testing.T) { 131 | cache := NewIDCache() 132 | 133 | channel1 := &tg.Channel{ 134 | ID: 987654321, 135 | Title: "First Channel", 136 | Username: "testchannel", 137 | } 138 | 139 | channel2 := &tg.Channel{ 140 | ID: 987654321, 141 | Title: "Second Channel", 142 | Username: "testchannel", 143 | } 144 | 145 | // Set first channel 146 | cache.SetChannel("testchannel", channel1) 147 | 148 | // Overwrite with second channel 149 | cache.SetChannel("testchannel", channel2) 150 | 151 | // Should get the second channel 152 | retrievedChannel, err := cache.GetChannel("testchannel") 153 | assert.NoError(t, err) 154 | assert.Equal(t, "Second Channel", retrievedChannel.Title) 155 | } 156 | 157 | func TestIDCacheImpl_MultipleUsers(t *testing.T) { 158 | cache := NewIDCache() 159 | 160 | users := []*tg.User{ 161 | {ID: 111, FirstName: "User1", Username: "user1"}, 162 | {ID: 222, FirstName: "User2", Username: "user2"}, 163 | {ID: 333, FirstName: "User3", Username: "user3"}, 164 | } 165 | 166 | // Set multiple users 167 | for _, user := range users { 168 | cache.SetUser(user.Username, user) 169 | } 170 | 171 | // Get all users 172 | for _, expectedUser := range users { 173 | retrievedUser, err := cache.GetUser(expectedUser.Username) 174 | assert.NoError(t, err) 175 | assert.Equal(t, expectedUser.FirstName, retrievedUser.FirstName) 176 | } 177 | } 178 | 179 | func TestIDCacheImpl_MultipleChannels(t *testing.T) { 180 | cache := NewIDCache() 181 | 182 | channels := []*tg.Channel{ 183 | {ID: 111, Title: "Channel1", Username: "channel1"}, 184 | {ID: 222, Title: "Channel2", Username: "channel2"}, 185 | {ID: 333, Title: "Channel3", Username: "channel3"}, 186 | } 187 | 188 | // Set multiple channels 189 | for _, channel := range channels { 190 | cache.SetChannel(channel.Username, channel) 191 | } 192 | 193 | // Get all channels 194 | for _, expectedChannel := range channels { 195 | retrievedChannel, err := cache.GetChannel(expectedChannel.Username) 196 | assert.NoError(t, err) 197 | assert.Equal(t, expectedChannel.Title, retrievedChannel.Title) 198 | } 199 | } 200 | 201 | func TestIDCacheImpl_InterfaceCompliance(t *testing.T) { 202 | cache := NewIDCache() 203 | 204 | // Verify that the cache has all required methods 205 | assert.NotNil(t, cache.SetUser) 206 | assert.NotNil(t, cache.GetUser) 207 | assert.NotNil(t, cache.SetChannel) 208 | assert.NotNil(t, cache.GetChannel) 209 | } 210 | -------------------------------------------------------------------------------- /internal/service/authService/sessions/sessions.go: -------------------------------------------------------------------------------- 1 | package sessions 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "gift-buyer/internal/config" 8 | "gift-buyer/pkg/logger" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/gotd/td/telegram" 14 | "github.com/gotd/td/telegram/auth" 15 | "github.com/gotd/td/tg" 16 | ) 17 | 18 | type sessionManagerImpl struct { 19 | cfg *config.TgSettings 20 | } 21 | 22 | func NewSessionManager(cfg *config.TgSettings) *sessionManagerImpl { 23 | return &sessionManagerImpl{ 24 | cfg: cfg, 25 | } 26 | } 27 | 28 | // initClient initializes and authenticates the main Telegram user client. 29 | // It handles the complete authentication flow including 2FA, session management, 30 | // and interactive code input when required. 31 | // 32 | // The authentication process: 33 | // 1. Checks for existing valid session 34 | // 2. Initiates authentication flow if needed 35 | // 3. Handles phone number and password authentication 36 | // 4. Prompts for verification code interactively 37 | // 5. Manages session persistence and recovery 38 | // 39 | // Parameters: 40 | // - client: configured Telegram client instance 41 | // - ctx: context for cancellation and timeout control 42 | // 43 | // Returns: 44 | // - *tg.Client: authenticated Telegram API client 45 | // - error: authentication error, network error, or timeout 46 | func (f *sessionManagerImpl) InitUserAPI(client *telegram.Client, ctx context.Context) (*tg.Client, error) { 47 | authDone := make(chan *tg.Client, 1) 48 | errCh := make(chan error, 1) 49 | 50 | go func() { 51 | err := client.Run(ctx, func(ctx context.Context) error { 52 | status, err := client.Auth().Status(ctx) 53 | if err == nil && status.Authorized { 54 | logger.GlobalLogger.Info("Already authorized, using existing session") 55 | authDone <- client.API() 56 | <-ctx.Done() 57 | return ctx.Err() 58 | } 59 | 60 | logger.GlobalLogger.Info("Starting Telegram authentication...") 61 | // codePrompt provides interactive code input for 2FA verification 62 | codePrompt := func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { 63 | fmt.Print("Enter code: ") 64 | code, err := bufio.NewReader(os.Stdin).ReadString('\n') 65 | if err != nil { 66 | return "", err 67 | } 68 | return strings.TrimSpace(code), nil 69 | } 70 | 71 | err = auth.NewFlow( 72 | auth.Constant(f.cfg.Phone, f.cfg.Password, auth.CodeAuthenticatorFunc(codePrompt)), 73 | auth.SendCodeOptions{}, 74 | ).Run(ctx, client.Auth()) 75 | if err != nil { 76 | logger.GlobalLogger.Errorf("Authentication failed: %v", err) 77 | if strings.Contains(err.Error(), "AUTH_RESTART") { 78 | logger.GlobalLogger.Warn("AUTH_RESTART received, clearing session file") 79 | if removeErr := os.Remove("session.json"); removeErr != nil { 80 | logger.GlobalLogger.Warnf("Failed to remove session file: %v", removeErr) 81 | } 82 | } 83 | return err 84 | } 85 | 86 | logger.GlobalLogger.Info("Authentication successful!") 87 | authDone <- client.API() 88 | <-ctx.Done() 89 | return ctx.Err() 90 | }) 91 | if err != nil { 92 | errCh <- err 93 | } 94 | }() 95 | 96 | select { 97 | case api := <-authDone: 98 | logger.GlobalLogger.Info("Ready to start gift service") 99 | return api, nil 100 | case err := <-errCh: 101 | return nil, fmt.Errorf("telegram client initialization failed: %w", err) 102 | case <-ctx.Done(): 103 | return nil, fmt.Errorf("context cancelled during authentication") 104 | case <-time.After(560 * time.Second): 105 | return nil, fmt.Errorf("authentication timeout") 106 | } 107 | } 108 | 109 | // createBotClient creates and authenticates a Telegram bot client for notifications. 110 | // It initializes a separate bot session for sending notifications and status updates 111 | // to the configured chat, independent of the main user client. 112 | // 113 | // The bot authentication process: 114 | // 1. Creates bot client with separate session storage 115 | // 2. Authenticates using the bot token 116 | // 3. Verifies bot permissions and access 117 | // 4. Returns ready-to-use bot API client 118 | // 119 | // Parameters: 120 | // - ctx: context for cancellation and timeout control 121 | // 122 | // Returns: 123 | // - *tg.Client: authenticated bot API client for notifications 124 | // - error: bot authentication error, invalid token, or network error 125 | func (f *sessionManagerImpl) InitBotAPI(ctx context.Context) (*tg.Client, error) { 126 | if f.cfg.TgBotKey == "" { 127 | return nil, fmt.Errorf("bot token is not configured") 128 | } 129 | 130 | opts := telegram.Options{ 131 | SessionStorage: &telegram.FileSessionStorage{ 132 | Path: "bot_session.json", 133 | }, 134 | } 135 | 136 | // Set datacenter if specified 137 | if f.cfg.Datacenter > 0 { 138 | opts.DC = f.cfg.Datacenter 139 | } 140 | 141 | botClient := telegram.NewClient(f.cfg.AppId, f.cfg.ApiHash, opts) 142 | 143 | botAPI := make(chan *tg.Client, 1) 144 | errCh := make(chan error, 1) 145 | 146 | go func() { 147 | err := botClient.Run(ctx, func(ctx context.Context) error { 148 | _, err := botClient.Auth().Bot(ctx, f.cfg.TgBotKey) 149 | if err != nil { 150 | logger.GlobalLogger.Errorf("Bot authentication failed: %v", err) 151 | return err 152 | } 153 | 154 | logger.GlobalLogger.Info("Bot authenticated successfully!") 155 | botAPI <- botClient.API() 156 | <-ctx.Done() 157 | return ctx.Err() 158 | }) 159 | if err != nil { 160 | logger.GlobalLogger.Errorf("Bot client error: %v", err) 161 | errCh <- err 162 | } 163 | }() 164 | 165 | select { 166 | case api := <-botAPI: 167 | logger.GlobalLogger.Info("Bot ready for notifications") 168 | return api, nil 169 | case err := <-errCh: 170 | return nil, fmt.Errorf("bot client initialization failed: %w", err) 171 | case <-ctx.Done(): 172 | return nil, fmt.Errorf("context cancelled during bot authentication") 173 | case <-time.After(30 * time.Second): 174 | return nil, fmt.Errorf("bot authentication timeout") 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /internal/service/giftService/giftValidator/validator_test.go: -------------------------------------------------------------------------------- 1 | package giftValidator 2 | 3 | import ( 4 | "gift-buyer/internal/config" 5 | "testing" 6 | 7 | "github.com/gotd/td/tg" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewGiftValidator(t *testing.T) { 12 | criterias := []config.Criterias{ 13 | {MinPrice: 100, MaxPrice: 1000, TotalSupply: 50, Count: 5}, 14 | } 15 | giftParam := config.GiftParam{ 16 | TotalStarCap: 10000, 17 | TestMode: false, 18 | LimitedStatus: true, 19 | ReleaseBy: false, 20 | } 21 | 22 | validator := NewGiftValidator(criterias, giftParam) 23 | 24 | assert.NotNil(t, validator) 25 | assert.Equal(t, criterias, validator.criteria) 26 | assert.Equal(t, giftParam.TotalStarCap, validator.totalStarCap) 27 | assert.Equal(t, giftParam.TestMode, validator.testMode) 28 | assert.Equal(t, giftParam.LimitedStatus, validator.limitedStatus) 29 | assert.Equal(t, giftParam.ReleaseBy, validator.releaseBy) 30 | } 31 | 32 | func TestGiftValidator_IsEligible_SoldOut(t *testing.T) { 33 | criterias := []config.Criterias{ 34 | {MinPrice: 100, MaxPrice: 1000, TotalSupply: 50, Count: 5}, 35 | } 36 | giftParam := config.GiftParam{ 37 | TotalStarCap: 10000, 38 | TestMode: false, 39 | LimitedStatus: true, 40 | ReleaseBy: false, 41 | } 42 | 43 | validator := NewGiftValidator(criterias, giftParam) 44 | 45 | gift := &tg.StarGift{ 46 | ID: 1, 47 | Stars: 500, 48 | SoldOut: true, 49 | Limited: true, 50 | } 51 | 52 | result, eligible := validator.IsEligible(gift) 53 | assert.False(t, eligible) 54 | assert.Nil(t, result) 55 | } 56 | 57 | func TestGiftValidator_IsEligible_LimitedStatusMismatch(t *testing.T) { 58 | criterias := []config.Criterias{ 59 | {MinPrice: 100, MaxPrice: 1000, TotalSupply: 50, Count: 5}, 60 | } 61 | giftParam := config.GiftParam{ 62 | TotalStarCap: 10000, 63 | TestMode: false, 64 | LimitedStatus: true, 65 | ReleaseBy: false, 66 | } 67 | 68 | validator := NewGiftValidator(criterias, giftParam) 69 | 70 | gift := &tg.StarGift{ 71 | ID: 1, 72 | Stars: 500, 73 | SoldOut: false, 74 | Limited: false, // Mismatch with LimitedStatus: true 75 | } 76 | 77 | result, eligible := validator.IsEligible(gift) 78 | assert.False(t, eligible) 79 | assert.Nil(t, result) 80 | } 81 | 82 | func TestGiftValidator_IsEligible_ValidGift(t *testing.T) { 83 | criterias := []config.Criterias{ 84 | {MinPrice: 100, MaxPrice: 1000, TotalSupply: 50, Count: 5, ReceiverType: []int{1}}, 85 | } 86 | giftParam := config.GiftParam{ 87 | TotalStarCap: 10000, 88 | TestMode: true, // Enable test mode to bypass validations 89 | LimitedStatus: true, 90 | ReleaseBy: false, 91 | } 92 | 93 | validator := NewGiftValidator(criterias, giftParam) 94 | 95 | gift := &tg.StarGift{ 96 | ID: 1, 97 | Stars: 500, 98 | SoldOut: false, 99 | Limited: true, 100 | } 101 | 102 | result, eligible := validator.IsEligible(gift) 103 | assert.True(t, eligible) 104 | assert.NotNil(t, result) 105 | assert.Equal(t, int64(5), result.CountForBuy) 106 | assert.Equal(t, []int{1}, result.ReceiverType) 107 | } 108 | 109 | func TestGiftValidator_IsEligible_TestMode(t *testing.T) { 110 | criterias := []config.Criterias{ 111 | {MinPrice: 100, MaxPrice: 1000, TotalSupply: 50, Count: 5, ReceiverType: []int{1}}, 112 | } 113 | giftParam := config.GiftParam{ 114 | TotalStarCap: 10000, 115 | TestMode: true, // Test mode enabled 116 | LimitedStatus: true, 117 | ReleaseBy: false, 118 | } 119 | 120 | validator := NewGiftValidator(criterias, giftParam) 121 | 122 | gift := &tg.StarGift{ 123 | ID: 1, 124 | Stars: 500, 125 | SoldOut: false, 126 | Limited: true, 127 | } 128 | 129 | result, eligible := validator.IsEligible(gift) 130 | assert.True(t, eligible) 131 | assert.NotNil(t, result) 132 | } 133 | 134 | func TestGiftValidator_PriceValid(t *testing.T) { 135 | giftParam := config.GiftParam{ 136 | TotalStarCap: 10000, 137 | TestMode: false, 138 | LimitedStatus: true, 139 | ReleaseBy: false, 140 | } 141 | 142 | validator := NewGiftValidator([]config.Criterias{}, giftParam) 143 | 144 | criteria := config.Criterias{MinPrice: 100, MaxPrice: 1000} 145 | 146 | // Valid price 147 | gift := &tg.StarGift{Stars: 500} 148 | assert.True(t, validator.priceValid(criteria, gift)) 149 | 150 | // Price too low 151 | gift = &tg.StarGift{Stars: 50} 152 | assert.False(t, validator.priceValid(criteria, gift)) 153 | 154 | // Price too high 155 | gift = &tg.StarGift{Stars: 1500} 156 | assert.False(t, validator.priceValid(criteria, gift)) 157 | 158 | // Edge cases 159 | gift = &tg.StarGift{Stars: 100} // Min price 160 | assert.True(t, validator.priceValid(criteria, gift)) 161 | 162 | gift = &tg.StarGift{Stars: 1000} // Max price 163 | assert.True(t, validator.priceValid(criteria, gift)) 164 | } 165 | 166 | func TestGiftValidator_SupplyValid_TestMode(t *testing.T) { 167 | giftParam := config.GiftParam{ 168 | TotalStarCap: 10000, 169 | TestMode: true, // Test mode bypasses supply validation 170 | LimitedStatus: true, 171 | ReleaseBy: false, 172 | } 173 | 174 | validator := NewGiftValidator([]config.Criterias{}, giftParam) 175 | 176 | criteria := config.Criterias{TotalSupply: 50} 177 | gift := &tg.StarGift{Limited: true} 178 | 179 | // In test mode, supply validation should always pass 180 | assert.True(t, validator.supplyValid(criteria, gift)) 181 | } 182 | 183 | func TestGiftValidator_StarCapValidation_TestMode(t *testing.T) { 184 | giftParam := config.GiftParam{ 185 | TotalStarCap: 1000, 186 | TestMode: true, // Test mode bypasses star cap validation 187 | LimitedStatus: true, 188 | ReleaseBy: false, 189 | } 190 | 191 | validator := NewGiftValidator([]config.Criterias{}, giftParam) 192 | 193 | // Gift that would exceed star cap 194 | gift := &tg.StarGift{ 195 | Stars: 500, 196 | Limited: true, 197 | Flags: 0, 198 | } 199 | 200 | // In test mode, star cap validation should always pass 201 | assert.True(t, validator.starCapValidation(gift)) 202 | } 203 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/invoiceCreator/invoiceCreator.go: -------------------------------------------------------------------------------- 1 | package invoiceCreator 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gift-buyer/internal/service/giftService/giftInterfaces" 7 | "gift-buyer/internal/service/giftService/giftTypes" 8 | "gift-buyer/pkg/errors" 9 | "gift-buyer/pkg/utils" 10 | "time" 11 | 12 | "github.com/google/uuid" 13 | "github.com/gotd/td/tg" 14 | ) 15 | 16 | type InvoiceCreatorImpl struct { 17 | userReceiver, channelReceiver []string 18 | idCache giftInterfaces.UserCache 19 | } 20 | 21 | func NewInvoiceCreator(userReceiver, channelReceiver []string, idCache giftInterfaces.UserCache) *InvoiceCreatorImpl { 22 | return &InvoiceCreatorImpl{ 23 | userReceiver: userReceiver, 24 | channelReceiver: channelReceiver, 25 | idCache: idCache, 26 | } 27 | } 28 | 29 | // createInvoice creates a Telegram invoice for the specified gift. 30 | // It configures the invoice based on the receiver type (self, user, or channel) 31 | // and includes appropriate peer information and gift details. 32 | // 33 | // Supported receiver types: 34 | // - 0: Self (current user) 35 | // - 1: User (specified by user ID) 36 | // - 2: Channel (specified by channel ID with access hash) 37 | // 38 | // Parameters: 39 | // - gift: the star gift to create an invoice for 40 | // 41 | // Returns: 42 | // - *tg.InputInvoiceStarGift: configured invoice for the gift purchase 43 | // - error: invoice creation error or unsupported receiver type 44 | func (ic *InvoiceCreatorImpl) CreateInvoice(gift *giftTypes.GiftRequire) (*tg.InputInvoiceStarGift, error) { 45 | randReceiverType := utils.SelectRandomElementFast(gift.ReceiverType) 46 | 47 | switch randReceiverType { 48 | case 0: 49 | return ic.selfPurchase(gift) 50 | case 1: 51 | return ic.userPurchase(gift) 52 | case 2: 53 | return ic.channelPurchase(gift) 54 | default: 55 | return nil, errors.Wrap(errors.New("unexpected receiver type"), 56 | fmt.Sprintf("unexpected receiver type: %d", randReceiverType)) 57 | } 58 | } 59 | 60 | func (ic *InvoiceCreatorImpl) selfPurchase(gift *giftTypes.GiftRequire) (*tg.InputInvoiceStarGift, error) { 61 | invoice := &tg.InputInvoiceStarGift{ 62 | Peer: &tg.InputPeerSelf{}, 63 | GiftID: gift.Gift.ID, 64 | HideName: gift.Hide, 65 | Message: tg.TextWithEntities{ 66 | Text: fmt.Sprintf("By @cheifssq %s_%d_%s", utils.RandString5(10), time.Now().UnixNano(), uuid.New().String()[:6]), 67 | }, 68 | } 69 | return invoice, nil 70 | } 71 | 72 | func (ic *InvoiceCreatorImpl) userPurchase(gift *giftTypes.GiftRequire) (*tg.InputInvoiceStarGift, error) { 73 | userInfo, err := ic.getUserInfo(context.Background(), utils.SelectRandomElementFast(ic.userReceiver)) 74 | if err != nil { 75 | return nil, errors.Wrap(err, "cannot create invoice without user access hash") 76 | } 77 | 78 | invoice := &tg.InputInvoiceStarGift{ 79 | Peer: &tg.InputPeerUser{UserID: userInfo.ID, AccessHash: userInfo.AccessHash}, 80 | GiftID: gift.Gift.ID, 81 | HideName: gift.Hide, 82 | Message: tg.TextWithEntities{ 83 | Text: fmt.Sprintf("By @cheifssq %s_%d_%s", utils.RandString5(10), time.Now().UnixNano(), uuid.New().String()[:6]), 84 | }, 85 | } 86 | return invoice, nil 87 | } 88 | 89 | func (ic *InvoiceCreatorImpl) channelPurchase(gift *giftTypes.GiftRequire) (*tg.InputInvoiceStarGift, error) { 90 | channelInfo, err := ic.getChannelInfo(context.Background(), utils.SelectRandomElementFast(ic.channelReceiver)) 91 | if err != nil { 92 | return nil, errors.Wrap(err, "cannot create invoice without channel access hash") 93 | } 94 | 95 | invoice := &tg.InputInvoiceStarGift{ 96 | Peer: &tg.InputPeerChannel{ 97 | ChannelID: ic.convertChannelID(channelInfo.ID), 98 | AccessHash: channelInfo.AccessHash, 99 | }, 100 | GiftID: gift.Gift.ID, 101 | HideName: gift.Hide, 102 | Message: tg.TextWithEntities{ 103 | Text: fmt.Sprintf("By @cheifssq %s_%d", utils.RandString5(10), time.Now().UnixNano()), 104 | }, 105 | } 106 | return invoice, nil 107 | } 108 | 109 | // getChannelInfo retrieves channel information including access hash for invoice creation. 110 | // It handles channel ID conversion and fetches the channel details required for 111 | // creating invoices for channel recipients. 112 | // 113 | // Parameters: 114 | // - ctx: context for request cancellation and timeout control 115 | // - channelID: the channel ID (may be in supergroup format) 116 | // 117 | // Returns: 118 | // - *tg.Channel: channel information with access hash 119 | // - error: channel retrieval error or API communication failure 120 | func (ic *InvoiceCreatorImpl) getChannelInfo(ctx context.Context, channelID string) (*tg.Channel, error) { 121 | channel, err := ic.idCache.GetChannel(channelID) 122 | if err == nil { 123 | return channel, nil 124 | } 125 | 126 | return nil, errors.New("channel not found") 127 | } 128 | 129 | // getUserInfo retrieves user information including access hash for invoice creation. 130 | // It tries multiple methods to get user info without requiring contacts: 131 | // 1. Direct UsersGetUsers call 132 | // 2. Search through recent dialogs with larger limit 133 | // 3. Try to get user through common groups/channels 134 | // 4. Search for messages from user 135 | // 5. Try to resolve by username if available 136 | // 6. Search through all chats and channels 137 | // 138 | // Parameters: 139 | // - ctx: context for request cancellation and timeout control 140 | // - userID: the user ID 141 | // 142 | // Returns: 143 | // - *tg.User: user information with access hash 144 | // - error: user retrieval error or API communication failure 145 | func (ic *InvoiceCreatorImpl) getUserInfo(ctx context.Context, userID string) (*tg.User, error) { 146 | user, err := ic.idCache.GetUser(userID) 147 | if err == nil { 148 | return user, nil 149 | } 150 | 151 | return nil, errors.New(fmt.Sprintf("user %s not accessible: session hasn't met this user. See logs for solutions.", userID)) 152 | } 153 | 154 | func (ic *InvoiceCreatorImpl) convertChannelID(channelID int64) int64 { 155 | var realChannelID int64 156 | if channelID < -1000000000000 { 157 | realChannelID = -channelID - 1000000000000 158 | } else { 159 | realChannelID = channelID 160 | } 161 | return realChannelID 162 | } 163 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/paymentProcessor/paymentProcessor_test.go: -------------------------------------------------------------------------------- 1 | package paymentProcessor 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "gift-buyer/internal/service/giftService/giftInterfaces" 9 | "gift-buyer/internal/service/giftService/giftTypes" 10 | 11 | "github.com/gotd/td/tg" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/mock" 14 | ) 15 | 16 | // Mock для RateLimiter 17 | type MockRateLimiter struct { 18 | mock.Mock 19 | } 20 | 21 | func (m *MockRateLimiter) Acquire(ctx context.Context) error { 22 | args := m.Called(ctx) 23 | return args.Error(0) 24 | } 25 | 26 | func (m *MockRateLimiter) Close() { 27 | m.Called() 28 | } 29 | 30 | // Mock для InvoiceCreator 31 | type MockInvoiceCreator struct { 32 | mock.Mock 33 | } 34 | 35 | func (m *MockInvoiceCreator) CreateInvoice(gift *giftTypes.GiftRequire) (*tg.InputInvoiceStarGift, error) { 36 | args := m.Called(gift) 37 | if args.Get(0) == nil { 38 | return nil, args.Error(1) 39 | } 40 | return args.Get(0).(*tg.InputInvoiceStarGift), args.Error(1) 41 | } 42 | 43 | // Helper functions 44 | func createTestGift(id int64, stars int64) *tg.StarGift { 45 | return &tg.StarGift{ 46 | ID: id, 47 | Stars: stars, 48 | } 49 | } 50 | 51 | func createTestGiftRequire(gift *tg.StarGift) *giftTypes.GiftRequire { 52 | return &giftTypes.GiftRequire{ 53 | Gift: gift, 54 | ReceiverType: []int{0}, 55 | CountForBuy: 1, 56 | Hide: true, 57 | } 58 | } 59 | 60 | func createTestInvoice(giftID int64) *tg.InputInvoiceStarGift { 61 | return &tg.InputInvoiceStarGift{ 62 | Peer: &tg.InputPeerSelf{}, 63 | GiftID: giftID, 64 | HideName: true, 65 | Message: tg.TextWithEntities{ 66 | Text: "test message", 67 | }, 68 | } 69 | } 70 | 71 | func TestNewPaymentProcessor(t *testing.T) { 72 | t.Run("создание нового PaymentProcessor", func(t *testing.T) { 73 | mockInvoiceCreator := &MockInvoiceCreator{} 74 | mockRateLimiter := &MockRateLimiter{} 75 | 76 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 77 | 78 | assert.NotNil(t, processor) 79 | // Тестируем что создан правильный тип 80 | var _ giftInterfaces.PaymentProcessor = processor 81 | }) 82 | } 83 | 84 | func TestPaymentProcessorImpl_InvoiceCreation_Success(t *testing.T) { 85 | t.Run("успешное создание инвойса", func(t *testing.T) { 86 | mockInvoiceCreator := &MockInvoiceCreator{} 87 | mockRateLimiter := &MockRateLimiter{} 88 | 89 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 90 | 91 | gift := createTestGift(1, 100) 92 | giftRequire := createTestGiftRequire(gift) 93 | invoice := createTestInvoice(1) 94 | 95 | // Настраиваем моки 96 | mockInvoiceCreator.On("CreateInvoice", giftRequire).Return(invoice, nil) 97 | 98 | // Тестируем только создание инвойса 99 | createdInvoice, err := processor.invoiceCreator.CreateInvoice(giftRequire) 100 | assert.NoError(t, err) 101 | assert.Equal(t, invoice, createdInvoice) 102 | 103 | mockInvoiceCreator.AssertExpectations(t) 104 | }) 105 | } 106 | 107 | func TestPaymentProcessorImpl_RateLimiter_Success(t *testing.T) { 108 | t.Run("успешное получение токена rate limiter", func(t *testing.T) { 109 | mockInvoiceCreator := &MockInvoiceCreator{} 110 | mockRateLimiter := &MockRateLimiter{} 111 | 112 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 113 | 114 | ctx := context.Background() 115 | 116 | // Настраиваем мок 117 | mockRateLimiter.On("Acquire", ctx).Return(nil) 118 | 119 | // Тестируем rate limiting 120 | err := processor.rateLimiter.Acquire(ctx) 121 | assert.NoError(t, err) 122 | 123 | mockRateLimiter.AssertExpectations(t) 124 | }) 125 | } 126 | 127 | func TestPaymentProcessorImpl_RateLimiter_Error(t *testing.T) { 128 | t.Run("ошибка rate limiter", func(t *testing.T) { 129 | mockInvoiceCreator := &MockInvoiceCreator{} 130 | mockRateLimiter := &MockRateLimiter{} 131 | 132 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 133 | 134 | ctx := context.Background() 135 | 136 | // Настраиваем мок для возврата ошибки rate limiter 137 | mockRateLimiter.On("Acquire", ctx).Return(errors.New("rate limit exceeded")) 138 | 139 | // Тестируем rate limiting с ошибкой 140 | err := processor.rateLimiter.Acquire(ctx) 141 | assert.Error(t, err) 142 | assert.Contains(t, err.Error(), "rate limit exceeded") 143 | 144 | mockRateLimiter.AssertExpectations(t) 145 | }) 146 | } 147 | 148 | func TestPaymentProcessorImpl_InvoiceCreation_Error(t *testing.T) { 149 | t.Run("ошибка создания инвойса", func(t *testing.T) { 150 | mockInvoiceCreator := &MockInvoiceCreator{} 151 | mockRateLimiter := &MockRateLimiter{} 152 | 153 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 154 | 155 | gift := createTestGift(1, 100) 156 | giftRequire := createTestGiftRequire(gift) 157 | 158 | // Настраиваем мок для возврата ошибки 159 | mockInvoiceCreator.On("CreateInvoice", giftRequire).Return((*tg.InputInvoiceStarGift)(nil), errors.New("invoice creation failed")) 160 | 161 | // Тестируем создание инвойса с ошибкой 162 | createdInvoice, err := processor.invoiceCreator.CreateInvoice(giftRequire) 163 | assert.Error(t, err) 164 | assert.Nil(t, createdInvoice) 165 | assert.Contains(t, err.Error(), "invoice creation failed") 166 | 167 | mockInvoiceCreator.AssertExpectations(t) 168 | }) 169 | } 170 | 171 | func TestPaymentProcessorImpl_ContextCancellation(t *testing.T) { 172 | t.Run("отмена контекста", func(t *testing.T) { 173 | mockInvoiceCreator := &MockInvoiceCreator{} 174 | mockRateLimiter := &MockRateLimiter{} 175 | 176 | processor := NewPaymentProcessor((*tg.Client)(nil), mockInvoiceCreator, mockRateLimiter) 177 | 178 | // Создаем отмененный контекст 179 | ctx, cancel := context.WithCancel(context.Background()) 180 | cancel() 181 | 182 | // Настраиваем мок для возврата ошибки контекста 183 | mockRateLimiter.On("Acquire", ctx).Return(context.Canceled) 184 | 185 | // Тестируем обработку отмененного контекста 186 | err := processor.rateLimiter.Acquire(ctx) 187 | assert.Error(t, err) 188 | assert.Equal(t, context.Canceled, err) 189 | 190 | mockRateLimiter.AssertExpectations(t) 191 | }) 192 | } 193 | -------------------------------------------------------------------------------- /internal/config/appConfig.go: -------------------------------------------------------------------------------- 1 | // Package config provides application configuration structures and utilities 2 | // for the Gift Buyer application. It defines the configuration schema 3 | // for Telegram settings, gift criteria, and operational parameters. 4 | package config 5 | 6 | // AppConfig represents the main application configuration structure. 7 | // It contains logger settings and software-specific configuration. 8 | type AppConfig struct { 9 | // LoggerLevel specifies the logging level (debug, info, warn, error, fatal, panic) 10 | LoggerLevel string `json:"logger_level"` 11 | 12 | // SoftConfig contains the core application configuration 13 | SoftConfig SoftConfig `json:"soft_config"` 14 | } 15 | 16 | // SoftConfig contains the core operational configuration for the gift buying system. 17 | // It includes Telegram settings, purchase criteria, and operational limits. 18 | type SoftConfig struct { 19 | // UpdateTicker is the interval for checking for updates 20 | UpdateTicker float64 `json:"update_ticker"` 21 | 22 | // RepoOwner is the owner of the repository 23 | RepoOwner string `json:"repo_owner"` 24 | 25 | // RepoName is the name of the repository 26 | RepoName string `json:"repo_name"` 27 | 28 | // ApiLink is the link to the API 29 | ApiLink string `json:"api_link"` 30 | 31 | // TgSettings contains Telegram API and bot configuration 32 | TgSettings TgSettings `json:"tg_settings"` 33 | 34 | // Criterias defines the list of criteria for gift validation 35 | Criterias []Criterias `json:"criterias"` 36 | 37 | // Receiver specifies the target recipient for purchased gifts 38 | Receiver ReceiverParams `json:"receiver"` 39 | 40 | // Ticker is the monitoring interval in seconds 41 | Ticker float64 `json:"ticker"` 42 | 43 | // RetryCount is the number of retries for failed purchases 44 | RetryCount int `json:"retry_count"` 45 | 46 | // RetryDelay is the delay between retries in seconds 47 | RetryDelay float64 `json:"retry_delay"` 48 | 49 | // MaxBuyCount is the maximum number of gifts that can be purchased 50 | MaxBuyCount int64 `json:"max_buy_count"` 51 | 52 | // GiftParam is the parameter for the gift 53 | GiftParam GiftParam `json:"gift_param"` 54 | 55 | // ConcurrencyLimit is the maximum number of concurrent purchases 56 | ConcurrencyGiftCount int `json:"concurrency_gift_count"` 57 | 58 | // ConcurrentOperations is the maximum number of concurrent operations 59 | ConcurrentOperations int `json:"concurrent_operations"` 60 | 61 | // RPCRateLimit is the rate limit for RPC requests 62 | RPCRateLimit int `json:"rpc_rate_limit"` 63 | 64 | // LogFlag controls whether logs should be written to both file and console. 65 | // When true: logs are written to both log files (info_logs.jsonl, error_logs.jsonl) AND displayed in console 66 | // When false: logs are written ONLY to log files, console output is disabled 67 | // This flag is useful for production environments where console output should be minimized 68 | LogFlag bool `json:"log_flag"` 69 | 70 | // Prioritization disables prioritization between users and channels 71 | Prioritization bool `json:"prioritization"` 72 | } 73 | 74 | type GiftParam struct { 75 | // LimitedStatus is the status of the limited gifts 76 | LimitedStatus bool `json:"limited_status"` 77 | 78 | // TestMode enables test mode which bypasses certain validations 79 | TestMode bool `json:"test_mode"` 80 | 81 | // OnlyPremium allows only premium purchases 82 | OnlyPremium bool `json:"only_premium"` 83 | 84 | // TotalStarCap is the maximum total stars that can be spent across all gifts 85 | TotalStarCap int64 `json:"total_star_cap"` 86 | 87 | // ReleaseBy is the type of release by 88 | ReleaseBy bool `json:"release_by"` 89 | } 90 | 91 | // TgSettings contains all Telegram-related configuration parameters. 92 | // This includes API credentials, bot settings, and notification preferences. 93 | type TgSettings struct { 94 | // AppId is the Telegram application ID obtained from my.telegram.org 95 | AppId int `json:"app_id"` 96 | 97 | // ApiHash is the Telegram API hash obtained from my.telegram.org 98 | ApiHash string `json:"api_hash"` 99 | 100 | // Phone is the phone number associated with the Telegram account 101 | Phone string `json:"phone"` 102 | 103 | // Password is the 2FA password for the Telegram account (if enabled) 104 | Password string `json:"password"` 105 | 106 | // TgBotKey is the bot token for sending notifications 107 | TgBotKey string `json:"tg_bot_key"` 108 | 109 | // Datacenter specifies which Telegram datacenter to use (1, 2, 3, 4, 5) 110 | // Default is 0 (auto-select). Use 4 for better performance when DC2 is lagging 111 | Datacenter int `json:"datacenter"` 112 | 113 | // NotificationChatID is the chat ID where notifications will be sent 114 | NotificationChatID int64 `json:"notification_chat_id"` 115 | } 116 | 117 | // Criterias defines the validation criteria for gift purchases. 118 | // Multiple criteria can be defined, and gifts matching any criteria will be considered eligible. 119 | type Criterias struct { 120 | // MinPrice is the minimum price in stars for eligible gifts 121 | MinPrice int64 `json:"min_price"` 122 | 123 | // MaxPrice is the maximum price in stars for eligible gifts 124 | MaxPrice int64 `json:"max_price"` 125 | 126 | // TotalSupply is the minimum total supply required for limited gifts 127 | TotalSupply int64 `json:"total_supply"` 128 | 129 | // Count is the number of gifts to purchase when this criteria matches 130 | Count int64 `json:"count"` 131 | 132 | // ReceiverType is the type of receiver (1 for user, 2 for channel) 133 | ReceiverType []int `json:"receiver_type"` 134 | 135 | // ReceiverDistribution []DistributionParams `json:"receiver_distribution"` 136 | Hide bool `json:"hide"` 137 | } 138 | 139 | type DistributionParams struct { 140 | Username string `json:"username"` 141 | Count int `json:"count"` 142 | } 143 | 144 | // ReceiverParams specifies the recipient configuration for purchased gifts. 145 | type ReceiverParams struct { 146 | // Type specifies the receiver type (1 for user, 2 for channel) 147 | // Type []int `json:"type"` 148 | 149 | // ReceiverID is the Telegram ID of the gift recipient 150 | UserReceiverID []string `json:"user_receiver_id"` 151 | 152 | // ChannelReceiverID is the Telegram ID of the gift recipient 153 | ChannelReceiverID []string `json:"channel_receiver_id"` 154 | } 155 | -------------------------------------------------------------------------------- /internal/service/giftService/giftValidator/validator.go: -------------------------------------------------------------------------------- 1 | // Package giftValidator provides gift validation functionality for the gift buying system. 2 | // It implements criteria-based validation to determine which gifts are eligible for purchase 3 | // based on price ranges, supply constraints, and total spending limits. 4 | package giftValidator 5 | 6 | import ( 7 | "gift-buyer/internal/config" 8 | "gift-buyer/internal/service/giftService/giftTypes" 9 | 10 | "github.com/gotd/td/tg" 11 | ) 12 | 13 | // GiftValidator implements the GiftValidator interface for validating gifts 14 | // against configured purchase criteria. It evaluates gifts based on price, 15 | // supply availability, and total star spending caps. 16 | type giftValidatorImpl struct { 17 | limitedStatus, releaseBy, premium bool 18 | // criteria contains the list of validation criteria for gift purchases 19 | criteria []config.Criterias 20 | 21 | // totalStarCap is the maximum total stars that can be spent across all gifts 22 | totalStarCap int64 23 | 24 | // testMode enables test mode which bypasses certain validations 25 | testMode bool 26 | } 27 | 28 | // NewGiftValidator creates a new GiftValidator instance with the specified criteria. 29 | // The validator will use the provided criteria to evaluate gift eligibility. 30 | // 31 | // Parameters: 32 | // - criterias: slice of criteria defining price ranges, supply limits, and purchase counts 33 | // - totalStarCap: maximum total stars that can be spent across all eligible gifts 34 | // - testMode: if true, bypasses supply and star cap validations for testing 35 | // 36 | // Returns: 37 | // - giftInterfaces.GiftValidator: configured gift validator instance 38 | func NewGiftValidator(criterias []config.Criterias, giftParam config.GiftParam) *giftValidatorImpl { 39 | return &giftValidatorImpl{ 40 | criteria: criterias, 41 | totalStarCap: giftParam.TotalStarCap, 42 | premium: giftParam.OnlyPremium, 43 | testMode: giftParam.TestMode, 44 | limitedStatus: giftParam.LimitedStatus, 45 | releaseBy: giftParam.ReleaseBy, 46 | } 47 | } 48 | 49 | // IsEligible checks if a gift meets any of the configured purchase criteria. 50 | // It evaluates the gift against all criteria and returns the purchase count 51 | // for the first matching criteria. 52 | // 53 | // The validation process checks: 54 | // - Gift is not sold out 55 | // - Price falls within configured range 56 | // - Supply meets minimum requirements (unless in test mode) 57 | // - Total star cap is not exceeded (unless in test mode) 58 | // 59 | // Parameters: 60 | // - gift: the star gift to validate against criteria 61 | // 62 | // Returns: 63 | // - int64: number of gifts to purchase if eligible (0 if not eligible) 64 | // - bool: true if the gift meets any criteria, false otherwise 65 | func (gv *giftValidatorImpl) IsEligible(gift *tg.StarGift) (*giftTypes.GiftRequire, bool) { 66 | if gift.SoldOut { 67 | return nil, false 68 | } 69 | 70 | if gift.Limited != gv.limitedStatus { 71 | return nil, false 72 | } 73 | 74 | if !gv.releaseByValidation(gift) { 75 | return nil, false 76 | } 77 | 78 | if ok := gv.premiumValidation(gift); !ok { 79 | return nil, false 80 | } 81 | 82 | for _, criteria := range gv.criteria { 83 | if gv.priceValid(criteria, gift) && gv.supplyValid(criteria, gift) && gv.starCapValidation(gift) { 84 | return &giftTypes.GiftRequire{ 85 | Gift: gift, 86 | ReceiverType: criteria.ReceiverType, 87 | CountForBuy: criteria.Count, 88 | Hide: criteria.Hide, 89 | }, true 90 | } 91 | } 92 | 93 | return nil, false 94 | } 95 | 96 | // priceValid checks if the gift price falls within the specified criteria range. 97 | // 98 | // Parameters: 99 | // - criteria: the criteria containing min and max price limits 100 | // - gift: the star gift to validate 101 | // 102 | // Returns: 103 | // - bool: true if the gift price is within the criteria range 104 | func (gv *giftValidatorImpl) priceValid(criteria config.Criterias, gift *tg.StarGift) bool { 105 | giftPrice := gift.GetStars() 106 | if giftPrice >= criteria.MinPrice && giftPrice <= criteria.MaxPrice { 107 | return true 108 | } 109 | 110 | return false 111 | } 112 | 113 | // supplyValid checks if the gift supply meets the minimum requirements. 114 | // In test mode, this validation is bypassed and always returns true. 115 | // 116 | // For limited gifts, it checks: 117 | // - Gift is not sold out 118 | // - Remaining supply is greater than 0 119 | // - Total supply is not greater than the maximum allowed supply 120 | // 121 | // For unlimited gifts, it always returns true. 122 | // 123 | // Parameters: 124 | // - criteria: the criteria containing total supply requirements 125 | // - gift: the star gift to validate 126 | // 127 | // Returns: 128 | // - bool: true if the gift supply meets requirements 129 | func (gv *giftValidatorImpl) supplyValid(criteria config.Criterias, gift *tg.StarGift) bool { 130 | if gv.testMode { 131 | return true 132 | } 133 | 134 | if gift.Limited { 135 | remains, hasRemains := gift.GetAvailabilityRemains() 136 | if !hasRemains || remains <= 0 { 137 | return false 138 | } 139 | 140 | totalSupply, hasTotalSupply := gift.GetAvailabilityTotal() 141 | if !hasTotalSupply { 142 | return false 143 | } 144 | 145 | if int64(totalSupply) <= criteria.TotalSupply { 146 | return true 147 | } 148 | return false 149 | } 150 | 151 | return true 152 | } 153 | 154 | // starCapValidation checks if purchasing the gift would exceed the total star spending cap. 155 | // In test mode, this validation is bypassed and always returns true. 156 | // 157 | // The validation calculates the total cost as: gift_price * total_supply 158 | // and ensures it doesn't exceed the configured total star cap. 159 | // 160 | // Parameters: 161 | // - gift: the star gift to validate 162 | // 163 | // Returns: 164 | // - bool: true if the gift doesn't exceed the star spending cap 165 | func (gv *giftValidatorImpl) starCapValidation(gift *tg.StarGift) bool { 166 | if gv.testMode { 167 | return true 168 | } 169 | 170 | price := gift.GetStars() 171 | giftSupply, _ := gift.GetAvailabilityTotal() 172 | return (price * int64(giftSupply)) <= gv.totalStarCap 173 | } 174 | 175 | func (gv *giftValidatorImpl) releaseByValidation(gift *tg.StarGift) bool { 176 | _, hasReleasedBy := gift.GetReleasedBy() 177 | 178 | if hasReleasedBy && !gv.releaseBy { 179 | return false 180 | } 181 | return true 182 | } 183 | 184 | func (gv *giftValidatorImpl) premiumValidation(gift *tg.StarGift) bool { 185 | premium := gift.GetRequirePremium() 186 | if gv.premium && !premium { 187 | return false 188 | } 189 | 190 | return true 191 | } 192 | -------------------------------------------------------------------------------- /internal/usecase/factory.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gift-buyer/internal/config" 7 | "gift-buyer/internal/infrastructure/gitVersion" 8 | "gift-buyer/internal/infrastructure/logsWriter" 9 | "gift-buyer/internal/infrastructure/logsWriter/logFormatter" 10 | "gift-buyer/internal/infrastructure/logsWriter/writer" 11 | "gift-buyer/internal/service/authService" 12 | "gift-buyer/internal/service/authService/apiChecker" 13 | "gift-buyer/internal/service/authService/sessions" 14 | "gift-buyer/internal/service/giftService/accountManager" 15 | "gift-buyer/internal/service/giftService/cache/giftCache" 16 | "gift-buyer/internal/service/giftService/cache/idCache" 17 | "gift-buyer/internal/service/giftService/giftBuyer" 18 | "gift-buyer/internal/service/giftService/giftBuyer/atomicCounter" 19 | "gift-buyer/internal/service/giftService/giftBuyer/giftBuyerMonitoring" 20 | "gift-buyer/internal/service/giftService/giftBuyer/invoiceCreator" 21 | "gift-buyer/internal/service/giftService/giftBuyer/paymentProcessor" 22 | "gift-buyer/internal/service/giftService/giftBuyer/purchaseProcessor" 23 | "gift-buyer/internal/service/giftService/giftManager" 24 | "gift-buyer/internal/service/giftService/giftMonitor" 25 | "gift-buyer/internal/service/giftService/giftNotification" 26 | "gift-buyer/internal/service/giftService/giftValidator" 27 | "gift-buyer/internal/service/giftService/rateLimiter" 28 | "time" 29 | 30 | "github.com/gotd/td/tg" 31 | ) 32 | 33 | // Factory provides a centralized way to create and configure the complete gift buying system. 34 | // It handles the complex initialization of all components including Telegram clients, 35 | // authentication, and dependency wiring with proper error handling. 36 | type Factory struct { 37 | // cfg contains the software configuration for the gift buying system 38 | cfg *config.SoftConfig 39 | } 40 | 41 | // NewFactory creates a new Factory instance with the specified configuration. 42 | // The factory will use this configuration to initialize all system components. 43 | // 44 | // Parameters: 45 | // - cfg: software configuration containing Telegram settings, criteria, and operational parameters 46 | // 47 | // Returns: 48 | // - *Factory: configured factory instance ready to create the gift buying system 49 | func NewFactory(cfg *config.SoftConfig) *Factory { 50 | return &Factory{cfg: cfg} 51 | } 52 | 53 | // CreateSystem creates and initializes the complete gift buying system. 54 | // It sets up Telegram clients, handles authentication, creates all service components, 55 | // and wires them together into a functional gift buying service. 56 | // 57 | // The initialization process: 58 | // 1. Creates and configures Telegram user client 59 | // 2. Handles user authentication (including 2FA if required) 60 | // 3. Creates and authenticates bot client for notifications 61 | // 4. Initializes all service components (validator, manager, cache, etc.) 62 | // 5. Wires components together into the main service 63 | // 64 | // Returns: 65 | // - GiftService: fully configured and ready-to-use gift buying service 66 | // - error: initialization error, authentication failure, or configuration error 67 | // 68 | // Possible errors: 69 | // - Telegram authentication failures 70 | // - Bot client initialization errors 71 | // - Network connectivity issues 72 | // - Invalid configuration parameters 73 | func (f *Factory) CreateSystem() (UseCase, error) { 74 | ctx, cancel := context.WithCancel(context.Background()) 75 | 76 | tickerInterval := f.cfg.Ticker 77 | if tickerInterval <= 0 { 78 | tickerInterval = 2.0 79 | } 80 | 81 | infoWriter := writer.NewLogsWriter("info", logFormatter.NewLogFormatter("info")) 82 | errorWriter := writer.NewLogsWriter("error", logFormatter.NewLogFormatter("error")) 83 | infoLogsHelper := logsWriter.NewLogger(infoWriter, f.cfg.LogFlag) 84 | errorLogsHelper := logsWriter.NewLogger(errorWriter, f.cfg.LogFlag) 85 | 86 | sessionManager := sessions.NewSessionManager(&f.cfg.TgSettings) 87 | authManager := authService.NewAuthManager(sessionManager, nil, &f.cfg.TgSettings, infoLogsHelper, errorLogsHelper) 88 | api, err := authManager.InitClient(ctx) 89 | if err != nil { 90 | cancel() 91 | return nil, err 92 | } 93 | 94 | apiChecker := apiChecker.NewApiChecker(api, time.NewTicker(time.Duration(tickerInterval*1000)*time.Millisecond)) 95 | authManager.SetApiChecker(apiChecker) 96 | authManager.RunApiChecker(ctx) 97 | 98 | var botClient *tg.Client 99 | if f.cfg.TgSettings.TgBotKey != "" { 100 | botClient, err = authManager.InitBotClient(ctx) 101 | if err != nil { 102 | cancel() 103 | return nil, fmt.Errorf("failed to create bot client: %w", err) 104 | } 105 | } 106 | 107 | validator := giftValidator.NewGiftValidator(f.cfg.Criterias, f.cfg.GiftParam) 108 | manager := giftManager.NewGiftManager(api) 109 | cache := giftCache.NewGiftCache() 110 | userCache := idCache.NewIDCache() 111 | notification := giftNotification.NewNotification(botClient, &f.cfg.TgSettings, errorLogsHelper) 112 | monitor := giftMonitor.NewGiftMonitor(cache, manager, validator, notification, time.Duration(tickerInterval*1000)*time.Millisecond, errorLogsHelper, infoLogsHelper, f.cfg.GiftParam.TestMode) 113 | authManager.SetMonitor(monitor) 114 | rl := rateLimiter.NewRateLimiter(f.cfg.RPCRateLimit) 115 | counter := atomicCounter.NewAtomicCounter(f.cfg.MaxBuyCount) 116 | invoiceCreator := invoiceCreator.NewInvoiceCreator(f.cfg.Receiver.UserReceiverID, f.cfg.Receiver.ChannelReceiverID, userCache) 117 | paymentProcessor := paymentProcessor.NewPaymentProcessor(api, invoiceCreator, rl) 118 | purchaseProcessor := purchaseProcessor.NewPurchaseProcessor(api, paymentProcessor) 119 | monitorProcessor := giftBuyerMonitoring.NewGiftBuyerMonitoring(api, notification, infoLogsHelper, errorLogsHelper) 120 | accountManager := accountManager.NewAccountManager(api, f.cfg.Receiver.UserReceiverID, f.cfg.Receiver.ChannelReceiverID, userCache, userCache) 121 | buyer := giftBuyer.NewGiftBuyer(api, f.cfg.Receiver.UserReceiverID, f.cfg.Receiver.ChannelReceiverID, manager, notification, f.cfg.MaxBuyCount, f.cfg.RetryCount, f.cfg.RetryDelay, f.cfg.Prioritization, userCache, f.cfg.ConcurrencyGiftCount, rl, f.cfg.ConcurrentOperations, invoiceCreator, purchaseProcessor, monitorProcessor, counter, errorLogsHelper) 122 | gitVersion := gitVersion.NewGitVersionController(f.cfg.RepoOwner, f.cfg.RepoName, f.cfg.ApiLink) 123 | 124 | updateInterval := f.cfg.UpdateTicker 125 | if updateInterval <= 0 { 126 | updateInterval = 60 127 | } 128 | 129 | service := NewUseCase( 130 | manager, 131 | validator, 132 | cache, 133 | notification, 134 | monitor, 135 | buyer, 136 | ctx, 137 | cancel, 138 | api, 139 | accountManager, 140 | gitVersion, 141 | time.NewTicker(time.Duration(updateInterval)*time.Second), 142 | ) 143 | 144 | return service, nil 145 | } 146 | -------------------------------------------------------------------------------- /internal/service/giftService/rateLimiter/rateLimiter_test.go: -------------------------------------------------------------------------------- 1 | package rateLimiter 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewRateLimiter(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | rps int 17 | }{ 18 | { 19 | name: "создание rate limiter с 1 RPS", 20 | rps: 1, 21 | }, 22 | { 23 | name: "создание rate limiter с 10 RPS", 24 | rps: 10, 25 | }, 26 | { 27 | name: "создание rate limiter с 100 RPS", 28 | rps: 100, 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | rl := NewRateLimiter(tt.rps) 35 | assert.NotNil(t, rl) 36 | assert.Equal(t, tt.rps, rl.maxTokens) 37 | assert.NotNil(t, rl.tokens) 38 | assert.NotNil(t, rl.ticker) 39 | assert.False(t, rl.closed) 40 | 41 | // Проверяем что канал заполнен токенами 42 | assert.Equal(t, tt.rps, len(rl.tokens)) 43 | 44 | rl.Close() 45 | }) 46 | } 47 | } 48 | 49 | func TestRateLimiter_Acquire(t *testing.T) { 50 | t.Run("успешное получение токена", func(t *testing.T) { 51 | rl := NewRateLimiter(5) 52 | defer rl.Close() 53 | 54 | ctx := context.Background() 55 | err := rl.Acquire(ctx) 56 | assert.NoError(t, err) 57 | }) 58 | 59 | t.Run("получение токена с отменой контекста", func(t *testing.T) { 60 | rl := NewRateLimiter(1) 61 | defer rl.Close() 62 | 63 | // Сначала берем единственный токен 64 | ctx := context.Background() 65 | err := rl.Acquire(ctx) 66 | require.NoError(t, err) 67 | 68 | // Теперь пытаемся взять еще один с отмененным контекстом 69 | canceledCtx, cancel := context.WithCancel(context.Background()) 70 | cancel() 71 | 72 | err = rl.Acquire(canceledCtx) 73 | assert.Error(t, err) 74 | assert.Equal(t, context.Canceled, err) 75 | }) 76 | 77 | t.Run("получение токена с таймаутом", func(t *testing.T) { 78 | rl := NewRateLimiter(1) 79 | defer rl.Close() 80 | 81 | // Берем единственный токен 82 | ctx := context.Background() 83 | err := rl.Acquire(ctx) 84 | require.NoError(t, err) 85 | 86 | // Пытаемся взять еще один с коротким таймаутом 87 | timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) 88 | defer cancel() 89 | 90 | err = rl.Acquire(timeoutCtx) 91 | assert.Error(t, err) 92 | assert.Equal(t, context.DeadlineExceeded, err) 93 | }) 94 | } 95 | 96 | func TestRateLimiter_RateLimit(t *testing.T) { 97 | t.Run("проверка ограничения скорости", func(t *testing.T) { 98 | rps := 2 99 | rl := NewRateLimiter(rps) 100 | defer rl.Close() 101 | 102 | ctx := context.Background() 103 | start := time.Now() 104 | 105 | // Берем все доступные токены 106 | for i := 0; i < rps; i++ { 107 | err := rl.Acquire(ctx) 108 | require.NoError(t, err) 109 | } 110 | 111 | // Следующий запрос должен заблокироваться 112 | err := rl.Acquire(ctx) 113 | elapsed := time.Since(start) 114 | 115 | assert.NoError(t, err) 116 | // Должно пройти примерно время для пополнения токенов 117 | assert.True(t, elapsed >= 400*time.Millisecond, "elapsed time: %v", elapsed) 118 | }) 119 | } 120 | 121 | func TestRateLimiter_ConcurrentAccess(t *testing.T) { 122 | t.Run("конкурентный доступ к rate limiter", func(t *testing.T) { 123 | rps := 10 124 | rl := NewRateLimiter(rps) 125 | defer rl.Close() 126 | 127 | var wg sync.WaitGroup 128 | var successCount int64 129 | var mu sync.Mutex 130 | 131 | numGoroutines := 20 132 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 133 | defer cancel() 134 | 135 | for i := 0; i < numGoroutines; i++ { 136 | wg.Add(1) 137 | go func() { 138 | defer wg.Done() 139 | err := rl.Acquire(ctx) 140 | if err == nil { 141 | mu.Lock() 142 | successCount++ 143 | mu.Unlock() 144 | } 145 | }() 146 | } 147 | 148 | wg.Wait() 149 | 150 | mu.Lock() 151 | finalCount := successCount 152 | mu.Unlock() 153 | 154 | // Все горутины должны получить токены (возможно с задержкой) 155 | // Допускаем небольшую погрешность из-за таймингов 156 | assert.GreaterOrEqual(t, finalCount, int64(numGoroutines-1)) 157 | }) 158 | } 159 | 160 | func TestRateLimiter_Close(t *testing.T) { 161 | t.Run("закрытие rate limiter", func(t *testing.T) { 162 | rl := NewRateLimiter(5) 163 | 164 | assert.False(t, rl.closed) 165 | assert.NotNil(t, rl.ticker) 166 | 167 | rl.Close() 168 | 169 | assert.True(t, rl.closed) 170 | }) 171 | 172 | t.Run("повторное закрытие rate limiter", func(t *testing.T) { 173 | rl := NewRateLimiter(5) 174 | 175 | rl.Close() 176 | assert.True(t, rl.closed) 177 | 178 | // Повторное закрытие не должно вызывать панику 179 | assert.NotPanics(t, func() { 180 | rl.Close() 181 | }) 182 | }) 183 | } 184 | 185 | func TestRateLimiter_RefillTokens(t *testing.T) { 186 | t.Run("пополнение токенов", func(t *testing.T) { 187 | rps := 2 188 | rl := NewRateLimiter(rps) 189 | defer rl.Close() 190 | 191 | ctx := context.Background() 192 | 193 | // Берем все токены 194 | for i := 0; i < rps; i++ { 195 | err := rl.Acquire(ctx) 196 | require.NoError(t, err) 197 | } 198 | 199 | // Ждем пополнения 200 | time.Sleep(time.Second + 100*time.Millisecond) 201 | 202 | // Должны быть доступны новые токены 203 | err := rl.Acquire(ctx) 204 | assert.NoError(t, err) 205 | }) 206 | } 207 | 208 | func TestRateLimiter_EdgeCases(t *testing.T) { 209 | t.Run("rate limiter с 0 RPS", func(t *testing.T) { 210 | // Хотя это не практичный случай, код должен работать 211 | rl := NewRateLimiter(0) 212 | defer rl.Close() 213 | 214 | assert.Equal(t, 0, rl.maxTokens) 215 | assert.Equal(t, 0, len(rl.tokens)) 216 | }) 217 | 218 | t.Run("rate limiter с большим RPS", func(t *testing.T) { 219 | rps := 1000 220 | rl := NewRateLimiter(rps) 221 | defer rl.Close() 222 | 223 | assert.Equal(t, rps, rl.maxTokens) 224 | // Изначально канал должен быть заполнен 225 | initialTokens := len(rl.tokens) 226 | assert.Equal(t, rps, initialTokens) 227 | 228 | // Должны быть доступны все токены 229 | ctx := context.Background() 230 | for i := 0; i < rps; i++ { 231 | err := rl.Acquire(ctx) 232 | assert.NoError(t, err) 233 | } 234 | 235 | // После использования всех токенов канал должен быть пустым 236 | assert.Equal(t, 0, len(rl.tokens)) 237 | }) 238 | } 239 | 240 | func TestRateLimiter_Performance(t *testing.T) { 241 | t.Run("производительность rate limiter", func(t *testing.T) { 242 | rps := 100 243 | rl := NewRateLimiter(rps) 244 | defer rl.Close() 245 | 246 | ctx := context.Background() 247 | start := time.Now() 248 | 249 | // Берем много токенов 250 | for i := 0; i < rps; i++ { 251 | err := rl.Acquire(ctx) 252 | require.NoError(t, err) 253 | } 254 | 255 | elapsed := time.Since(start) 256 | // Первые токены должны быть получены быстро 257 | assert.True(t, elapsed < 100*time.Millisecond, "elapsed time: %v", elapsed) 258 | }) 259 | } 260 | -------------------------------------------------------------------------------- /internal/service/giftService/cache/giftCache/cache_test.go: -------------------------------------------------------------------------------- 1 | package giftCache 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/gotd/td/tg" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewGiftCache(t *testing.T) { 12 | cache := NewGiftCache() 13 | assert.NotNil(t, cache) 14 | 15 | // Verify it implements the interface 16 | _, ok := cache.(*GiftCacheImpl) 17 | assert.True(t, ok) 18 | } 19 | 20 | func TestGiftCache_SetAndGetGift(t *testing.T) { 21 | cache := NewGiftCache() 22 | 23 | gift := &tg.StarGift{ 24 | ID: 123, 25 | Stars: 500, 26 | } 27 | 28 | // Set gift 29 | cache.SetGift(123, gift) 30 | 31 | // Get gift 32 | retrievedGift, err := cache.GetGift(123) 33 | assert.NoError(t, err) 34 | assert.NotNil(t, retrievedGift) 35 | assert.Equal(t, gift.ID, retrievedGift.ID) 36 | assert.Equal(t, gift.Stars, retrievedGift.Stars) 37 | } 38 | 39 | func TestGiftCache_GetNonExistentGift(t *testing.T) { 40 | cache := NewGiftCache() 41 | 42 | gift, err := cache.GetGift(999) 43 | assert.NoError(t, err) 44 | assert.Nil(t, gift) 45 | } 46 | 47 | func TestGiftCache_HasGift(t *testing.T) { 48 | cache := NewGiftCache() 49 | 50 | gift := &tg.StarGift{ 51 | ID: 456, 52 | Stars: 750, 53 | } 54 | 55 | // Initially should not have the gift 56 | assert.False(t, cache.HasGift(456)) 57 | 58 | // Set gift 59 | cache.SetGift(456, gift) 60 | 61 | // Now should have the gift 62 | assert.True(t, cache.HasGift(456)) 63 | 64 | // Should not have other gifts 65 | assert.False(t, cache.HasGift(789)) 66 | } 67 | 68 | func TestGiftCache_DeleteGift(t *testing.T) { 69 | cache := NewGiftCache() 70 | 71 | gift := &tg.StarGift{ 72 | ID: 789, 73 | Stars: 1000, 74 | } 75 | 76 | // Set gift 77 | cache.SetGift(789, gift) 78 | assert.True(t, cache.HasGift(789)) 79 | 80 | // Delete gift 81 | cache.DeleteGift(789) 82 | assert.False(t, cache.HasGift(789)) 83 | 84 | // Verify it's actually gone 85 | retrievedGift, err := cache.GetGift(789) 86 | assert.NoError(t, err) 87 | assert.Nil(t, retrievedGift) 88 | } 89 | 90 | func TestGiftCache_DeleteNonExistentGift(t *testing.T) { 91 | cache := NewGiftCache() 92 | 93 | // Should not panic when deleting non-existent gift 94 | assert.NotPanics(t, func() { 95 | cache.DeleteGift(999) 96 | }) 97 | } 98 | 99 | func TestGiftCache_GetAllGifts(t *testing.T) { 100 | cache := NewGiftCache() 101 | 102 | // Initially should be empty 103 | allGifts := cache.GetAllGifts() 104 | assert.NotNil(t, allGifts) 105 | assert.Empty(t, allGifts) 106 | 107 | // Add some gifts 108 | gift1 := &tg.StarGift{ID: 1, Stars: 100} 109 | gift2 := &tg.StarGift{ID: 2, Stars: 200} 110 | gift3 := &tg.StarGift{ID: 3, Stars: 300} 111 | 112 | cache.SetGift(1, gift1) 113 | cache.SetGift(2, gift2) 114 | cache.SetGift(3, gift3) 115 | 116 | // Get all gifts 117 | allGifts = cache.GetAllGifts() 118 | assert.Len(t, allGifts, 3) 119 | assert.Equal(t, gift1, allGifts[1]) 120 | assert.Equal(t, gift2, allGifts[2]) 121 | assert.Equal(t, gift3, allGifts[3]) 122 | } 123 | 124 | func TestGiftCache_GetAllGifts_IsolatedCopy(t *testing.T) { 125 | cache := NewGiftCache() 126 | 127 | gift := &tg.StarGift{ID: 1, Stars: 100} 128 | cache.SetGift(1, gift) 129 | 130 | // Get all gifts 131 | allGifts1 := cache.GetAllGifts() 132 | allGifts2 := cache.GetAllGifts() 133 | 134 | // Modify one copy 135 | allGifts1[999] = &tg.StarGift{ID: 999, Stars: 999} 136 | 137 | // Other copy should not be affected 138 | assert.Len(t, allGifts2, 1) 139 | assert.NotContains(t, allGifts2, int64(999)) 140 | 141 | // Original cache should not be affected 142 | assert.False(t, cache.HasGift(999)) 143 | } 144 | 145 | func TestGiftCache_Clear(t *testing.T) { 146 | cache := NewGiftCache() 147 | 148 | // Add some gifts 149 | cache.SetGift(1, &tg.StarGift{ID: 1, Stars: 100}) 150 | cache.SetGift(2, &tg.StarGift{ID: 2, Stars: 200}) 151 | cache.SetGift(3, &tg.StarGift{ID: 3, Stars: 300}) 152 | 153 | // Verify gifts are there 154 | assert.True(t, cache.HasGift(1)) 155 | assert.True(t, cache.HasGift(2)) 156 | assert.True(t, cache.HasGift(3)) 157 | 158 | // Clear cache 159 | cache.Clear() 160 | 161 | // Verify cache is empty 162 | assert.False(t, cache.HasGift(1)) 163 | assert.False(t, cache.HasGift(2)) 164 | assert.False(t, cache.HasGift(3)) 165 | 166 | allGifts := cache.GetAllGifts() 167 | assert.Empty(t, allGifts) 168 | } 169 | 170 | func TestGiftCache_UpdateGift(t *testing.T) { 171 | cache := NewGiftCache() 172 | 173 | // Set initial gift 174 | originalGift := &tg.StarGift{ID: 1, Stars: 100} 175 | cache.SetGift(1, originalGift) 176 | 177 | // Update gift 178 | updatedGift := &tg.StarGift{ID: 1, Stars: 200} 179 | cache.SetGift(1, updatedGift) 180 | 181 | // Verify update 182 | retrievedGift, err := cache.GetGift(1) 183 | assert.NoError(t, err) 184 | assert.NotNil(t, retrievedGift) 185 | assert.Equal(t, int64(200), retrievedGift.Stars) 186 | } 187 | 188 | func TestGiftCache_ConcurrentAccess(t *testing.T) { 189 | cache := NewGiftCache() 190 | const numGoroutines = 100 191 | const numOperations = 100 192 | 193 | var wg sync.WaitGroup 194 | 195 | // Concurrent writes 196 | wg.Add(numGoroutines) 197 | for i := 0; i < numGoroutines; i++ { 198 | go func(id int) { 199 | defer wg.Done() 200 | for j := 0; j < numOperations; j++ { 201 | gift := &tg.StarGift{ 202 | ID: int64(id*numOperations + j), 203 | Stars: int64(j), 204 | } 205 | cache.SetGift(gift.ID, gift) 206 | } 207 | }(i) 208 | } 209 | wg.Wait() 210 | 211 | // Verify all gifts were set 212 | allGifts := cache.GetAllGifts() 213 | assert.Len(t, allGifts, numGoroutines*numOperations) 214 | 215 | // Concurrent reads 216 | wg.Add(numGoroutines) 217 | for i := 0; i < numGoroutines; i++ { 218 | go func(id int) { 219 | defer wg.Done() 220 | for j := 0; j < numOperations; j++ { 221 | giftID := int64(id*numOperations + j) 222 | gift, err := cache.GetGift(giftID) 223 | assert.NoError(t, err) 224 | assert.NotNil(t, gift) 225 | assert.Equal(t, giftID, gift.ID) 226 | assert.Equal(t, int64(j), gift.Stars) 227 | } 228 | }(i) 229 | } 230 | wg.Wait() 231 | } 232 | 233 | func TestGiftCache_ZeroIDGift(t *testing.T) { 234 | cache := NewGiftCache() 235 | 236 | gift := &tg.StarGift{ 237 | ID: 0, // Zero ID 238 | Stars: 100, 239 | } 240 | 241 | cache.SetGift(0, gift) 242 | assert.True(t, cache.HasGift(0)) 243 | 244 | retrievedGift, err := cache.GetGift(0) 245 | assert.NoError(t, err) 246 | assert.NotNil(t, retrievedGift) 247 | assert.Equal(t, int64(0), retrievedGift.ID) 248 | } 249 | 250 | func TestGiftCache_NegativeIDGift(t *testing.T) { 251 | cache := NewGiftCache() 252 | 253 | gift := &tg.StarGift{ 254 | ID: -1, // Negative ID 255 | Stars: 100, 256 | } 257 | 258 | cache.SetGift(-1, gift) 259 | assert.True(t, cache.HasGift(-1)) 260 | 261 | retrievedGift, err := cache.GetGift(-1) 262 | assert.NoError(t, err) 263 | assert.NotNil(t, retrievedGift) 264 | assert.Equal(t, int64(-1), retrievedGift.ID) 265 | } 266 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/atomicCounter/counter_new_test.go: -------------------------------------------------------------------------------- 1 | package atomicCounter 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewAtomicCounterExtended(t *testing.T) { 11 | t.Run("создание нового счетчика", func(t *testing.T) { 12 | maxCount := int64(100) 13 | counter := NewAtomicCounter(maxCount) 14 | 15 | assert.NotNil(t, counter) 16 | assert.Equal(t, int64(0), counter.Get()) 17 | assert.Equal(t, maxCount, counter.GetMax()) 18 | }) 19 | 20 | t.Run("создание счетчика с нулевым максимумом", func(t *testing.T) { 21 | counter := NewAtomicCounter(0) 22 | 23 | assert.NotNil(t, counter) 24 | assert.Equal(t, int64(0), counter.Get()) 25 | assert.Equal(t, int64(0), counter.GetMax()) 26 | assert.False(t, counter.TryIncrement()) // Нельзя увеличить 27 | }) 28 | } 29 | 30 | func TestAtomicCounter_Operations(t *testing.T) { 31 | t.Run("базовые операции с счетчиком", func(t *testing.T) { 32 | counter := NewAtomicCounter(5) 33 | 34 | // Начальное значение 35 | assert.Equal(t, int64(0), counter.Get()) 36 | 37 | // Успешное увеличение 38 | assert.True(t, counter.TryIncrement()) 39 | assert.Equal(t, int64(1), counter.Get()) 40 | 41 | // Еще несколько увеличений 42 | assert.True(t, counter.TryIncrement()) 43 | assert.True(t, counter.TryIncrement()) 44 | assert.Equal(t, int64(3), counter.Get()) 45 | 46 | // Уменьшение 47 | counter.Decrement() 48 | assert.Equal(t, int64(2), counter.Get()) 49 | 50 | // Уменьшение до нуля и ниже 51 | counter.Decrement() 52 | counter.Decrement() 53 | assert.Equal(t, int64(0), counter.Get()) 54 | 55 | counter.Decrement() 56 | assert.Equal(t, int64(-1), counter.Get()) 57 | }) 58 | 59 | t.Run("достижение максимума", func(t *testing.T) { 60 | counter := NewAtomicCounter(2) 61 | 62 | // Увеличиваем до максимума 63 | assert.True(t, counter.TryIncrement()) 64 | assert.Equal(t, int64(1), counter.Get()) 65 | 66 | assert.True(t, counter.TryIncrement()) 67 | assert.Equal(t, int64(2), counter.Get()) 68 | 69 | // Попытка превысить максимум 70 | assert.False(t, counter.TryIncrement()) 71 | assert.Equal(t, int64(2), counter.Get()) 72 | 73 | // Еще одна попытка 74 | assert.False(t, counter.TryIncrement()) 75 | assert.Equal(t, int64(2), counter.Get()) 76 | }) 77 | 78 | t.Run("уменьшение после достижения максимума", func(t *testing.T) { 79 | counter := NewAtomicCounter(2) 80 | 81 | // Достигаем максимума 82 | counter.TryIncrement() 83 | counter.TryIncrement() 84 | assert.Equal(t, int64(2), counter.Get()) 85 | assert.False(t, counter.TryIncrement()) 86 | 87 | // Уменьшаем и снова увеличиваем 88 | counter.Decrement() 89 | assert.Equal(t, int64(1), counter.Get()) 90 | assert.True(t, counter.TryIncrement()) 91 | assert.Equal(t, int64(2), counter.Get()) 92 | }) 93 | } 94 | 95 | func TestAtomicCounter_Concurrent(t *testing.T) { 96 | t.Run("конкурентные увеличения", func(t *testing.T) { 97 | maxCount := int64(100) 98 | counter := NewAtomicCounter(maxCount) 99 | numGoroutines := 50 100 | incrementsPerGoroutine := 10 101 | 102 | var wg sync.WaitGroup 103 | successCount := int64(0) 104 | var successMutex sync.Mutex 105 | 106 | for i := 0; i < numGoroutines; i++ { 107 | wg.Add(1) 108 | go func() { 109 | defer wg.Done() 110 | localSuccess := 0 111 | for j := 0; j < incrementsPerGoroutine; j++ { 112 | if counter.TryIncrement() { 113 | localSuccess++ 114 | } 115 | } 116 | successMutex.Lock() 117 | successCount += int64(localSuccess) 118 | successMutex.Unlock() 119 | }() 120 | } 121 | 122 | wg.Wait() 123 | 124 | // Проверяем что количество успешных увеличений равно значению счетчика 125 | assert.Equal(t, successCount, counter.Get()) 126 | // И не превышает максимум 127 | assert.True(t, counter.Get() <= maxCount) 128 | // Фактически должно быть ровно максимум (так как пытаемся больше) 129 | assert.Equal(t, maxCount, counter.Get()) 130 | }) 131 | 132 | t.Run("конкурентные увеличения и уменьшения", func(t *testing.T) { 133 | counter := NewAtomicCounter(50) 134 | numGoroutines := 20 135 | 136 | var wg sync.WaitGroup 137 | 138 | // Горутины для увеличения 139 | for i := 0; i < numGoroutines; i++ { 140 | wg.Add(1) 141 | go func() { 142 | defer wg.Done() 143 | for j := 0; j < 10; j++ { 144 | counter.TryIncrement() 145 | } 146 | }() 147 | } 148 | 149 | // Горутины для уменьшения 150 | for i := 0; i < numGoroutines/2; i++ { 151 | wg.Add(1) 152 | go func() { 153 | defer wg.Done() 154 | for j := 0; j < 5; j++ { 155 | counter.Decrement() 156 | } 157 | }() 158 | } 159 | 160 | wg.Wait() 161 | 162 | // Проверяем что счетчик в разумных пределах 163 | finalValue := counter.Get() 164 | assert.True(t, finalValue >= -50) // Может быть отрицательным из-за Decrement 165 | assert.True(t, finalValue <= counter.GetMax()) 166 | }) 167 | 168 | t.Run("конкурентное чтение", func(t *testing.T) { 169 | counter := NewAtomicCounter(10) 170 | numReaders := 100 171 | 172 | // Устанавливаем начальное значение 173 | counter.TryIncrement() 174 | counter.TryIncrement() 175 | counter.TryIncrement() 176 | expectedValue := counter.Get() 177 | 178 | var wg sync.WaitGroup 179 | results := make([]int64, numReaders) 180 | 181 | for i := 0; i < numReaders; i++ { 182 | wg.Add(1) 183 | go func(index int) { 184 | defer wg.Done() 185 | results[index] = counter.Get() 186 | }(i) 187 | } 188 | 189 | wg.Wait() 190 | 191 | // Все чтения должны вернуть одинаковое значение 192 | for i, value := range results { 193 | assert.Equal(t, expectedValue, value, "Reader %d got different value", i) 194 | } 195 | }) 196 | } 197 | 198 | func TestAtomicCounter_AdvancedEdgeCases(t *testing.T) { 199 | t.Run("максимум равен единице", func(t *testing.T) { 200 | counter := NewAtomicCounter(1) 201 | 202 | assert.True(t, counter.TryIncrement()) 203 | assert.Equal(t, int64(1), counter.Get()) 204 | assert.False(t, counter.TryIncrement()) 205 | assert.Equal(t, int64(1), counter.Get()) 206 | 207 | counter.Decrement() 208 | assert.Equal(t, int64(0), counter.Get()) 209 | assert.True(t, counter.TryIncrement()) 210 | }) 211 | 212 | t.Run("отрицательный максимум", func(t *testing.T) { 213 | counter := NewAtomicCounter(-5) 214 | 215 | // С отрицательным максимумом нельзя увеличивать 216 | assert.False(t, counter.TryIncrement()) 217 | assert.Equal(t, int64(0), counter.Get()) 218 | 219 | // Но можно уменьшать 220 | counter.Decrement() 221 | assert.Equal(t, int64(-1), counter.Get()) 222 | }) 223 | 224 | t.Run("большие значения", func(t *testing.T) { 225 | maxValue := int64(1000000) 226 | counter := NewAtomicCounter(maxValue) 227 | 228 | // Увеличиваем на большое количество 229 | successCount := 0 230 | for i := 0; i < int(maxValue)+100; i++ { 231 | if counter.TryIncrement() { 232 | successCount++ 233 | } 234 | } 235 | 236 | assert.Equal(t, int(maxValue), successCount) 237 | assert.Equal(t, maxValue, counter.Get()) 238 | assert.Equal(t, maxValue, counter.GetMax()) 239 | }) 240 | } 241 | -------------------------------------------------------------------------------- /internal/service/authService/authManager.go: -------------------------------------------------------------------------------- 1 | package authService 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "gift-buyer/internal/config" 7 | "gift-buyer/internal/service/authService/apiChecker" 8 | "gift-buyer/internal/service/authService/authInterfaces" 9 | "os" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/gotd/td/telegram" 15 | "github.com/gotd/td/tg" 16 | ) 17 | 18 | type AuthManagerImpl struct { 19 | api *tg.Client 20 | botApi *tg.Client 21 | mu sync.RWMutex 22 | sessionManager authInterfaces.SessionManager 23 | apiChecker authInterfaces.ApiChecker 24 | cfg *config.TgSettings 25 | reconnect chan struct{} 26 | stopCh chan struct{} 27 | wg sync.WaitGroup 28 | monitor authInterfaces.GiftMonitorAndAuthController 29 | infoLogsWriter authInterfaces.InfoLogger 30 | errorLogsWriter authInterfaces.ErrorLogger 31 | } 32 | 33 | func NewAuthManager(sessionManager authInterfaces.SessionManager, apiChecker authInterfaces.ApiChecker, cfg *config.TgSettings, infoLogsWriter authInterfaces.InfoLogger, errorLogsWriter authInterfaces.ErrorLogger) *AuthManagerImpl { 34 | return &AuthManagerImpl{ 35 | sessionManager: sessionManager, 36 | apiChecker: apiChecker, 37 | cfg: cfg, 38 | reconnect: make(chan struct{}, 1), 39 | stopCh: make(chan struct{}), 40 | infoLogsWriter: infoLogsWriter, 41 | errorLogsWriter: errorLogsWriter, 42 | } 43 | } 44 | 45 | func (f *AuthManagerImpl) InitClient(ctx context.Context) (*tg.Client, error) { 46 | if f.cfg == nil { 47 | return nil, errors.New("configuration is nil") 48 | } 49 | 50 | if f.sessionManager == nil { 51 | return nil, errors.New("session manager is nil") 52 | } 53 | 54 | opts := telegram.Options{ 55 | SessionStorage: &telegram.FileSessionStorage{ 56 | Path: "session.json", 57 | }, 58 | } 59 | 60 | // Set datacenter if specified 61 | if f.cfg.Datacenter > 0 { 62 | opts.DC = f.cfg.Datacenter 63 | } 64 | 65 | client := telegram.NewClient(f.cfg.AppId, f.cfg.ApiHash, opts) 66 | 67 | api, err := f.sessionManager.InitUserAPI(client, ctx) 68 | if err != nil { 69 | return nil, err 70 | } 71 | f.mu.Lock() 72 | f.api = api 73 | f.mu.Unlock() 74 | return api, nil 75 | } 76 | 77 | func (f *AuthManagerImpl) RunApiChecker(ctx context.Context) { 78 | if f.apiChecker == nil { 79 | f.errorLogsWriter.LogError("API checker is nil, skipping") 80 | return 81 | } 82 | 83 | f.infoLogsWriter.LogInfo("Starting API monitoring") 84 | 85 | f.wg.Add(1) 86 | go func() { 87 | defer f.wg.Done() 88 | ticker := time.NewTicker(2 * time.Second) 89 | defer ticker.Stop() 90 | 91 | for { 92 | select { 93 | case <-ctx.Done(): 94 | f.infoLogsWriter.LogInfo("API monitoring stopped due to context cancellation") 95 | return 96 | case <-f.stopCh: 97 | f.infoLogsWriter.LogInfo("API monitoring stopped due to stop signal") 98 | return 99 | case <-ticker.C: 100 | if err := f.apiChecker.Run(ctx); err != nil { 101 | f.errorLogsWriter.LogErrorf("API check failed: %v", err) 102 | if f.isCriticalError(err) { 103 | f.errorLogsWriter.LogErrorf("Critical API error detected, triggering reconnect: %v", err) 104 | select { 105 | case f.reconnect <- struct{}{}: 106 | f.stopCh <- struct{}{} 107 | f.infoLogsWriter.LogInfo("Reconnect signal sent") 108 | default: 109 | f.errorLogsWriter.LogError("Reconnect channel is full") 110 | } 111 | } 112 | } else { 113 | f.infoLogsWriter.LogInfo("API check successful") 114 | } 115 | } 116 | } 117 | }() 118 | 119 | f.wg.Add(1) 120 | go func() { 121 | defer f.wg.Done() 122 | f.handleReconnectSignals(ctx) 123 | }() 124 | } 125 | 126 | func (f *AuthManagerImpl) isCriticalError(err error) bool { 127 | if err == nil { 128 | return false 129 | } 130 | 131 | errStr := strings.ToLower(err.Error()) 132 | 133 | return strings.Contains(errStr, "auth_key_unregistered") || 134 | strings.Contains(errStr, "connection_not_inited") || 135 | strings.Contains(errStr, "session_revoked") 136 | } 137 | 138 | func (f *AuthManagerImpl) handleReconnectSignals(ctx context.Context) { 139 | for { 140 | select { 141 | case <-f.reconnect: 142 | f.infoLogsWriter.LogInfo("Processing reconnect signal") 143 | 144 | if f.monitor != nil { 145 | f.infoLogsWriter.LogInfo("Pausing gift monitoring during reconnection") 146 | f.monitor.Pause() 147 | } 148 | 149 | if _, err := f.Reconnect(ctx); err != nil { 150 | f.errorLogsWriter.LogErrorf("Reconnect failed: %v", err) 151 | if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline") { 152 | f.errorLogsWriter.LogError("Reconnection timeout, exiting program") 153 | os.Exit(1) 154 | } 155 | } else { 156 | if f.monitor != nil { 157 | f.infoLogsWriter.LogInfo("Resuming gift monitoring after reconnection") 158 | f.monitor.Resume() 159 | } 160 | <-f.stopCh 161 | f.infoLogsWriter.LogInfo("Reconnection completed successfully") 162 | } 163 | case <-f.stopCh: 164 | f.infoLogsWriter.LogInfo("Stopping reconnect handler") 165 | return 166 | case <-ctx.Done(): 167 | f.infoLogsWriter.LogInfo("Context cancelled, stopping reconnect handler") 168 | return 169 | } 170 | } 171 | } 172 | 173 | func (f *AuthManagerImpl) InitBotClient(ctx context.Context) (*tg.Client, error) { 174 | if f.sessionManager == nil { 175 | return nil, errors.New("session manager is nil") 176 | } 177 | 178 | botApi, err := f.sessionManager.InitBotAPI(ctx) 179 | if err != nil { 180 | return nil, err 181 | } 182 | f.mu.Lock() 183 | f.botApi = botApi 184 | f.mu.Unlock() 185 | return botApi, nil 186 | } 187 | 188 | func (f *AuthManagerImpl) GetBotApi() *tg.Client { 189 | f.mu.RLock() 190 | defer f.mu.RUnlock() 191 | return f.botApi 192 | } 193 | 194 | func (f *AuthManagerImpl) GetApi() *tg.Client { 195 | f.mu.RLock() 196 | defer f.mu.RUnlock() 197 | return f.api 198 | } 199 | 200 | func (f *AuthManagerImpl) Reconnect(ctx context.Context) (*tg.Client, error) { 201 | f.infoLogsWriter.LogInfo("Starting reconnection process") 202 | 203 | tgc, err := f.InitClient(ctx) 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | if f.apiChecker != nil { 209 | newApiChecker := apiChecker.NewApiChecker(tgc, time.NewTicker(2*time.Second)) 210 | f.SetApiChecker(newApiChecker) 211 | f.infoLogsWriter.LogInfo("API checker updated with new client") 212 | } 213 | 214 | f.infoLogsWriter.LogInfo("Reconnection successful") 215 | return tgc, nil 216 | } 217 | 218 | func (f *AuthManagerImpl) Stop() { 219 | f.infoLogsWriter.LogInfo("Stopping AuthManager...") 220 | 221 | close(f.stopCh) 222 | 223 | if f.apiChecker != nil { 224 | f.apiChecker.Stop() 225 | } 226 | 227 | close(f.reconnect) 228 | 229 | f.wg.Wait() 230 | 231 | f.infoLogsWriter.LogInfo("AuthManager stopped") 232 | } 233 | 234 | func (f *AuthManagerImpl) SetApiChecker(apiChecker authInterfaces.ApiChecker) { 235 | f.mu.Lock() 236 | defer f.mu.Unlock() 237 | f.apiChecker = apiChecker 238 | } 239 | 240 | func (f *AuthManagerImpl) SetMonitor(monitor authInterfaces.GiftMonitorAndAuthController) { 241 | f.mu.Lock() 242 | defer f.mu.Unlock() 243 | f.monitor = monitor 244 | f.infoLogsWriter.LogInfo("Gift monitor set for auth manager") 245 | } 246 | -------------------------------------------------------------------------------- /internal/service/giftService/giftMonitor/monitor.go: -------------------------------------------------------------------------------- 1 | // Package giftMonitor provides gift monitoring functionality for the gift buying system. 2 | // It continuously monitors for new gifts, validates them against criteria, 3 | // and triggers notifications when eligible gifts are discovered. 4 | package giftMonitor 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "gift-buyer/internal/service/giftService/giftInterfaces" 10 | "gift-buyer/internal/service/giftService/giftTypes" 11 | "gift-buyer/pkg/errors" 12 | 13 | "sync" 14 | "time" 15 | ) 16 | 17 | // giftMonitorImpl implements the GiftMonitor interface for monitoring new gifts. 18 | // It periodically checks for new gifts, validates them against criteria, 19 | // and manages caching to avoid duplicate processing. 20 | type giftMonitorImpl struct { 21 | // cache stores processed gifts to avoid duplicate notifications 22 | cache giftInterfaces.GiftCache 23 | 24 | // manager handles communication with Telegram API for gift retrieval 25 | manager giftInterfaces.Giftmanager 26 | 27 | // validator evaluates gifts against purchase criteria 28 | validator giftInterfaces.GiftValidator 29 | 30 | // notification sends alerts about new eligible gifts 31 | notification giftInterfaces.NotificationService 32 | 33 | // logsWriter is used to write logs to a file 34 | errorLogsWriter giftInterfaces.ErrorLogger 35 | infoLogsWriter giftInterfaces.InfoLogger 36 | 37 | // ticker controls the monitoring interval 38 | ticker *time.Ticker 39 | 40 | // paused indicates if monitoring is currently paused 41 | paused bool 42 | 43 | // firstRun indicates if the monitor is running for the first time 44 | firstRun bool 45 | 46 | // mu protects the paused field from concurrent access 47 | mu sync.RWMutex 48 | 49 | // testMode indicates if the monitor is running in test mode 50 | testMode bool 51 | } 52 | 53 | // NewGiftMonitor creates a new GiftMonitor instance with the specified dependencies. 54 | // The monitor will check for new gifts at the specified interval and process 55 | // them through the validation and notification pipeline. 56 | // 57 | // Parameters: 58 | // - cache: gift cache for tracking processed gifts 59 | // - manager: gift manager for retrieving available gifts 60 | // - validator: gift validator for eligibility checking 61 | // - notification: notification service for sending alerts 62 | // - tickTime: interval between gift checks 63 | // 64 | // Returns: 65 | // - giftInterfaces.GiftMonitor: configured gift monitor instance 66 | func NewGiftMonitor( 67 | cache giftInterfaces.GiftCache, 68 | manager giftInterfaces.Giftmanager, 69 | validator giftInterfaces.GiftValidator, 70 | notification giftInterfaces.NotificationService, 71 | tickTime time.Duration, 72 | errorLogsWriter giftInterfaces.ErrorLogger, 73 | infoLogsWriter giftInterfaces.InfoLogger, 74 | testMode bool, 75 | ) *giftMonitorImpl { 76 | return &giftMonitorImpl{ 77 | cache: cache, 78 | manager: manager, 79 | validator: validator, 80 | notification: notification, 81 | ticker: time.NewTicker(tickTime), 82 | firstRun: true, 83 | errorLogsWriter: errorLogsWriter, 84 | infoLogsWriter: infoLogsWriter, 85 | testMode: testMode, 86 | } 87 | } 88 | 89 | // Start begins the gift monitoring process and returns newly discovered eligible gifts. 90 | // It runs continuously until the context is cancelled, checking for new gifts 91 | // at the configured interval. When eligible gifts are found, it sends notifications 92 | // and returns the gifts for purchase processing. 93 | // 94 | // The monitoring process: 95 | // 1. Waits for the next tick or context cancellation 96 | // 2. Checks for new gifts via the gift manager 97 | // 3. Validates new gifts against criteria 98 | // 4. Sends notifications for eligible gifts 99 | // 5. Returns eligible gifts for purchase 100 | // 101 | // Parameters: 102 | // - ctx: context for cancellation and timeout control 103 | // 104 | // Returns: 105 | // - map[*tg.StarGift]int64: map of eligible gifts to their purchase quantities 106 | // - error: monitoring error, API communication error, or context cancellation 107 | func (gm *giftMonitorImpl) Start(ctx context.Context) ([]*giftTypes.GiftRequire, error) { 108 | resultCh := make(chan []*giftTypes.GiftRequire, 10) 109 | errCh := make(chan error, 10) 110 | 111 | for { 112 | select { 113 | case <-ctx.Done(): 114 | return nil, ctx.Err() 115 | case <-gm.ticker.C: 116 | if gm.IsPaused() { 117 | continue 118 | } 119 | 120 | go func() { 121 | newGifts, err := gm.checkForNewGifts(ctx) 122 | if err != nil { 123 | errCh <- err 124 | return 125 | } 126 | if len(newGifts) == 0 { 127 | gm.infoLogsWriter.LogInfo("no new gifts found") 128 | return 129 | } 130 | resultCh <- newGifts 131 | }() 132 | case newGifts := <-resultCh: 133 | return newGifts, nil 134 | case err := <-errCh: 135 | if !gm.IsPaused() { 136 | if notifErr := gm.notification.SendErrorNotification(ctx, err); notifErr != nil { 137 | gm.errorLogsWriter.LogError(notifErr.Error()) 138 | } 139 | gm.errorLogsWriter.LogError(err.Error()) 140 | } 141 | continue 142 | } 143 | } 144 | } 145 | 146 | // checkForNewGifts retrieves current gifts and identifies new eligible ones. 147 | // It compares the current gift list against the cache to find new gifts, 148 | // validates them against criteria, and updates the cache. 149 | // 150 | // Parameters: 151 | // - ctx: context for API request cancellation 152 | // 153 | // Returns: 154 | // - map[*tg.StarGift]int64: map of new eligible gifts to purchase quantities 155 | // - error: API communication error or validation error 156 | func (gm *giftMonitorImpl) checkForNewGifts(ctx context.Context) ([]*giftTypes.GiftRequire, error) { 157 | currentGifts, err := gm.manager.GetAvailableGifts(ctx) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | newValidGifts := make([]*giftTypes.GiftRequire, 0, len(currentGifts)) 163 | 164 | for _, gift := range currentGifts { 165 | if gm.cache.HasGift(gift.ID) { 166 | continue 167 | } 168 | if giftRequire, ok := gm.validator.IsEligible(gift); ok { 169 | gm.infoLogsWriter.LogInfo(fmt.Sprintf("gift id %d is valid", gift.ID)) 170 | giftRequire.Gift = gift 171 | newValidGifts = append(newValidGifts, giftRequire) 172 | } 173 | 174 | gm.cache.SetGift(gift.ID, gift) 175 | } 176 | 177 | if gm.firstRun && !gm.testMode { 178 | gm.firstRun = false 179 | return nil, errors.Wrap(errors.New("first run"), "touch grass") 180 | } 181 | 182 | return newValidGifts, nil 183 | } 184 | 185 | // Pause pauses the gift monitoring process. 186 | // It stops the monitoring goroutine and prevents new gifts from being discovered. 187 | func (gm *giftMonitorImpl) Pause() { 188 | gm.mu.Lock() 189 | defer gm.mu.Unlock() 190 | if !gm.paused { 191 | gm.paused = true 192 | gm.infoLogsWriter.LogInfo("Gift monitoring paused") 193 | } 194 | } 195 | 196 | // Resume resumes the gift monitoring process. 197 | // It starts the monitoring goroutine and allows new gifts to be discovered. 198 | func (gm *giftMonitorImpl) Resume() { 199 | gm.mu.Lock() 200 | defer gm.mu.Unlock() 201 | if gm.paused { 202 | gm.paused = false 203 | gm.infoLogsWriter.LogInfo("Gift monitoring resumed") 204 | } 205 | } 206 | 207 | // IsPaused returns the status of the gift monitoring process. 208 | // 209 | // Returns: 210 | // - bool: true if the monitoring is paused, false if active 211 | func (gm *giftMonitorImpl) IsPaused() bool { 212 | gm.mu.RLock() 213 | defer gm.mu.RUnlock() 214 | return gm.paused 215 | } 216 | -------------------------------------------------------------------------------- /internal/usecase/usecase.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "gift-buyer/internal/infrastructure/gitVersion/gitInterfaces" 7 | "gift-buyer/internal/service/giftService/giftInterfaces" 8 | "gift-buyer/pkg/logger" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gotd/td/tg" 13 | ) 14 | 15 | // UseCase defines the main interface for the gift buying service. 16 | // It provides lifecycle management methods for starting and stopping the service. 17 | type UseCase interface { 18 | // Start begins the gift monitoring and purchasing process. 19 | // This method runs continuously until stopped or context cancelled. 20 | Start() 21 | 22 | // Stop gracefully shuts down the gift service and all its components. 23 | Stop() 24 | 25 | // SetIds sets the IDs of the accounts 26 | SetIds(ctx context.Context) error 27 | 28 | // CheckForUpdates checks for updates and sends a notification if available 29 | CheckForUpdates() 30 | } 31 | 32 | // useCaseImpl implements the UseCase interface and orchestrates all gift buying operations. 33 | // It manages the lifecycle of monitoring, validation, purchasing, and notification components, 34 | // providing a unified service that automatically discovers and purchases eligible gifts. 35 | type useCaseImpl struct { 36 | // manager handles gift retrieval and API communication 37 | manager giftInterfaces.Giftmanager 38 | 39 | // validator evaluates gifts against purchase criteria 40 | validator giftInterfaces.GiftValidator 41 | 42 | // cache provides persistent storage for processed gifts 43 | cache giftInterfaces.GiftCache 44 | 45 | // notification sends alerts about discoveries and purchase status 46 | notification giftInterfaces.NotificationService 47 | 48 | // monitor continuously checks for new eligible gifts 49 | monitor giftInterfaces.GiftMonitor 50 | 51 | // buyer handles the actual gift purchase transactions 52 | buyer giftInterfaces.GiftBuyer 53 | 54 | // ctx provides cancellation context for the service 55 | ctx context.Context 56 | 57 | // cancel function to stop the service gracefully 58 | cancel context.CancelFunc 59 | 60 | // wg coordinates graceful shutdown of goroutines 61 | wg sync.WaitGroup 62 | 63 | // api is the main Telegram client for API operations 64 | api *tg.Client 65 | 66 | // accountManager handles account-related operations 67 | accountManager giftInterfaces.AccountManager 68 | 69 | // version is the current version of the service 70 | gitVersion gitInterfaces.GitVersionController 71 | updateTicker *time.Ticker 72 | lastNotificationVersion string 73 | subFlag bool 74 | } 75 | 76 | // NewUseCase creates a new UseCase instance with all required dependencies. 77 | // It wires together all components needed for automated gift buying operations. 78 | // 79 | // Parameters: 80 | // - manager: gift manager for API communication 81 | // - validator: gift validator for eligibility checking 82 | // - cache: gift cache for state persistence 83 | // - notification: notification service for alerts 84 | // - monitor: gift monitor for continuous discovery 85 | // - buyer: gift buyer for purchase operations 86 | // - ctx: context for cancellation control 87 | // - cancel: cancel function for graceful shutdown 88 | // - api: Telegram API client 89 | // 90 | // Returns: 91 | // - GiftService: configured gift service ready for operation 92 | func NewUseCase( 93 | manager giftInterfaces.Giftmanager, 94 | validator giftInterfaces.GiftValidator, 95 | cache giftInterfaces.GiftCache, 96 | notification giftInterfaces.NotificationService, 97 | monitor giftInterfaces.GiftMonitor, 98 | buyer giftInterfaces.GiftBuyer, 99 | ctx context.Context, 100 | cancel context.CancelFunc, 101 | api *tg.Client, 102 | accountManager giftInterfaces.AccountManager, 103 | gitVersion gitInterfaces.GitVersionController, 104 | updateTicker *time.Ticker, 105 | ) UseCase { 106 | return &useCaseImpl{ 107 | manager: manager, 108 | validator: validator, 109 | cache: cache, 110 | notification: notification, 111 | monitor: monitor, 112 | buyer: buyer, 113 | ctx: ctx, 114 | cancel: cancel, 115 | api: api, 116 | accountManager: accountManager, 117 | gitVersion: gitVersion, 118 | updateTicker: updateTicker, 119 | subFlag: false, 120 | } 121 | } 122 | 123 | // Start begins the main gift buying service loop. 124 | // It continuously monitors for new gifts, validates them against criteria, 125 | // and automatically purchases eligible gifts until the service is stopped. 126 | // 127 | // The service loop: 128 | // 1. Monitors for new eligible gifts 129 | // 2. Validates discovered gifts against criteria 130 | // 3. Attempts to purchase eligible gifts 131 | // 4. Handles errors and continues operation 132 | // 5. Respects context cancellation for graceful shutdown 133 | // 134 | // This method blocks until the service is stopped or context is cancelled. 135 | func (tc *useCaseImpl) Start() { 136 | for { 137 | select { 138 | case <-tc.ctx.Done(): 139 | tc.wg.Wait() 140 | return 141 | default: 142 | newGifts, err := tc.monitor.Start(tc.ctx) 143 | if err != nil { 144 | if tc.ctx.Err() != nil { 145 | logger.GlobalLogger.Info("Context cancelled, stopping service") 146 | tc.wg.Wait() 147 | return 148 | } 149 | logger.GlobalLogger.Error("Error checking for new gifts", "error", err) 150 | continue 151 | } 152 | 153 | if len(newGifts) > 0 { 154 | logger.GlobalLogger.Infof("Found %d new gift types to process", len(newGifts)) 155 | tc.wg.Add(2) 156 | go func() { 157 | defer tc.wg.Done() 158 | for _, require := range newGifts { 159 | if err := tc.notification.SendNewGiftNotification(tc.ctx, require.Gift); err != nil { 160 | logger.GlobalLogger.Errorf("Error sending notification: %v, gift_id: %d, count: %d", err, require.Gift.ID, require.CountForBuy) 161 | } 162 | } 163 | }() 164 | go func() { 165 | defer tc.wg.Done() 166 | tc.buyer.BuyGift(tc.ctx, newGifts) 167 | }() 168 | 169 | continue 170 | } 171 | } 172 | } 173 | } 174 | 175 | // Stop gracefully shuts down the gift service. 176 | // It cancels the service context and waits for all goroutines to complete 177 | // before returning, ensuring clean shutdown of all components. 178 | func (tc *useCaseImpl) Stop() { 179 | if tc.cancel != nil { 180 | tc.cancel() 181 | } 182 | tc.wg.Wait() 183 | 184 | if tc.buyer != nil { 185 | tc.buyer.Close() 186 | } 187 | } 188 | 189 | func (tc *useCaseImpl) SetIds(ctx context.Context) error { 190 | return tc.accountManager.SetIds(ctx) 191 | } 192 | 193 | func (tc *useCaseImpl) CheckForUpdates() { 194 | if err := tc.checkNewUpdates(); err != nil { 195 | logger.GlobalLogger.Errorf("Error checking for updates: %v", err) 196 | } 197 | for { 198 | select { 199 | case <-tc.ctx.Done(): 200 | return 201 | case <-tc.updateTicker.C: 202 | if err := tc.checkNewUpdates(); err != nil { 203 | logger.GlobalLogger.Errorf("Error checking for updates: %v", err) 204 | } 205 | } 206 | } 207 | } 208 | 209 | func (tc *useCaseImpl) checkNewUpdates() error { 210 | localVersion, err := tc.gitVersion.GetCurrentVersion() 211 | if err != nil { 212 | logger.GlobalLogger.Errorf("Error getting current version: %v", err) 213 | return err 214 | } 215 | 216 | remoteVersion, err := tc.gitVersion.GetLatestVersion() 217 | if err != nil { 218 | logger.GlobalLogger.Errorf("Error getting latest version: %v", err) 219 | return err 220 | } 221 | 222 | ok, err := tc.gitVersion.CompareVersions(localVersion, remoteVersion.TagName) 223 | if err != nil { 224 | logger.GlobalLogger.Errorf("Error comparing versions: %v", err) 225 | return err 226 | } 227 | 228 | if ok && tc.lastNotificationVersion != remoteVersion.TagName { 229 | if err := tc.notification.SendUpdateNotification(tc.ctx, remoteVersion.TagName, fmt.Sprintf("%s\n", remoteVersion.Body)); err != nil { 230 | logger.GlobalLogger.Errorf("Error sending update notification: %v", err) 231 | } 232 | tc.lastNotificationVersion = remoteVersion.TagName 233 | } 234 | return nil 235 | } 236 | -------------------------------------------------------------------------------- /internal/service/authService/authManager_test.go: -------------------------------------------------------------------------------- 1 | package authService 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "gift-buyer/internal/config" 8 | "gift-buyer/internal/infrastructure/logsWriter/logTypes" 9 | 10 | "github.com/gotd/td/telegram" 11 | "github.com/gotd/td/tg" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // MockLogsWriter для тестирования 16 | type MockLogsWriter struct{} 17 | 18 | func (m *MockLogsWriter) Write(entry *logTypes.LogEntry) error { 19 | return nil 20 | } 21 | 22 | func (m *MockLogsWriter) LogError(message string) {} 23 | 24 | func (m *MockLogsWriter) LogErrorf(format string, args ...interface{}) {} 25 | 26 | func (m *MockLogsWriter) LogInfo(message string) {} 27 | 28 | func TestNewAuthManager(t *testing.T) { 29 | sessionManager := &MockSessionManager{} 30 | apiChecker := &MockApiChecker{} 31 | tgSettings := &config.TgSettings{ 32 | AppId: 123456, 33 | ApiHash: "test_hash", 34 | Phone: "+1234567890", 35 | Password: "test_password", 36 | } 37 | mockInfoWriter := &MockLogsWriter{} 38 | mockErrorWriter := &MockLogsWriter{} 39 | 40 | authManager := NewAuthManager(sessionManager, apiChecker, tgSettings, mockInfoWriter, mockErrorWriter) 41 | 42 | assert.NotNil(t, authManager) 43 | assert.Equal(t, sessionManager, authManager.sessionManager) 44 | assert.Equal(t, apiChecker, authManager.apiChecker) 45 | assert.Equal(t, tgSettings, authManager.cfg) 46 | } 47 | 48 | func TestNewAuthManager_NilDependencies(t *testing.T) { 49 | mockInfoWriter := &MockLogsWriter{} 50 | mockErrorWriter := &MockLogsWriter{} 51 | 52 | // Test with nil session manager 53 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 54 | assert.NotNil(t, authManager) 55 | 56 | assert.Nil(t, authManager.sessionManager) 57 | assert.Nil(t, authManager.apiChecker) 58 | assert.Nil(t, authManager.cfg) 59 | } 60 | 61 | func TestAuthManagerImpl_SetApiChecker(t *testing.T) { 62 | sessionManager := &MockSessionManager{} 63 | tgSettings := &config.TgSettings{ 64 | AppId: 123456, 65 | ApiHash: "test_hash", 66 | Phone: "+1234567890", 67 | } 68 | mockInfoWriter := &MockLogsWriter{} 69 | mockErrorWriter := &MockLogsWriter{} 70 | 71 | authManager := NewAuthManager(sessionManager, nil, tgSettings, mockInfoWriter, mockErrorWriter) 72 | 73 | // Initially should be nil 74 | assert.Nil(t, authManager.apiChecker) 75 | 76 | // Set api checker 77 | apiChecker := &MockApiChecker{} 78 | authManager.SetApiChecker(apiChecker) 79 | 80 | // Should now be set 81 | assert.Equal(t, apiChecker, authManager.apiChecker) 82 | } 83 | 84 | func TestAuthManagerImpl_SetMonitor(t *testing.T) { 85 | sessionManager := &MockSessionManager{} 86 | tgSettings := &config.TgSettings{ 87 | AppId: 123456, 88 | ApiHash: "test_hash", 89 | Phone: "+1234567890", 90 | } 91 | mockInfoWriter := &MockLogsWriter{} 92 | mockErrorWriter := &MockLogsWriter{} 93 | 94 | authManager := NewAuthManager(sessionManager, nil, tgSettings, mockInfoWriter, mockErrorWriter) 95 | 96 | // Initially should be nil 97 | assert.Nil(t, authManager.monitor) 98 | 99 | // Set monitor 100 | monitor := &MockGiftMonitor{} 101 | authManager.SetMonitor(monitor) 102 | 103 | // Should now be set 104 | assert.Equal(t, monitor, authManager.monitor) 105 | } 106 | 107 | func TestAuthManagerImpl_InitClient_NilSettings(t *testing.T) { 108 | mockInfoWriter := &MockLogsWriter{} 109 | mockErrorWriter := &MockLogsWriter{} 110 | 111 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 112 | 113 | ctx := context.Background() 114 | 115 | // Должен вернуть ошибку без паники при nil настройках 116 | assert.NotPanics(t, func() { 117 | client, err := authManager.InitClient(ctx) 118 | assert.Error(t, err) 119 | assert.Nil(t, client) 120 | }) 121 | } 122 | 123 | func TestAuthManagerImpl_InitBotClient_NilSettings(t *testing.T) { 124 | mockInfoWriter := &MockLogsWriter{} 125 | mockErrorWriter := &MockLogsWriter{} 126 | 127 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 128 | 129 | ctx := context.Background() 130 | client, err := authManager.InitBotClient(ctx) 131 | 132 | assert.Error(t, err) 133 | assert.Nil(t, client) 134 | } 135 | 136 | func TestAuthManagerImpl_InitBotClient_NilSessionManager(t *testing.T) { 137 | tgSettings := &config.TgSettings{ 138 | AppId: 123456, 139 | ApiHash: "test_hash", 140 | Phone: "+1234567890", 141 | TgBotKey: "test_bot_key", 142 | } 143 | mockInfoWriter := &MockLogsWriter{} 144 | mockErrorWriter := &MockLogsWriter{} 145 | 146 | authManager := NewAuthManager(nil, nil, tgSettings, mockInfoWriter, mockErrorWriter) 147 | 148 | ctx := context.Background() 149 | client, err := authManager.InitBotClient(ctx) 150 | 151 | assert.Error(t, err) 152 | assert.Nil(t, client) 153 | } 154 | 155 | func TestAuthManagerImpl_RunApiChecker_NilApiChecker(t *testing.T) { 156 | sessionManager := &MockSessionManager{} 157 | tgSettings := &config.TgSettings{ 158 | AppId: 123456, 159 | ApiHash: "test_hash", 160 | Phone: "+1234567890", 161 | } 162 | mockInfoWriter := &MockLogsWriter{} 163 | mockErrorWriter := &MockLogsWriter{} 164 | 165 | authManager := NewAuthManager(sessionManager, nil, tgSettings, mockInfoWriter, mockErrorWriter) 166 | 167 | ctx := context.Background() 168 | 169 | // Should not panic with nil api checker 170 | assert.NotPanics(t, func() { 171 | authManager.RunApiChecker(ctx) 172 | }) 173 | } 174 | 175 | func TestAuthManagerImpl_RunApiChecker_WithApiChecker(t *testing.T) { 176 | sessionManager := &MockSessionManager{} 177 | apiChecker := &MockApiChecker{} 178 | tgSettings := &config.TgSettings{ 179 | AppId: 123456, 180 | ApiHash: "test_hash", 181 | Phone: "+1234567890", 182 | } 183 | mockInfoWriter := &MockLogsWriter{} 184 | mockErrorWriter := &MockLogsWriter{} 185 | 186 | authManager := NewAuthManager(sessionManager, apiChecker, tgSettings, mockInfoWriter, mockErrorWriter) 187 | 188 | ctx := context.Background() 189 | 190 | // Should not panic with api checker 191 | assert.NotPanics(t, func() { 192 | authManager.RunApiChecker(ctx) 193 | }) 194 | } 195 | 196 | func TestAuthManagerImpl_GetApi(t *testing.T) { 197 | mockInfoWriter := &MockLogsWriter{} 198 | mockErrorWriter := &MockLogsWriter{} 199 | 200 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 201 | 202 | // Initially should be nil 203 | api := authManager.GetApi() 204 | assert.Nil(t, api) 205 | } 206 | 207 | func TestAuthManagerImpl_GetBotApi(t *testing.T) { 208 | mockInfoWriter := &MockLogsWriter{} 209 | mockErrorWriter := &MockLogsWriter{} 210 | 211 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 212 | 213 | // Initially should be nil 214 | botApi := authManager.GetBotApi() 215 | assert.Nil(t, botApi) 216 | } 217 | 218 | func TestAuthManagerImpl_Stop(t *testing.T) { 219 | mockInfoWriter := &MockLogsWriter{} 220 | mockErrorWriter := &MockLogsWriter{} 221 | 222 | authManager := NewAuthManager(nil, nil, nil, mockInfoWriter, mockErrorWriter) 223 | 224 | // Should not panic 225 | assert.NotPanics(t, func() { 226 | authManager.Stop() 227 | }) 228 | } 229 | 230 | // Mock implementations for testing 231 | 232 | type MockSessionManager struct{} 233 | 234 | func (m *MockSessionManager) InitUserAPI(client *telegram.Client, ctx context.Context) (*tg.Client, error) { 235 | return nil, assert.AnError 236 | } 237 | 238 | func (m *MockSessionManager) InitBotAPI(ctx context.Context) (*tg.Client, error) { 239 | return nil, assert.AnError 240 | } 241 | 242 | func (m *MockSessionManager) CreateDeviceConfig() telegram.DeviceConfig { 243 | return telegram.DeviceConfig{ 244 | DeviceModel: "Test Device", 245 | SystemVersion: "Test OS", 246 | AppVersion: "1.0.0", 247 | SystemLangCode: "en", 248 | LangCode: "en", 249 | } 250 | } 251 | 252 | type MockApiChecker struct{} 253 | 254 | func (m *MockApiChecker) Run(ctx context.Context) error { 255 | return nil 256 | } 257 | 258 | func (m *MockApiChecker) Stop() { 259 | // Mock implementation 260 | } 261 | 262 | type MockGiftMonitor struct{} 263 | 264 | func (m *MockGiftMonitor) Pause() {} 265 | 266 | func (m *MockGiftMonitor) Resume() {} 267 | 268 | func (m *MockGiftMonitor) IsPaused() bool { 269 | return false 270 | } 271 | -------------------------------------------------------------------------------- /internal/service/giftService/giftBuyer/atomicCounter/counter_test.go: -------------------------------------------------------------------------------- 1 | package atomicCounter 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewAtomicCounter(t *testing.T) { 11 | t.Run("создание нового счетчика", func(t *testing.T) { 12 | max := int64(100) 13 | counter := NewAtomicCounter(max) 14 | 15 | assert.NotNil(t, counter) 16 | assert.Equal(t, int64(0), counter.Get()) 17 | assert.Equal(t, max, counter.GetMax()) 18 | }) 19 | 20 | t.Run("создание счетчика с нулевым максимумом", func(t *testing.T) { 21 | counter := NewAtomicCounter(0) 22 | 23 | assert.NotNil(t, counter) 24 | assert.Equal(t, int64(0), counter.Get()) 25 | assert.Equal(t, int64(0), counter.GetMax()) 26 | }) 27 | 28 | t.Run("создание счетчика с отрицательным максимумом", func(t *testing.T) { 29 | counter := NewAtomicCounter(-5) 30 | 31 | assert.NotNil(t, counter) 32 | assert.Equal(t, int64(0), counter.Get()) 33 | assert.Equal(t, int64(-5), counter.GetMax()) 34 | }) 35 | } 36 | 37 | func TestAtomicCounter_TryIncrement(t *testing.T) { 38 | t.Run("успешное увеличение счетчика", func(t *testing.T) { 39 | counter := NewAtomicCounter(5) 40 | 41 | // Первое увеличение должно быть успешным 42 | result := counter.TryIncrement() 43 | assert.True(t, result) 44 | assert.Equal(t, int64(1), counter.Get()) 45 | 46 | // Второе увеличение тоже должно быть успешным 47 | result = counter.TryIncrement() 48 | assert.True(t, result) 49 | assert.Equal(t, int64(2), counter.Get()) 50 | }) 51 | 52 | t.Run("достижение максимального значения", func(t *testing.T) { 53 | counter := NewAtomicCounter(2) 54 | 55 | // Увеличиваем до максимума 56 | assert.True(t, counter.TryIncrement()) 57 | assert.True(t, counter.TryIncrement()) 58 | assert.Equal(t, int64(2), counter.Get()) 59 | 60 | // Попытка увеличить сверх максимума должна провалиться 61 | result := counter.TryIncrement() 62 | assert.False(t, result) 63 | assert.Equal(t, int64(2), counter.Get()) 64 | }) 65 | 66 | t.Run("счетчик с максимумом 0", func(t *testing.T) { 67 | counter := NewAtomicCounter(0) 68 | 69 | // Любая попытка увеличения должна провалиться 70 | result := counter.TryIncrement() 71 | assert.False(t, result) 72 | assert.Equal(t, int64(0), counter.Get()) 73 | }) 74 | 75 | t.Run("счетчик с отрицательным максимумом", func(t *testing.T) { 76 | counter := NewAtomicCounter(-1) 77 | 78 | // Любая попытка увеличения должна провалиться 79 | result := counter.TryIncrement() 80 | assert.False(t, result) 81 | assert.Equal(t, int64(0), counter.Get()) 82 | }) 83 | } 84 | 85 | func TestAtomicCounter_Decrement(t *testing.T) { 86 | t.Run("уменьшение счетчика", func(t *testing.T) { 87 | counter := NewAtomicCounter(10) 88 | 89 | // Увеличиваем счетчик 90 | counter.TryIncrement() 91 | counter.TryIncrement() 92 | assert.Equal(t, int64(2), counter.Get()) 93 | 94 | // Уменьшаем счетчик 95 | counter.Decrement() 96 | assert.Equal(t, int64(1), counter.Get()) 97 | 98 | counter.Decrement() 99 | assert.Equal(t, int64(0), counter.Get()) 100 | }) 101 | 102 | t.Run("уменьшение ниже нуля", func(t *testing.T) { 103 | counter := NewAtomicCounter(10) 104 | 105 | // Уменьшаем счетчик ниже нуля 106 | counter.Decrement() 107 | assert.Equal(t, int64(-1), counter.Get()) 108 | 109 | counter.Decrement() 110 | assert.Equal(t, int64(-2), counter.Get()) 111 | }) 112 | } 113 | 114 | func TestAtomicCounter_ConcurrentAccess(t *testing.T) { 115 | t.Run("конкурентное увеличение счетчика", func(t *testing.T) { 116 | max := int64(1000) 117 | counter := NewAtomicCounter(max) 118 | numGoroutines := 100 119 | incrementsPerGoroutine := 10 120 | 121 | var wg sync.WaitGroup 122 | successCount := int64(0) 123 | var successMutex sync.Mutex 124 | 125 | for i := 0; i < numGoroutines; i++ { 126 | wg.Add(1) 127 | go func() { 128 | defer wg.Done() 129 | localSuccess := int64(0) 130 | for j := 0; j < incrementsPerGoroutine; j++ { 131 | if counter.TryIncrement() { 132 | localSuccess++ 133 | } 134 | } 135 | successMutex.Lock() 136 | successCount += localSuccess 137 | successMutex.Unlock() 138 | }() 139 | } 140 | 141 | wg.Wait() 142 | 143 | // Проверяем что счетчик не превысил максимум 144 | assert.True(t, counter.Get() <= max) 145 | assert.Equal(t, counter.Get(), successCount) 146 | assert.True(t, successCount <= max) 147 | }) 148 | 149 | t.Run("конкурентное увеличение и уменьшение", func(t *testing.T) { 150 | counter := NewAtomicCounter(100) 151 | numGoroutines := 50 152 | 153 | var wg sync.WaitGroup 154 | 155 | // Горутины для увеличения 156 | for i := 0; i < numGoroutines; i++ { 157 | wg.Add(1) 158 | go func() { 159 | defer wg.Done() 160 | for j := 0; j < 10; j++ { 161 | counter.TryIncrement() 162 | } 163 | }() 164 | } 165 | 166 | // Горутины для уменьшения 167 | for i := 0; i < numGoroutines; i++ { 168 | wg.Add(1) 169 | go func() { 170 | defer wg.Done() 171 | for j := 0; j < 5; j++ { 172 | counter.Decrement() 173 | } 174 | }() 175 | } 176 | 177 | wg.Wait() 178 | 179 | // Проверяем что операции выполнились без гонок данных 180 | finalValue := counter.Get() 181 | assert.True(t, finalValue >= -250) // минимально возможное значение 182 | assert.True(t, finalValue <= 100) // максимально возможное значение 183 | }) 184 | 185 | t.Run("конкурентное достижение лимита", func(t *testing.T) { 186 | max := int64(10) 187 | counter := NewAtomicCounter(max) 188 | numGoroutines := 100 189 | 190 | var wg sync.WaitGroup 191 | successCount := int64(0) 192 | var successMutex sync.Mutex 193 | 194 | for i := 0; i < numGoroutines; i++ { 195 | wg.Add(1) 196 | go func() { 197 | defer wg.Done() 198 | if counter.TryIncrement() { 199 | successMutex.Lock() 200 | successCount++ 201 | successMutex.Unlock() 202 | } 203 | }() 204 | } 205 | 206 | wg.Wait() 207 | 208 | // Должно быть ровно max успешных увеличений 209 | assert.Equal(t, max, successCount) 210 | assert.Equal(t, max, counter.Get()) 211 | }) 212 | } 213 | 214 | func TestAtomicCounter_EdgeCases(t *testing.T) { 215 | t.Run("большие значения", func(t *testing.T) { 216 | max := int64(9223372036854775807) // максимальное значение int64 217 | counter := NewAtomicCounter(max) 218 | 219 | assert.Equal(t, int64(0), counter.Get()) 220 | assert.Equal(t, max, counter.GetMax()) 221 | 222 | // Должно успешно увеличиться 223 | result := counter.TryIncrement() 224 | assert.True(t, result) 225 | assert.Equal(t, int64(1), counter.Get()) 226 | }) 227 | 228 | t.Run("минимальные значения", func(t *testing.T) { 229 | max := int64(-9223372036854775808) // минимальное значение int64 230 | counter := NewAtomicCounter(max) 231 | 232 | assert.Equal(t, int64(0), counter.Get()) 233 | assert.Equal(t, max, counter.GetMax()) 234 | 235 | // Увеличение должно провалиться так как 0 > max 236 | result := counter.TryIncrement() 237 | assert.False(t, result) 238 | assert.Equal(t, int64(0), counter.Get()) 239 | }) 240 | } 241 | 242 | func TestAtomicCounter_Get(t *testing.T) { 243 | t.Run("получение текущего значения", func(t *testing.T) { 244 | counter := NewAtomicCounter(10) 245 | 246 | assert.Equal(t, int64(0), counter.Get()) 247 | 248 | counter.TryIncrement() 249 | assert.Equal(t, int64(1), counter.Get()) 250 | 251 | counter.TryIncrement() 252 | counter.TryIncrement() 253 | assert.Equal(t, int64(3), counter.Get()) 254 | 255 | counter.Decrement() 256 | assert.Equal(t, int64(2), counter.Get()) 257 | }) 258 | } 259 | 260 | func TestAtomicCounter_GetMax(t *testing.T) { 261 | t.Run("получение максимального значения", func(t *testing.T) { 262 | testCases := []int64{0, 1, 10, 100, 1000, -1, -100} 263 | 264 | for _, max := range testCases { 265 | counter := NewAtomicCounter(max) 266 | assert.Equal(t, max, counter.GetMax()) 267 | } 268 | }) 269 | } 270 | 271 | func BenchmarkAtomicCounter_TryIncrement(b *testing.B) { 272 | counter := NewAtomicCounter(int64(b.N)) 273 | 274 | b.ResetTimer() 275 | b.RunParallel(func(pb *testing.PB) { 276 | for pb.Next() { 277 | counter.TryIncrement() 278 | } 279 | }) 280 | } 281 | 282 | func BenchmarkAtomicCounter_Decrement(b *testing.B) { 283 | counter := NewAtomicCounter(1000000) 284 | 285 | b.ResetTimer() 286 | b.RunParallel(func(pb *testing.PB) { 287 | for pb.Next() { 288 | counter.Decrement() 289 | } 290 | }) 291 | } 292 | 293 | func BenchmarkAtomicCounter_Get(b *testing.B) { 294 | counter := NewAtomicCounter(1000000) 295 | 296 | b.ResetTimer() 297 | b.RunParallel(func(pb *testing.PB) { 298 | for pb.Next() { 299 | counter.Get() 300 | } 301 | }) 302 | } 303 | -------------------------------------------------------------------------------- /internal/service/giftService/giftNotification/notification.go: -------------------------------------------------------------------------------- 1 | // Package giftNotification provides notification functionality for the gift buying system. 2 | // It handles sending formatted notifications about new gifts and purchase status updates 3 | // through Telegram bot API with retry logic and error handling. 4 | package giftNotification 5 | 6 | import ( 7 | "context" 8 | "crypto/rand" 9 | "encoding/binary" 10 | "fmt" 11 | "gift-buyer/internal/config" 12 | "gift-buyer/internal/service/giftService/giftInterfaces" 13 | mathRand "math/rand" 14 | "strings" 15 | "time" 16 | 17 | "github.com/gotd/td/tg" 18 | ) 19 | 20 | // cryptoRandomInt63 генерирует криптографически стойкое случайное число 21 | func cryptoRandomInt63() int64 { 22 | var randomBytes [8]byte 23 | if _, err := rand.Read(randomBytes[:]); err != nil { 24 | // Fallback на math/rand если crypto/rand недоступен 25 | return mathRand.Int63() 26 | } 27 | // Безопасное преобразование с маскированием старшего бита 28 | val := binary.BigEndian.Uint64(randomBytes[:]) 29 | return int64(val >> 1) // Сдвиг вправо гарантирует положительное значение 30 | } 31 | 32 | // NotificationServiceImpl implements the NotificationService interface for sending 33 | // Telegram notifications about gift discoveries and purchase status updates. 34 | // It provides formatted messages with retry logic and flood protection. 35 | type notificationServiceImpl struct { 36 | // Bot is the Telegram bot client used for sending notifications 37 | Bot *tg.Client 38 | 39 | // Config contains Telegram settings including notification chat ID 40 | Config *config.TgSettings 41 | 42 | // logsWriter is used to write logs to a file 43 | errorLogsWriter giftInterfaces.ErrorLogger 44 | } 45 | 46 | // NewNotification creates a new NotificationService instance with the specified bot client and configuration. 47 | // The service will use the bot to send notifications to the configured chat. 48 | // 49 | // Parameters: 50 | // - bot: configured Telegram bot client for sending messages 51 | // - config: Telegram settings containing notification chat ID and other parameters 52 | // 53 | // Returns: 54 | // - giftInterfaces.NotificationService: configured notification service instance 55 | func NewNotification(bot *tg.Client, config *config.TgSettings, errorLogsWriter giftInterfaces.ErrorLogger) *notificationServiceImpl { 56 | return ¬ificationServiceImpl{ 57 | Bot: bot, 58 | Config: config, 59 | errorLogsWriter: errorLogsWriter, 60 | } 61 | } 62 | 63 | // sendNotification sends a message to the configured notification chat with retry logic. 64 | // It handles flood protection, implements exponential backoff, and provides error recovery. 65 | // 66 | // The retry mechanism: 67 | // - Maximum 3 retry attempts 68 | // - Special handling for FLOOD_WAIT errors with 5-second delay 69 | // - Exponential backoff for other errors (2, 4, 6 seconds) 70 | // - Logs errors and continues operation on failure 71 | // 72 | // Parameters: 73 | // - ctx: context for request cancellation and timeout control 74 | // - message: the message text to send 75 | // 76 | // Returns: 77 | // - error: notification sending error after all retries exhausted 78 | func (ns *notificationServiceImpl) sendNotification(ctx context.Context, message string) error { 79 | if ns.Bot == nil || ns.Config == nil || ns.Config.NotificationChatID == 0 { 80 | ns.errorLogsWriter.LogError("Bot client or notification chat ID not configured") 81 | return nil 82 | } 83 | 84 | maxRetries := 3 85 | for attempt := 0; attempt < maxRetries; attempt++ { 86 | _, err := ns.Bot.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ 87 | Peer: &tg.InputPeerUser{ 88 | UserID: ns.Config.NotificationChatID, 89 | }, 90 | Message: message, 91 | RandomID: cryptoRandomInt63(), 92 | }) 93 | 94 | if err == nil { 95 | return nil 96 | } 97 | 98 | if strings.Contains(err.Error(), "FLOOD_WAIT") { 99 | time.Sleep(5 * time.Second) 100 | continue 101 | } 102 | 103 | if attempt < maxRetries-1 { 104 | time.Sleep(time.Duration(attempt+1) * 2 * time.Second) 105 | continue 106 | } 107 | 108 | ns.errorLogsWriter.LogError(fmt.Sprintf("Failed to send notification: %v", err)) 109 | return err 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // SendNewGiftNotification sends a formatted notification about a newly discovered gift. 116 | // It creates a detailed message including gift information, pricing, availability, 117 | // and current timestamp for tracking purposes. 118 | // 119 | // The notification includes: 120 | // - Gift title and ID 121 | // - Total and available supply with percentage 122 | // - Purchase price and conversion price in stars 123 | // - Current UTC timestamp 124 | // - Formatted numbers for better readability 125 | // 126 | // Parameters: 127 | // - ctx: context for request cancellation and timeout control 128 | // - gift: the star gift to notify about 129 | // 130 | // Returns: 131 | // - error: notification sending error or formatting error 132 | func (ns *notificationServiceImpl) SendNewGiftNotification(ctx context.Context, gift *tg.StarGift) error { 133 | giftTitle, hasTitle := gift.GetTitle() 134 | if !hasTitle { 135 | giftTitle = "Unknown Gift" 136 | } 137 | 138 | giftID := gift.GetID() 139 | giftPrice := gift.GetStars() 140 | convertPrice := gift.GetConvertStars() 141 | giftSupply, hasSupply := gift.GetAvailabilityTotal() 142 | 143 | var availableAmount int 144 | var percentage float64 145 | 146 | if gift.Limited { 147 | remains, hasRemains := gift.GetAvailabilityRemains() 148 | if hasRemains { 149 | availableAmount = remains 150 | if hasSupply && giftSupply > 0 { 151 | percentage = float64(remains) / float64(giftSupply) * 100 152 | } 153 | } 154 | } else { 155 | availableAmount = giftSupply 156 | percentage = 100.0 157 | } 158 | 159 | currentTime := time.Now().UTC().Format("02-01-2006 15:04:05") 160 | 161 | message := fmt.Sprintf(`🎁 New gift detected! 162 | %s (%d) 163 | 164 | 🎯 Total amount: %s 165 | ❓ Available amount: %d (%.0f%%, updated at %s UTC) 166 | 167 | 💎 Price: %s ⭐️ 168 | ♻️ Convert price: %s ⭐️`, 169 | giftTitle, 170 | giftID, 171 | formatNumber(giftSupply), 172 | availableAmount, 173 | percentage, 174 | currentTime, 175 | formatNumber(int(giftPrice)), 176 | formatNumber(int(convertPrice)), 177 | ) 178 | 179 | return ns.sendNotification(ctx, message) 180 | } 181 | 182 | // SendBuyStatus sends a notification about the purchase operation status. 183 | // It reports successful purchases or error conditions with appropriate formatting 184 | // and emoji indicators for quick visual identification. 185 | // 186 | // Parameters: 187 | // - ctx: context for request cancellation and timeout control 188 | // - status: human-readable status message describing the operation result 189 | // - err: error that occurred during purchase (nil for successful operations) 190 | // 191 | // Returns: 192 | // - error: notification sending error 193 | func (ns *notificationServiceImpl) SendBuyStatus(ctx context.Context, status string, err error) error { 194 | var message string 195 | if err != nil { 196 | message = fmt.Sprintf("📊 Buy Status: %s\n❌ Error: %s", status, err.Error()) 197 | } else { 198 | message = fmt.Sprintf("📊 Buy Status: %s\n✅ Success", status) 199 | } 200 | 201 | return ns.sendNotification(ctx, message) 202 | } 203 | 204 | func (ns *notificationServiceImpl) SendErrorNotification(ctx context.Context, err error) error { 205 | ns.errorLogsWriter.LogError(err.Error()) 206 | return ns.sendNotification(ctx, err.Error()) 207 | } 208 | 209 | // SetBot sets the bot client 210 | func (ns *notificationServiceImpl) SetBot() bool { 211 | return ns.Bot != nil 212 | } 213 | 214 | func (ns *notificationServiceImpl) SendUpdateNotification(ctx context.Context, version, message string) error { 215 | return ns.sendNotification(ctx, fmt.Sprintf("🆕 New version available: %s\n%s", version, message)) 216 | } 217 | 218 | // formatNumber formats integers with comma separators for better readability. 219 | // It adds commas every three digits to make large numbers easier to read. 220 | // 221 | // Examples: 222 | // - 1000 -> "1,000" 223 | // - 1234567 -> "1,234,567" 224 | // - 0 -> "0" 225 | // 226 | // Parameters: 227 | // - num: the integer to format 228 | // 229 | // Returns: 230 | // - string: formatted number with comma separators 231 | func formatNumber(num int) string { 232 | if num == 0 { 233 | return "0" 234 | } 235 | 236 | str := fmt.Sprintf("%d", num) 237 | if len(str) <= 3 { 238 | return str 239 | } 240 | 241 | result := "" 242 | for i, digit := range str { 243 | if i > 0 && (len(str)-i)%3 == 0 { 244 | result += "," 245 | } 246 | result += string(digit) 247 | } 248 | 249 | return result 250 | } 251 | -------------------------------------------------------------------------------- /pkg/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestParseLevel(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | input string 16 | expected LoggerLevel 17 | }{ 18 | {"debug level", "debug", DebugLevel}, 19 | {"info level", "info", InfoLevel}, 20 | {"warn level", "warn", WarnLevel}, 21 | {"warning level", "warning", WarnLevel}, 22 | {"error level", "error", ErrorLevel}, 23 | {"fatal level", "fatal", FatalLevel}, 24 | {"panic level", "panic", PanicLevel}, 25 | {"uppercase debug", "DEBUG", DebugLevel}, 26 | {"mixed case info", "InFo", InfoLevel}, 27 | {"invalid level defaults to info", "invalid", InfoLevel}, 28 | {"empty string defaults to info", "", InfoLevel}, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | result := ParseLevel(tt.input) 34 | assert.Equal(t, tt.expected, result) 35 | }) 36 | } 37 | } 38 | 39 | func TestNew(t *testing.T) { 40 | tests := []struct { 41 | name string 42 | level LoggerLevel 43 | }{ 44 | {"debug logger", DebugLevel}, 45 | {"info logger", InfoLevel}, 46 | {"warn logger", WarnLevel}, 47 | {"error logger", ErrorLevel}, 48 | {"fatal logger", FatalLevel}, 49 | {"panic logger", PanicLevel}, 50 | } 51 | 52 | for _, tt := range tests { 53 | t.Run(tt.name, func(t *testing.T) { 54 | logger := New(tt.level) 55 | assert.NotNil(t, logger) 56 | assert.Implements(t, (*Logger)(nil), logger) 57 | }) 58 | } 59 | } 60 | 61 | func TestLoggerMethods(t *testing.T) { 62 | // Create a buffer to capture log output 63 | var buf bytes.Buffer 64 | 65 | // Create a custom logrus logger for testing 66 | logrusLogger := logrus.New() 67 | logrusLogger.SetOutput(&buf) 68 | logrusLogger.SetFormatter(&logrus.TextFormatter{ 69 | DisableTimestamp: true, 70 | DisableColors: true, 71 | }) 72 | logrusLogger.SetLevel(logrus.DebugLevel) 73 | 74 | logger := &logger{Logger: logrusLogger} 75 | 76 | tests := []struct { 77 | name string 78 | logFunc func() 79 | expected string 80 | level string 81 | }{ 82 | { 83 | name: "debug message", 84 | logFunc: func() { 85 | buf.Reset() 86 | logger.Debug("debug message") 87 | }, 88 | expected: "debug message", 89 | level: "debug", 90 | }, 91 | { 92 | name: "info message", 93 | logFunc: func() { 94 | buf.Reset() 95 | logger.Info("info message") 96 | }, 97 | expected: "info message", 98 | level: "info", 99 | }, 100 | { 101 | name: "warn message", 102 | logFunc: func() { 103 | buf.Reset() 104 | logger.Warn("warn message") 105 | }, 106 | expected: "warn message", 107 | level: "warning", 108 | }, 109 | { 110 | name: "error message", 111 | logFunc: func() { 112 | buf.Reset() 113 | logger.Error("error message") 114 | }, 115 | expected: "error message", 116 | level: "error", 117 | }, 118 | } 119 | 120 | for _, tt := range tests { 121 | t.Run(tt.name, func(t *testing.T) { 122 | tt.logFunc() 123 | output := buf.String() 124 | assert.Contains(t, strings.ToLower(output), tt.level) 125 | assert.Contains(t, output, tt.expected) 126 | }) 127 | } 128 | } 129 | 130 | func TestLoggerFormattedMethods(t *testing.T) { 131 | var buf bytes.Buffer 132 | 133 | logrusLogger := logrus.New() 134 | logrusLogger.SetOutput(&buf) 135 | logrusLogger.SetFormatter(&logrus.TextFormatter{ 136 | DisableTimestamp: true, 137 | DisableColors: true, 138 | }) 139 | logrusLogger.SetLevel(logrus.DebugLevel) 140 | 141 | logger := &logger{Logger: logrusLogger} 142 | 143 | tests := []struct { 144 | name string 145 | logFunc func() 146 | expected string 147 | level string 148 | }{ 149 | { 150 | name: "debugf message", 151 | logFunc: func() { 152 | buf.Reset() 153 | logger.Debugf("debug %s %d", "test", 123) 154 | }, 155 | expected: "debug test 123", 156 | level: "debug", 157 | }, 158 | { 159 | name: "infof message", 160 | logFunc: func() { 161 | buf.Reset() 162 | logger.Infof("info %s %d", "test", 456) 163 | }, 164 | expected: "info test 456", 165 | level: "info", 166 | }, 167 | { 168 | name: "warnf message", 169 | logFunc: func() { 170 | buf.Reset() 171 | logger.Warnf("warn %s %d", "test", 789) 172 | }, 173 | expected: "warn test 789", 174 | level: "warning", 175 | }, 176 | { 177 | name: "errorf message", 178 | logFunc: func() { 179 | buf.Reset() 180 | logger.Errorf("error %s %d", "test", 999) 181 | }, 182 | expected: "error test 999", 183 | level: "error", 184 | }, 185 | } 186 | 187 | for _, tt := range tests { 188 | t.Run(tt.name, func(t *testing.T) { 189 | tt.logFunc() 190 | output := buf.String() 191 | assert.Contains(t, strings.ToLower(output), tt.level) 192 | assert.Contains(t, output, tt.expected) 193 | }) 194 | } 195 | } 196 | 197 | func TestWithFields(t *testing.T) { 198 | var buf bytes.Buffer 199 | 200 | logrusLogger := logrus.New() 201 | logrusLogger.SetOutput(&buf) 202 | logrusLogger.SetFormatter(&logrus.JSONFormatter{}) 203 | logrusLogger.SetLevel(logrus.InfoLevel) 204 | 205 | logger := &logger{Logger: logrusLogger} 206 | 207 | fields := Fields{ 208 | "user_id": 123, 209 | "action": "login", 210 | "ip": "192.168.1.1", 211 | } 212 | 213 | fieldLogger := logger.WithFields(fields) 214 | assert.NotNil(t, fieldLogger) 215 | assert.Implements(t, (*Logger)(nil), fieldLogger) 216 | 217 | fieldLogger.Info("user logged in") 218 | output := buf.String() 219 | 220 | // Check that all fields are present in the JSON output 221 | assert.Contains(t, output, "user_id") 222 | assert.Contains(t, output, "123") 223 | assert.Contains(t, output, "action") 224 | assert.Contains(t, output, "login") 225 | assert.Contains(t, output, "ip") 226 | assert.Contains(t, output, "192.168.1.1") 227 | assert.Contains(t, output, "user logged in") 228 | } 229 | 230 | func TestLogLevels(t *testing.T) { 231 | tests := []struct { 232 | name string 233 | loggerLevel LoggerLevel 234 | logLevel string 235 | shouldLog bool 236 | }{ 237 | {"debug logger logs debug", DebugLevel, "debug", true}, 238 | {"debug logger logs info", DebugLevel, "info", true}, 239 | {"debug logger logs warn", DebugLevel, "warn", true}, 240 | {"debug logger logs error", DebugLevel, "error", true}, 241 | {"info logger skips debug", InfoLevel, "debug", false}, 242 | {"info logger logs info", InfoLevel, "info", true}, 243 | {"info logger logs warn", InfoLevel, "warn", true}, 244 | {"info logger logs error", InfoLevel, "error", true}, 245 | {"warn logger skips debug", WarnLevel, "debug", false}, 246 | {"warn logger skips info", WarnLevel, "info", false}, 247 | {"warn logger logs warn", WarnLevel, "warn", true}, 248 | {"warn logger logs error", WarnLevel, "error", true}, 249 | {"error logger skips debug", ErrorLevel, "debug", false}, 250 | {"error logger skips info", ErrorLevel, "info", false}, 251 | {"error logger skips warn", ErrorLevel, "warn", false}, 252 | {"error logger logs error", ErrorLevel, "error", true}, 253 | } 254 | 255 | for _, tt := range tests { 256 | t.Run(tt.name, func(t *testing.T) { 257 | var buf bytes.Buffer 258 | 259 | logrusLogger := logrus.New() 260 | logrusLogger.SetOutput(&buf) 261 | logrusLogger.SetFormatter(&logrus.TextFormatter{ 262 | DisableTimestamp: true, 263 | DisableColors: true, 264 | }) 265 | 266 | // Set the logger level 267 | logrusLevel, _ := logrus.ParseLevel(string(tt.loggerLevel)) 268 | logrusLogger.SetLevel(logrusLevel) 269 | 270 | logger := &logger{Logger: logrusLogger} 271 | 272 | // Log at the specified level 273 | switch tt.logLevel { 274 | case "debug": 275 | logger.Debug("test message") 276 | case "info": 277 | logger.Info("test message") 278 | case "warn": 279 | logger.Warn("test message") 280 | case "error": 281 | logger.Error("test message") 282 | } 283 | 284 | output := buf.String() 285 | if tt.shouldLog { 286 | assert.Contains(t, output, "test message") 287 | } else { 288 | assert.Empty(t, output) 289 | } 290 | }) 291 | } 292 | } 293 | 294 | func TestMultipleArguments(t *testing.T) { 295 | var buf bytes.Buffer 296 | 297 | logrusLogger := logrus.New() 298 | logrusLogger.SetOutput(&buf) 299 | logrusLogger.SetFormatter(&logrus.TextFormatter{ 300 | DisableTimestamp: true, 301 | DisableColors: true, 302 | }) 303 | logrusLogger.SetLevel(logrus.InfoLevel) 304 | 305 | logger := &logger{Logger: logrusLogger} 306 | 307 | logger.Info("multiple", "arguments", 123, true) 308 | output := buf.String() 309 | 310 | assert.Contains(t, output, "multiple") 311 | assert.Contains(t, output, "arguments") 312 | assert.Contains(t, output, "123") 313 | assert.Contains(t, output, "true") 314 | } 315 | -------------------------------------------------------------------------------- /internal/usecase/factory_test.go: -------------------------------------------------------------------------------- 1 | package usecase 2 | 3 | import ( 4 | "gift-buyer/internal/config" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNewFactory(t *testing.T) { 11 | cfg := &config.SoftConfig{ 12 | TgSettings: config.TgSettings{ 13 | AppId: 123456, 14 | ApiHash: "test_hash", 15 | Phone: "+1234567890", 16 | Password: "test_password", 17 | }, 18 | Criterias: []config.Criterias{ 19 | { 20 | MinPrice: 100, 21 | MaxPrice: 1000, 22 | TotalSupply: 50, 23 | }, 24 | }, 25 | Receiver: config.ReceiverParams{ 26 | UserReceiverID: []string{"987654321"}, 27 | ChannelReceiverID: []string{"123456789"}, 28 | }, 29 | Ticker: 30.0, 30 | } 31 | 32 | factory := NewFactory(cfg) 33 | assert.NotNil(t, factory) 34 | assert.Equal(t, cfg, factory.cfg) 35 | } 36 | 37 | func TestNewFactory_NilConfig(t *testing.T) { 38 | factory := NewFactory(nil) 39 | assert.NotNil(t, factory) 40 | assert.Nil(t, factory.cfg) 41 | } 42 | 43 | func TestFactory_Structure(t *testing.T) { 44 | cfg := &config.SoftConfig{ 45 | TgSettings: config.TgSettings{ 46 | AppId: 123456, 47 | ApiHash: "test_hash", 48 | Phone: "+1234567890", 49 | Password: "test_password", 50 | }, 51 | Criterias: []config.Criterias{ 52 | { 53 | MinPrice: 100, 54 | MaxPrice: 1000, 55 | TotalSupply: 50, 56 | }, 57 | }, 58 | Receiver: config.ReceiverParams{ 59 | UserReceiverID: []string{"987654321"}, 60 | ChannelReceiverID: []string{"123456789"}, 61 | }, 62 | Ticker: 30.0, 63 | } 64 | 65 | factory := NewFactory(cfg) 66 | assert.NotNil(t, factory) 67 | assert.Equal(t, cfg, factory.cfg) 68 | 69 | // Verify factory has CreateSystem method 70 | assert.NotNil(t, factory.CreateSystem) 71 | } 72 | 73 | func TestFactory_CreateSystemMethod(t *testing.T) { 74 | cfg := &config.SoftConfig{ 75 | TgSettings: config.TgSettings{ 76 | AppId: 123456, 77 | ApiHash: "test_hash", 78 | Phone: "+1234567890", 79 | Password: "test_password", 80 | }, 81 | Criterias: []config.Criterias{ 82 | { 83 | MinPrice: 100, 84 | MaxPrice: 1000, 85 | TotalSupply: 50, 86 | }, 87 | }, 88 | Receiver: config.ReceiverParams{ 89 | UserReceiverID: []string{"987654321"}, 90 | ChannelReceiverID: []string{"123456789"}, 91 | }, 92 | Ticker: 30.0, 93 | } 94 | 95 | factory := NewFactory(cfg) 96 | 97 | // Test that CreateSystem method exists and can be called 98 | assert.NotPanics(t, func() { 99 | // Don't actually call CreateSystem as it will try to connect to Telegram 100 | // Just verify the method signature exists 101 | _ = factory.CreateSystem 102 | }) 103 | } 104 | 105 | func TestFactory_ConfigMutability(t *testing.T) { 106 | cfg := &config.SoftConfig{ 107 | TgSettings: config.TgSettings{ 108 | AppId: 123456, 109 | ApiHash: "original_hash", 110 | Phone: "+1234567890", 111 | Password: "original_password", 112 | }, 113 | Criterias: []config.Criterias{ 114 | { 115 | MinPrice: 100, 116 | MaxPrice: 1000, 117 | TotalSupply: 50, 118 | }, 119 | }, 120 | Receiver: config.ReceiverParams{ 121 | UserReceiverID: []string{"987654321"}, 122 | ChannelReceiverID: []string{"123456789"}, 123 | }, 124 | Ticker: 30.0, 125 | } 126 | 127 | factory := NewFactory(cfg) 128 | 129 | // Modify original config 130 | cfg.TgSettings.ApiHash = "modified_hash" 131 | cfg.TgSettings.AppId = 999999 132 | cfg.Ticker = 60.0 133 | 134 | // Factory should still reference the same config object 135 | assert.Equal(t, "modified_hash", factory.cfg.TgSettings.ApiHash) 136 | assert.Equal(t, 999999, factory.cfg.TgSettings.AppId) 137 | assert.Equal(t, 60.0, factory.cfg.Ticker) 138 | } 139 | 140 | func TestFactory_MinimalConfig(t *testing.T) { 141 | cfg := &config.SoftConfig{ 142 | TgSettings: config.TgSettings{ 143 | AppId: 1, 144 | ApiHash: "h", 145 | Phone: "1", 146 | }, 147 | Criterias: []config.Criterias{ 148 | { 149 | MinPrice: 1, 150 | MaxPrice: 2, 151 | TotalSupply: 1, 152 | }, 153 | }, 154 | Receiver: config.ReceiverParams{ 155 | UserReceiverID: []string{"0"}, 156 | ChannelReceiverID: []string{"0"}, 157 | }, 158 | Ticker: 1.0, 159 | } 160 | 161 | factory := NewFactory(cfg) 162 | assert.NotNil(t, factory) 163 | assert.Equal(t, cfg, factory.cfg) 164 | assert.Equal(t, 1, factory.cfg.TgSettings.AppId) 165 | assert.Equal(t, "h", factory.cfg.TgSettings.ApiHash) 166 | assert.Equal(t, "1", factory.cfg.TgSettings.Phone) 167 | assert.Equal(t, 1, len(factory.cfg.Criterias)) 168 | assert.Equal(t, 1.0, factory.cfg.Ticker) 169 | } 170 | 171 | func TestFactory_MultipleCriterias(t *testing.T) { 172 | cfg := &config.SoftConfig{ 173 | TgSettings: config.TgSettings{ 174 | AppId: 123456, 175 | ApiHash: "test_hash", 176 | Phone: "+1234567890", 177 | Password: "test_password", 178 | }, 179 | Criterias: []config.Criterias{ 180 | { 181 | MinPrice: 100, 182 | MaxPrice: 500, 183 | TotalSupply: 25, 184 | }, 185 | { 186 | MinPrice: 1000, 187 | MaxPrice: 5000, 188 | TotalSupply: 100, 189 | }, 190 | { 191 | MinPrice: 10000, 192 | MaxPrice: 50000, 193 | TotalSupply: 10, 194 | }, 195 | }, 196 | Receiver: config.ReceiverParams{ 197 | UserReceiverID: []string{"987654321"}, 198 | ChannelReceiverID: []string{"123456789"}, 199 | }, 200 | Ticker: 15.0, 201 | } 202 | 203 | factory := NewFactory(cfg) 204 | assert.NotNil(t, factory) 205 | assert.Equal(t, cfg, factory.cfg) 206 | assert.Equal(t, 3, len(factory.cfg.Criterias)) 207 | 208 | // Verify first criteria 209 | assert.Equal(t, int64(100), factory.cfg.Criterias[0].MinPrice) 210 | assert.Equal(t, int64(500), factory.cfg.Criterias[0].MaxPrice) 211 | assert.Equal(t, int64(25), factory.cfg.Criterias[0].TotalSupply) 212 | 213 | // Verify second criteria 214 | assert.Equal(t, int64(1000), factory.cfg.Criterias[1].MinPrice) 215 | assert.Equal(t, int64(5000), factory.cfg.Criterias[1].MaxPrice) 216 | assert.Equal(t, int64(100), factory.cfg.Criterias[1].TotalSupply) 217 | 218 | // Verify third criteria 219 | assert.Equal(t, int64(10000), factory.cfg.Criterias[2].MinPrice) 220 | assert.Equal(t, int64(50000), factory.cfg.Criterias[2].MaxPrice) 221 | assert.Equal(t, int64(10), factory.cfg.Criterias[2].TotalSupply) 222 | } 223 | 224 | func TestFactory_EdgeCaseValues(t *testing.T) { 225 | cfg := &config.SoftConfig{ 226 | TgSettings: config.TgSettings{ 227 | AppId: -1, // Negative app ID 228 | ApiHash: "test_hash", 229 | Phone: "+1234567890", 230 | Password: "", 231 | }, 232 | Criterias: []config.Criterias{ 233 | { 234 | MinPrice: -100, // Negative prices 235 | MaxPrice: -50, 236 | TotalSupply: -10, // Negative supply 237 | }, 238 | }, 239 | Receiver: config.ReceiverParams{ 240 | UserReceiverID: []string{"-987654321"}, // Negative receiver 241 | ChannelReceiverID: []string{"-123456789"}, // Negative channel 242 | }, 243 | Ticker: -30.0, // Negative ticker 244 | } 245 | 246 | factory := NewFactory(cfg) 247 | assert.NotNil(t, factory) 248 | assert.Equal(t, cfg, factory.cfg) 249 | 250 | // Verify edge case values are preserved 251 | assert.Equal(t, -1, factory.cfg.TgSettings.AppId) 252 | assert.Equal(t, "", factory.cfg.TgSettings.Password) 253 | assert.Equal(t, int64(-100), factory.cfg.Criterias[0].MinPrice) 254 | assert.Equal(t, int64(-50), factory.cfg.Criterias[0].MaxPrice) 255 | assert.Equal(t, int64(-10), factory.cfg.Criterias[0].TotalSupply) 256 | assert.Equal(t, []string{"-987654321"}, factory.cfg.Receiver.UserReceiverID) 257 | assert.Equal(t, []string{"-123456789"}, factory.cfg.Receiver.ChannelReceiverID) 258 | assert.Equal(t, -30.0, factory.cfg.Ticker) 259 | } 260 | 261 | func TestFactory_ZeroValues(t *testing.T) { 262 | cfg := &config.SoftConfig{ 263 | TgSettings: config.TgSettings{ 264 | AppId: 0, 265 | ApiHash: "", 266 | Phone: "", 267 | Password: "", 268 | }, 269 | Criterias: []config.Criterias{ 270 | { 271 | MinPrice: 0, 272 | MaxPrice: 0, 273 | TotalSupply: 0, 274 | }, 275 | }, 276 | Receiver: config.ReceiverParams{ 277 | UserReceiverID: []string{"0"}, 278 | ChannelReceiverID: []string{"0"}, 279 | }, 280 | Ticker: 0.0, 281 | } 282 | 283 | factory := NewFactory(cfg) 284 | assert.NotNil(t, factory) 285 | assert.Equal(t, cfg, factory.cfg) 286 | 287 | // Verify zero values are preserved 288 | assert.Equal(t, 0, factory.cfg.TgSettings.AppId) 289 | assert.Equal(t, "", factory.cfg.TgSettings.ApiHash) 290 | assert.Equal(t, "", factory.cfg.TgSettings.Phone) 291 | assert.Equal(t, "", factory.cfg.TgSettings.Password) 292 | assert.Equal(t, int64(0), factory.cfg.Criterias[0].MinPrice) 293 | assert.Equal(t, int64(0), factory.cfg.Criterias[0].MaxPrice) 294 | assert.Equal(t, int64(0), factory.cfg.Criterias[0].TotalSupply) 295 | assert.Equal(t, []string{"0"}, factory.cfg.Receiver.UserReceiverID) 296 | assert.Equal(t, []string{"0"}, factory.cfg.Receiver.ChannelReceiverID) 297 | assert.Equal(t, 0.0, factory.cfg.Ticker) 298 | } 299 | --------------------------------------------------------------------------------