diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..a626139 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,42 @@ +name: Check +on: push +jobs: + golangci-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: "1.26" + - name: install deps + run: go mod download + - name: Lint + run: go vet ./... + test: + needs: [golangci-lint] + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.25', '1.26'] + os: ['ubuntu-latest', 'windows-latest'] + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Setup GO + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go-version }} + - name: install deps + run: go mod download + - name: Run tests + run: go test -coverprofile=coverage.out ./internal/... + - name: Check coverage threshold + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%') + echo "Coverage: $COVERAGE%" + if (( $(echo "$COVERAGE < 25" | bc -l) )); then + echo "Coverage is below 25%" + exit 1 + fi \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e889c90 --- /dev/null +++ b/Dockerfile @@ -0,0 +1 @@ +#todo dockerfile.... \ No newline at end of file diff --git a/cmd/server/.env.example b/cmd/server/.env.example new file mode 100644 index 0000000..7ddb1d7 --- /dev/null +++ b/cmd/server/.env.example @@ -0,0 +1,14 @@ +ENVIRONMENT= +LOG_LEVEL= + +POSTGRES_HOST= +POSTGRES_PORT= +POSTGRES_DB= +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_SSL= + +REDIS_HOST= +REDIS_PORT= +REDIS_USER= +REDIS_PASSWORD= \ No newline at end of file diff --git a/cmd/server/.gitignore b/cmd/server/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/cmd/server/.gitignore @@ -0,0 +1 @@ +.env diff --git a/cmd/server/main.go b/cmd/server/main.go index a5f6c35..0470ba9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,76 +1,85 @@ -package main - -import ( - "database/sql" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - - handlers "processing/internal/delivery/http" - "processing/internal/infrastructure/cache" - "processing/internal/infrastructure/storage" - "processing/internal/usecase" - - _ "github.com/jackc/pgx/v5/stdlib" -) - -const ( - db_url = "postgres://admin:secret@localhost:5432/postgres_bd" - redis_url = "localhost:6379" -) - -func main() { - if err := run(); err != nil { - os.Exit(1) - } -} - -func run() error { - app, err := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer app.Close() - - stor, err := os.OpenFile("storage.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer stor.Close() - - redis, err := os.OpenFile("redis.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - panic(err) - } - defer redis.Close() - - applog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, app), nil)) - storagelog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, stor), nil)) - redislog := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, redis), nil)) - - db, err := sql.Open("pgx", db_url) - if err != nil { - applog.Error("не получилось подключиться к бд", "err", err) - return err - } - if err := db.Ping(); err != nil { - applog.Error("не получилось пингануть бд", "err", err) - return err - } - slog.Info("Успешное подключение к бд!") - tx := storage.NewUoWFactory(db, storagelog) - cache := cache.NewRedis(redis_url, redislog) - path := filepath.Join("service.log") // возможно нужно по другому - transferService := usecase.NewService(tx, cache, path) - handler := handlers.NewHandler(transferService) - - mux := http.NewServeMux() - mux.HandleFunc("POST /transactions", handler.Transfer) - mux.HandleFunc("GET /transactions/{id}", handler.GetTransaction) - slog.Info("сервер запущен на :8080") - http.ListenAndServe(":8080", mux) - - return nil -} +package main + +import ( + "database/sql" + "log" + "log/slog" + "net/http" + "os" + + handlers "processing/internal/delivery/http" + "processing/internal/delivery/http/middleware" + "processing/internal/infrastructure/cache" + "processing/internal/infrastructure/config" + "processing/internal/infrastructure/logger" + "processing/internal/infrastructure/storage" + "processing/internal/usecase" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func main() { + if err := run(); err != nil { + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + panic(err) + } + + logger, err := logger.NewLogger(cfg.LogLevel, cfg.Environment) + if err != nil { + panic(err) + } + + postgres_url := cfg.Postgres.PostgresDSN() + db, err := sql.Open("pgx", postgres_url) + if err != nil { + logger.Debug("не получилось подключиться к бд", "err", err) + return err + } + if err := db.Ping(); err != nil { + logger.Debug("не получилось пингануть бд", "err", err) + return err + } + slog.Info("Успешное подключение к бд!") + + redis_url := cfg.Redis.RedisDSN() + cache := cache.NewRedis(cache.NewRedisOptions{ + Addr: redis_url, + RateLimitMin: cfg.Redis.RateLimitMin, + RateLimitHour: cfg.Redis.RateLimitHour, + RateLimitDay: cfg.Redis.RateLimitDay, + }) + //kafka + //// + //// + + tx := storage.NewUoWFactory(db) + transactionService := usecase.NewTransactionsService(tx, cache, logger) + accountsService := usecase.NewAccountService(tx, cache, logger) + authService := usecase.NewAuthService(tx, cache, logger) + + handler := handlers.NewHandler(transactionService, accountsService, authService, logger) + + router := http.NewServeMux() + + router.HandleFunc("POST /auth/register", handler.Register) + router.HandleFunc("POST /auth/login", handler.Login) + router.HandleFunc("POST /auth/refresh", handler.Refresh) + + router.Handle("POST /auth/logout", middleware.AuthMiddleware(http.HandlerFunc(handler.Logout))) + router.Handle("POST /auth/logout-all", middleware.AuthMiddleware(http.HandlerFunc(handler.LogoutAll))) + router.Handle("GET /accounts/{id}", middleware.AuthMiddleware(http.HandlerFunc(handler.GetAccount))) + router.Handle("GET /accounts/{id}/transactions", middleware.AuthMiddleware(http.HandlerFunc(handler.AccountTransactions))) + router.Handle("POST /transactions", middleware.AuthMiddleware(http.HandlerFunc(handler.Transfer))) + router.Handle("GET /transactions/{id}", middleware.AuthMiddleware(http.HandlerFunc(handler.GetTransaction))) + + log.Println("сервер запущен на :8080!") + http.ListenAndServe(":8080", router) + + return nil +} diff --git a/coverage b/coverage new file mode 100644 index 0000000..5f0393b --- /dev/null +++ b/coverage @@ -0,0 +1,749 @@ +mode: set +processing/internal/delivery/http/account_handler.go:20.70,26.16 5 1 +processing/internal/delivery/http/account_handler.go:26.16,29.3 2 1 +processing/internal/delivery/http/account_handler.go:31.2,32.9 2 1 +processing/internal/delivery/http/account_handler.go:32.9,35.3 2 0 +processing/internal/delivery/http/account_handler.go:36.2,36.37 1 1 +processing/internal/delivery/http/account_handler.go:36.37,39.3 2 1 +processing/internal/delivery/http/account_handler.go:41.2,42.16 2 1 +processing/internal/delivery/http/account_handler.go:42.16,45.3 2 1 +processing/internal/delivery/http/account_handler.go:47.2,47.61 1 1 +processing/internal/delivery/http/account_handler.go:47.61,49.3 1 0 +processing/internal/delivery/http/account_handler.go:58.79,64.16 5 1 +processing/internal/delivery/http/account_handler.go:64.16,67.3 2 1 +processing/internal/delivery/http/account_handler.go:69.2,70.9 2 1 +processing/internal/delivery/http/account_handler.go:70.9,73.3 2 0 +processing/internal/delivery/http/account_handler.go:74.2,74.37 1 1 +processing/internal/delivery/http/account_handler.go:74.37,77.3 2 1 +processing/internal/delivery/http/account_handler.go:79.2,82.16 4 1 +processing/internal/delivery/http/account_handler.go:82.16,84.3 1 1 +processing/internal/delivery/http/account_handler.go:85.2,86.16 2 1 +processing/internal/delivery/http/account_handler.go:86.16,88.3 1 1 +processing/internal/delivery/http/account_handler.go:90.2,91.16 2 1 +processing/internal/delivery/http/account_handler.go:91.16,94.3 2 1 +processing/internal/delivery/http/account_handler.go:96.2,101.57 2 1 +processing/internal/delivery/http/account_handler.go:101.57,103.3 1 0 +processing/internal/delivery/http/auth_handler.go:18.68,24.61 5 1 +processing/internal/delivery/http/auth_handler.go:24.61,27.3 2 1 +processing/internal/delivery/http/auth_handler.go:29.2,29.32 1 1 +processing/internal/delivery/http/auth_handler.go:29.32,31.3 1 1 +processing/internal/delivery/http/auth_handler.go:33.2,34.16 2 1 +processing/internal/delivery/http/auth_handler.go:34.16,35.52 1 1 +processing/internal/delivery/http/auth_handler.go:35.52,38.4 2 0 +processing/internal/delivery/http/auth_handler.go:39.3,40.9 2 1 +processing/internal/delivery/http/auth_handler.go:43.2,44.16 2 1 +processing/internal/delivery/http/auth_handler.go:44.16,47.3 2 1 +processing/internal/delivery/http/auth_handler.go:49.2,54.17 3 1 +processing/internal/delivery/http/auth_handler.go:54.17,56.3 1 0 +processing/internal/delivery/http/auth_handler.go:59.65,65.61 5 1 +processing/internal/delivery/http/auth_handler.go:65.61,68.3 2 1 +processing/internal/delivery/http/auth_handler.go:70.2,70.29 1 1 +processing/internal/delivery/http/auth_handler.go:70.29,72.3 1 1 +processing/internal/delivery/http/auth_handler.go:74.2,75.16 2 1 +processing/internal/delivery/http/auth_handler.go:75.16,76.51 1 1 +processing/internal/delivery/http/auth_handler.go:76.51,79.4 2 0 +processing/internal/delivery/http/auth_handler.go:80.3,81.9 2 1 +processing/internal/delivery/http/auth_handler.go:84.2,86.59 3 1 +processing/internal/delivery/http/auth_handler.go:86.59,88.3 1 0 +processing/internal/delivery/http/auth_handler.go:91.67,96.16 5 1 +processing/internal/delivery/http/auth_handler.go:96.16,98.3 1 1 +processing/internal/delivery/http/auth_handler.go:98.8,102.62 2 1 +processing/internal/delivery/http/auth_handler.go:102.62,104.4 1 1 +processing/internal/delivery/http/auth_handler.go:106.2,107.24 2 1 +processing/internal/delivery/http/auth_handler.go:107.24,110.3 2 1 +processing/internal/delivery/http/auth_handler.go:112.2,113.16 2 1 +processing/internal/delivery/http/auth_handler.go:113.16,116.3 2 1 +processing/internal/delivery/http/auth_handler.go:118.2,120.59 3 1 +processing/internal/delivery/http/auth_handler.go:120.59,122.3 1 0 +processing/internal/delivery/http/auth_handler.go:125.66,130.16 5 1 +processing/internal/delivery/http/auth_handler.go:130.16,132.3 1 1 +processing/internal/delivery/http/auth_handler.go:132.8,136.62 2 1 +processing/internal/delivery/http/auth_handler.go:136.62,139.4 2 1 +processing/internal/delivery/http/auth_handler.go:140.3,140.34 1 1 +processing/internal/delivery/http/auth_handler.go:142.2,143.24 2 1 +processing/internal/delivery/http/auth_handler.go:143.24,146.3 2 1 +processing/internal/delivery/http/auth_handler.go:148.2,148.61 1 1 +processing/internal/delivery/http/auth_handler.go:148.61,150.3 1 1 +processing/internal/delivery/http/auth_handler.go:151.2,153.93 3 1 +processing/internal/delivery/http/auth_handler.go:153.93,155.3 1 0 +processing/internal/delivery/http/auth_handler.go:158.69,163.9 4 1 +processing/internal/delivery/http/auth_handler.go:163.9,166.3 2 1 +processing/internal/delivery/http/auth_handler.go:168.2,169.16 2 1 +processing/internal/delivery/http/auth_handler.go:169.16,172.3 2 1 +processing/internal/delivery/http/auth_handler.go:174.2,174.54 1 1 +processing/internal/delivery/http/auth_handler.go:174.54,176.3 1 1 +processing/internal/delivery/http/auth_handler.go:177.2,179.93 3 1 +processing/internal/delivery/http/auth_handler.go:179.93,181.3 1 0 +processing/internal/delivery/http/handler.go:21.12,29.2 2 1 +processing/internal/delivery/http/helpers.go:18.80,20.16 2 1 +processing/internal/delivery/http/helpers.go:20.16,22.3 1 1 +processing/internal/delivery/http/helpers.go:23.2,24.16 2 1 +processing/internal/delivery/http/helpers.go:24.16,26.3 1 0 +processing/internal/delivery/http/helpers.go:28.2,32.16 4 1 +processing/internal/delivery/http/helpers.go:32.16,34.3 1 1 +processing/internal/delivery/http/helpers.go:35.2,36.16 2 1 +processing/internal/delivery/http/helpers.go:36.16,38.3 1 0 +processing/internal/delivery/http/helpers.go:40.2,41.16 2 1 +processing/internal/delivery/http/helpers.go:41.16,43.3 1 0 +processing/internal/delivery/http/helpers.go:45.2,46.16 2 1 +processing/internal/delivery/http/helpers.go:46.16,48.3 1 0 +processing/internal/delivery/http/helpers.go:50.2,59.8 1 1 +processing/internal/delivery/http/helpers.go:62.65,64.15 2 1 +processing/internal/delivery/http/helpers.go:64.15,66.3 1 1 +processing/internal/delivery/http/helpers.go:68.2,69.16 2 1 +processing/internal/delivery/http/helpers.go:69.16,71.3 1 1 +processing/internal/delivery/http/helpers.go:72.2,72.16 1 1 +processing/internal/delivery/http/helpers.go:75.65,77.15 2 1 +processing/internal/delivery/http/helpers.go:77.15,79.3 1 1 +processing/internal/delivery/http/helpers.go:81.2,82.16 2 1 +processing/internal/delivery/http/helpers.go:82.16,84.3 1 0 +processing/internal/delivery/http/helpers.go:86.2,86.15 1 1 +processing/internal/delivery/http/helpers.go:89.28,101.2 3 1 +processing/internal/delivery/http/helpers.go:105.71,109.15 3 1 +processing/internal/delivery/http/helpers.go:109.15,115.3 2 1 +processing/internal/delivery/http/helpers.go:116.2,116.69 1 1 +processing/internal/delivery/http/helpers.go:119.62,121.56 2 1 +processing/internal/delivery/http/helpers.go:121.56,125.3 3 0 +processing/internal/delivery/http/helpers.go:126.2,129.12 4 1 +processing/internal/delivery/http/helpers.go:132.81,142.2 1 1 +processing/internal/delivery/http/helpers.go:144.63,147.22 2 1 +processing/internal/delivery/http/helpers.go:147.22,150.3 2 1 +processing/internal/delivery/http/helpers.go:152.2,152.25 1 1 +processing/internal/delivery/http/helpers.go:152.25,155.3 2 1 +processing/internal/delivery/http/helpers.go:157.2,159.34 3 1 +processing/internal/delivery/http/helpers.go:159.34,166.3 2 1 +processing/internal/delivery/http/helpers.go:168.2,168.28 1 1 +processing/internal/delivery/http/helpers.go:168.28,171.3 2 1 +processing/internal/delivery/http/helpers.go:173.2,173.13 1 1 +processing/internal/delivery/http/helpers.go:176.66,180.21 3 1 +processing/internal/delivery/http/helpers.go:180.21,183.3 2 1 +processing/internal/delivery/http/helpers.go:185.2,185.22 1 1 +processing/internal/delivery/http/helpers.go:185.22,188.3 2 1 +processing/internal/delivery/http/helpers.go:190.2,190.25 1 1 +processing/internal/delivery/http/helpers.go:190.25,193.3 2 1 +processing/internal/delivery/http/helpers.go:195.2,195.24 1 1 +processing/internal/delivery/http/helpers.go:195.24,198.3 2 1 +processing/internal/delivery/http/helpers.go:200.2,202.34 3 1 +processing/internal/delivery/http/helpers.go:202.34,209.3 2 1 +processing/internal/delivery/http/helpers.go:211.2,211.28 1 1 +processing/internal/delivery/http/helpers.go:211.28,214.3 2 1 +processing/internal/delivery/http/helpers.go:216.2,216.13 1 1 +processing/internal/delivery/http/transaction_handler.go:20.68,27.9 5 1 +processing/internal/delivery/http/transaction_handler.go:27.9,30.3 2 0 +processing/internal/delivery/http/transaction_handler.go:32.2,33.16 2 1 +processing/internal/delivery/http/transaction_handler.go:33.16,36.3 2 0 +processing/internal/delivery/http/transaction_handler.go:38.2,40.61 3 1 +processing/internal/delivery/http/transaction_handler.go:40.61,43.3 2 1 +processing/internal/delivery/http/transaction_handler.go:45.2,46.16 2 1 +processing/internal/delivery/http/transaction_handler.go:46.16,49.3 2 1 +processing/internal/delivery/http/transaction_handler.go:51.2,52.16 2 1 +processing/internal/delivery/http/transaction_handler.go:52.16,53.187 1 1 +processing/internal/delivery/http/transaction_handler.go:53.187,56.4 2 0 +processing/internal/delivery/http/transaction_handler.go:57.3,58.9 2 1 +processing/internal/delivery/http/transaction_handler.go:61.2,61.109 1 1 +processing/internal/delivery/http/transaction_handler.go:61.109,63.3 1 0 +processing/internal/delivery/http/transaction_handler.go:73.74,77.16 4 1 +processing/internal/delivery/http/transaction_handler.go:77.16,80.3 2 1 +processing/internal/delivery/http/transaction_handler.go:82.2,84.9 3 1 +processing/internal/delivery/http/transaction_handler.go:84.9,87.3 2 0 +processing/internal/delivery/http/transaction_handler.go:89.2,90.16 2 1 +processing/internal/delivery/http/transaction_handler.go:90.16,93.3 2 0 +processing/internal/delivery/http/transaction_handler.go:95.2,97.16 3 1 +processing/internal/delivery/http/transaction_handler.go:97.16,98.45 1 1 +processing/internal/delivery/http/transaction_handler.go:98.45,101.4 2 1 +processing/internal/delivery/http/transaction_handler.go:102.3,103.9 2 1 +processing/internal/delivery/http/transaction_handler.go:106.2,106.65 1 1 +processing/internal/delivery/http/transaction_handler.go:106.65,108.3 1 0 +processing/internal/delivery/http/transaction_handler.go:115.77,121.9 5 1 +processing/internal/delivery/http/transaction_handler.go:121.9,124.3 2 0 +processing/internal/delivery/http/transaction_handler.go:126.2,127.16 2 1 +processing/internal/delivery/http/transaction_handler.go:127.16,130.3 2 0 +processing/internal/delivery/http/transaction_handler.go:132.2,135.16 4 1 +processing/internal/delivery/http/transaction_handler.go:135.16,138.3 2 1 +processing/internal/delivery/http/transaction_handler.go:140.2,141.16 2 1 +processing/internal/delivery/http/transaction_handler.go:141.16,144.3 2 1 +processing/internal/delivery/http/transaction_handler.go:146.2,146.66 1 1 +processing/internal/delivery/http/transaction_handler.go:146.66,148.3 1 0 +processing/internal/infrastructure/storage/helper.go:12.95,17.34 4 0 +processing/internal/infrastructure/storage/helper.go:17.34,21.3 3 0 +processing/internal/infrastructure/storage/helper.go:23.2,23.33 1 0 +processing/internal/infrastructure/storage/helper.go:23.33,27.3 3 0 +processing/internal/infrastructure/storage/helper.go:29.2,29.35 1 0 +processing/internal/infrastructure/storage/helper.go:29.35,33.3 3 0 +processing/internal/infrastructure/storage/helper.go:35.2,35.28 1 0 +processing/internal/infrastructure/storage/helper.go:35.28,37.17 2 0 +processing/internal/infrastructure/storage/helper.go:37.17,39.4 1 0 +processing/internal/infrastructure/storage/helper.go:40.3,42.15 3 0 +processing/internal/infrastructure/storage/helper.go:45.2,45.28 1 0 +processing/internal/infrastructure/storage/helper.go:45.28,47.17 2 0 +processing/internal/infrastructure/storage/helper.go:47.17,49.4 1 0 +processing/internal/infrastructure/storage/helper.go:50.3,52.15 3 0 +processing/internal/infrastructure/storage/helper.go:55.2,55.27 1 0 +processing/internal/infrastructure/storage/helper.go:55.27,59.3 3 0 +processing/internal/infrastructure/storage/helper.go:61.2,61.25 1 0 +processing/internal/infrastructure/storage/helper.go:61.25,65.3 3 0 +processing/internal/infrastructure/storage/helper.go:67.2,69.22 2 0 +processing/internal/infrastructure/storage/helper.go:69.22,73.3 3 0 +processing/internal/infrastructure/storage/helper.go:75.2,75.23 1 0 +processing/internal/infrastructure/storage/helper.go:75.23,78.3 2 0 +processing/internal/infrastructure/storage/helper.go:80.2,80.20 1 0 +processing/internal/infrastructure/storage/storage.go:36.58,36.79 1 0 +processing/internal/infrastructure/storage/storage.go:37.58,37.74 1 0 +processing/internal/infrastructure/storage/storage.go:38.58,38.76 1 0 +processing/internal/infrastructure/storage/storage.go:40.32,42.2 1 0 +processing/internal/infrastructure/storage/storage.go:44.34,46.2 1 0 +processing/internal/infrastructure/storage/storage.go:52.45,54.2 1 0 +processing/internal/infrastructure/storage/storage.go:57.76,59.16 2 0 +processing/internal/infrastructure/storage/storage.go:59.16,61.3 1 0 +processing/internal/infrastructure/storage/storage.go:62.2,67.8 1 0 +processing/internal/infrastructure/storage/storage.go:71.77,74.120 2 0 +processing/internal/infrastructure/storage/storage.go:74.120,76.54 2 0 +processing/internal/infrastructure/storage/storage.go:76.54,78.4 1 0 +processing/internal/infrastructure/storage/storage.go:79.3,79.68 1 0 +processing/internal/infrastructure/storage/storage.go:81.2,81.12 1 0 +processing/internal/infrastructure/storage/storage.go:85.91,89.16 4 0 +processing/internal/infrastructure/storage/storage.go:89.16,90.36 1 0 +processing/internal/infrastructure/storage/storage.go:90.36,92.4 1 0 +processing/internal/infrastructure/storage/storage.go:93.3,93.94 1 0 +processing/internal/infrastructure/storage/storage.go:95.2,95.16 1 0 +processing/internal/infrastructure/storage/storage.go:99.94,103.16 4 0 +processing/internal/infrastructure/storage/storage.go:103.16,104.36 1 0 +processing/internal/infrastructure/storage/storage.go:104.36,106.4 1 0 +processing/internal/infrastructure/storage/storage.go:107.3,107.97 1 0 +processing/internal/infrastructure/storage/storage.go:109.2,109.16 1 0 +processing/internal/infrastructure/storage/storage.go:113.99,120.16 3 0 +processing/internal/infrastructure/storage/storage.go:120.16,122.3 1 0 +processing/internal/infrastructure/storage/storage.go:124.2,125.16 2 0 +processing/internal/infrastructure/storage/storage.go:125.16,127.3 1 0 +processing/internal/infrastructure/storage/storage.go:128.2,128.15 1 0 +processing/internal/infrastructure/storage/storage.go:128.15,130.3 1 0 +processing/internal/infrastructure/storage/storage.go:132.2,132.12 1 0 +processing/internal/infrastructure/storage/storage.go:136.101,139.16 3 0 +processing/internal/infrastructure/storage/storage.go:139.16,141.3 1 0 +processing/internal/infrastructure/storage/storage.go:143.2,144.16 2 0 +processing/internal/infrastructure/storage/storage.go:144.16,146.3 1 0 +processing/internal/infrastructure/storage/storage.go:148.2,148.15 1 0 +processing/internal/infrastructure/storage/storage.go:148.15,150.3 1 0 +processing/internal/infrastructure/storage/storage.go:152.2,152.12 1 0 +processing/internal/infrastructure/storage/storage.go:156.81,162.48 2 0 +processing/internal/infrastructure/storage/storage.go:162.48,164.3 1 0 +processing/internal/infrastructure/storage/storage.go:165.2,165.12 1 0 +processing/internal/infrastructure/storage/storage.go:169.115,174.105 2 0 +processing/internal/infrastructure/storage/storage.go:174.105,176.3 1 0 +processing/internal/infrastructure/storage/storage.go:177.2,177.12 1 0 +processing/internal/infrastructure/storage/storage.go:180.100,193.16 4 0 +processing/internal/infrastructure/storage/storage.go:193.16,195.3 1 0 +processing/internal/infrastructure/storage/storage.go:196.2,196.25 1 0 +processing/internal/infrastructure/storage/storage.go:200.118,202.17 2 0 +processing/internal/infrastructure/storage/storage.go:202.17,204.3 1 0 +processing/internal/infrastructure/storage/storage.go:206.2,207.16 2 0 +processing/internal/infrastructure/storage/storage.go:207.16,209.3 1 0 +processing/internal/infrastructure/storage/storage.go:210.2,213.18 3 0 +processing/internal/infrastructure/storage/storage.go:213.18,223.17 3 0 +processing/internal/infrastructure/storage/storage.go:223.17,225.4 1 0 +processing/internal/infrastructure/storage/storage.go:226.3,226.41 1 0 +processing/internal/infrastructure/storage/storage.go:229.2,229.34 1 0 +processing/internal/infrastructure/storage/storage.go:229.34,231.3 1 0 +processing/internal/infrastructure/storage/storage.go:233.2,233.26 1 0 +processing/internal/infrastructure/storage/storage.go:236.88,240.78 3 0 +processing/internal/infrastructure/storage/storage.go:240.78,242.3 1 0 +processing/internal/infrastructure/storage/storage.go:244.2,244.19 1 0 +processing/internal/infrastructure/storage/storage.go:247.113,251.16 3 0 +processing/internal/infrastructure/storage/storage.go:251.16,253.3 1 0 +processing/internal/infrastructure/storage/storage.go:255.2,256.16 2 0 +processing/internal/infrastructure/storage/storage.go:256.16,258.3 1 0 +processing/internal/infrastructure/storage/storage.go:260.2,260.15 1 0 +processing/internal/infrastructure/storage/storage.go:260.15,262.3 1 0 +processing/internal/infrastructure/storage/storage.go:264.2,264.12 1 0 +processing/internal/infrastructure/storage/storage.go:267.100,272.16 4 0 +processing/internal/infrastructure/storage/storage.go:272.16,273.36 1 0 +processing/internal/infrastructure/storage/storage.go:273.36,275.4 1 0 +processing/internal/infrastructure/storage/storage.go:276.3,276.77 1 0 +processing/internal/infrastructure/storage/storage.go:279.2,279.21 1 0 +processing/internal/infrastructure/storage/storage.go:282.77,286.16 3 0 +processing/internal/infrastructure/storage/storage.go:286.16,288.3 1 0 +processing/internal/infrastructure/storage/storage.go:290.2,291.16 2 0 +processing/internal/infrastructure/storage/storage.go:291.16,293.3 1 0 +processing/internal/infrastructure/storage/storage.go:295.2,295.15 1 0 +processing/internal/infrastructure/storage/storage.go:295.15,297.3 1 0 +processing/internal/infrastructure/storage/storage.go:299.2,299.12 1 0 +processing/internal/infrastructure/storage/storage.go:302.84,306.16 3 0 +processing/internal/infrastructure/storage/storage.go:306.16,308.3 1 0 +processing/internal/infrastructure/storage/storage.go:310.2,311.16 2 0 +processing/internal/infrastructure/storage/storage.go:311.16,313.3 1 0 +processing/internal/infrastructure/storage/storage.go:315.2,315.15 1 0 +processing/internal/infrastructure/storage/storage.go:315.15,317.3 1 0 +processing/internal/infrastructure/storage/storage.go:319.2,319.12 1 0 +processing/internal/infrastructure/cache/redis.go:58.44,68.2 2 1 +processing/internal/infrastructure/cache/redis.go:72.96,74.16 2 1 +processing/internal/infrastructure/cache/redis.go:74.16,76.3 1 0 +processing/internal/infrastructure/cache/redis.go:78.2,78.10 1 1 +processing/internal/infrastructure/cache/redis.go:78.10,80.3 1 1 +processing/internal/infrastructure/cache/redis.go:81.2,81.12 1 1 +processing/internal/infrastructure/cache/redis.go:86.74,87.91 1 1 +processing/internal/infrastructure/cache/redis.go:87.91,89.3 1 1 +processing/internal/infrastructure/cache/redis.go:91.2,91.91 1 1 +processing/internal/infrastructure/cache/redis.go:91.91,93.3 1 0 +processing/internal/infrastructure/cache/redis.go:95.2,95.92 1 1 +processing/internal/infrastructure/cache/redis.go:95.92,97.3 1 0 +processing/internal/infrastructure/cache/redis.go:99.2,99.12 1 1 +processing/internal/infrastructure/cache/redis.go:102.121,109.16 6 1 +processing/internal/infrastructure/cache/redis.go:109.16,111.3 1 0 +processing/internal/infrastructure/cache/redis.go:113.2,114.9 2 1 +processing/internal/infrastructure/cache/redis.go:114.9,116.3 1 0 +processing/internal/infrastructure/cache/redis.go:118.2,118.18 1 1 +processing/internal/infrastructure/cache/redis.go:118.18,120.3 1 1 +processing/internal/infrastructure/cache/redis.go:122.2,122.12 1 1 +processing/internal/decimal/decimal.go:20.51,28.26 6 0 +processing/internal/decimal/decimal.go:28.26,29.27 1 0 +processing/internal/decimal/decimal.go:29.27,30.19 1 0 +processing/internal/decimal/decimal.go:30.19,32.5 1 0 +processing/internal/decimal/decimal.go:33.4,34.12 2 0 +processing/internal/decimal/decimal.go:37.3,37.15 1 0 +processing/internal/decimal/decimal.go:37.15,38.19 1 0 +processing/internal/decimal/decimal.go:38.19,40.5 1 0 +processing/internal/decimal/decimal.go:41.4,41.14 1 0 +processing/internal/decimal/decimal.go:45.2,45.18 1 0 +processing/internal/decimal/decimal.go:45.18,47.17 2 0 +processing/internal/decimal/decimal.go:47.17,48.73 1 0 +processing/internal/decimal/decimal.go:48.73,50.5 1 0 +processing/internal/decimal/decimal.go:51.4,51.95 1 0 +processing/internal/decimal/decimal.go:53.3,54.15 2 0 +processing/internal/decimal/decimal.go:57.2,57.18 1 0 +processing/internal/decimal/decimal.go:57.18,61.3 1 0 +processing/internal/decimal/decimal.go:61.8,62.28 1 0 +processing/internal/decimal/decimal.go:62.28,64.4 1 0 +processing/internal/decimal/decimal.go:64.9,66.4 1 0 +processing/internal/decimal/decimal.go:67.3,68.23 2 0 +processing/internal/decimal/decimal.go:71.2,73.26 2 0 +processing/internal/decimal/decimal.go:73.26,75.17 2 0 +processing/internal/decimal/decimal.go:75.17,77.4 1 0 +processing/internal/decimal/decimal.go:78.3,78.32 1 0 +processing/internal/decimal/decimal.go:79.8,82.10 3 0 +processing/internal/decimal/decimal.go:82.10,84.4 1 0 +processing/internal/decimal/decimal.go:87.2,87.48 1 0 +processing/internal/decimal/decimal.go:87.48,90.3 1 0 +processing/internal/decimal/decimal.go:92.2,95.8 1 0 +processing/internal/decimal/decimal.go:98.34,100.2 1 0 +processing/internal/decimal/decimal.go:103.42,111.2 3 0 +processing/internal/decimal/decimal.go:114.42,122.2 3 0 +processing/internal/decimal/decimal.go:125.61,126.21 1 0 +processing/internal/decimal/decimal.go:126.21,128.3 1 0 +processing/internal/decimal/decimal.go:128.8,128.28 1 0 +processing/internal/decimal/decimal.go:128.28,130.3 1 0 +processing/internal/decimal/decimal.go:132.2,132.15 1 0 +processing/internal/decimal/decimal.go:140.42,142.2 1 0 +processing/internal/decimal/decimal.go:144.38,145.21 1 0 +processing/internal/decimal/decimal.go:145.21,147.3 1 0 +processing/internal/decimal/decimal.go:149.2,151.42 2 0 +processing/internal/decimal/decimal.go:154.52,157.19 3 0 +processing/internal/decimal/decimal.go:157.19,159.3 1 0 +processing/internal/decimal/decimal.go:160.2,162.21 3 0 +processing/internal/decimal/decimal.go:162.21,165.3 2 0 +processing/internal/decimal/decimal.go:166.2,167.24 2 0 +processing/internal/decimal/decimal.go:167.24,169.3 1 0 +processing/internal/decimal/decimal.go:170.2,170.15 1 0 +processing/internal/decimal/decimal.go:173.79,174.16 1 0 +processing/internal/decimal/decimal.go:174.16,176.3 1 0 +processing/internal/decimal/decimal.go:177.2,177.16 1 0 +processing/internal/decimal/decimal.go:177.16,178.28 1 0 +processing/internal/decimal/decimal.go:178.28,180.4 1 0 +processing/internal/decimal/decimal.go:180.9,182.4 1 0 +processing/internal/decimal/decimal.go:185.2,193.25 5 0 +processing/internal/decimal/decimal.go:193.25,196.3 2 0 +processing/internal/decimal/decimal.go:196.8,201.3 3 0 +processing/internal/decimal/decimal.go:203.2,203.23 1 0 +processing/internal/decimal/decimal.go:203.23,205.21 2 0 +processing/internal/decimal/decimal.go:205.21,206.32 1 0 +processing/internal/decimal/decimal.go:206.32,207.10 1 0 +processing/internal/decimal/decimal.go:210.3,210.40 1 0 +processing/internal/decimal/decimal.go:213.2,214.29 2 0 +processing/internal/decimal/decimal.go:214.29,216.3 1 0 +processing/internal/decimal/decimal.go:218.2,218.29 1 0 +processing/internal/decimal/decimal.go:218.29,220.3 1 0 +processing/internal/decimal/decimal.go:222.2,222.15 1 0 +processing/internal/decimal/decimal.go:225.38,226.20 1 0 +processing/internal/decimal/decimal.go:226.20,228.3 1 0 +processing/internal/decimal/decimal.go:229.2,229.16 1 0 +processing/internal/decimal/decimal.go:232.45,233.18 1 0 +processing/internal/decimal/decimal.go:233.18,238.3 1 0 +processing/internal/decimal/decimal.go:241.2,245.17 4 0 +processing/internal/decimal/decimal.go:245.17,247.3 1 0 +processing/internal/decimal/decimal.go:247.8,247.24 1 0 +processing/internal/decimal/decimal.go:247.24,249.3 1 0 +processing/internal/decimal/decimal.go:251.2,254.3 1 0 +processing/internal/decimal/decimal.go:262.29,264.2 1 0 +processing/internal/decimal/decimal.go:271.36,273.2 1 0 +processing/internal/decimal/decimal.go:275.21,280.2 1 0 +processing/internal/decimal/decimal.go:283.48,285.2 1 0 +processing/internal/decimal/decimal.go:288.52,290.49 2 0 +processing/internal/decimal/decimal.go:290.49,293.52 2 0 +processing/internal/decimal/decimal.go:293.52,295.4 1 0 +processing/internal/decimal/decimal.go:295.9,297.4 1 0 +processing/internal/decimal/decimal.go:300.2,302.12 3 0 +processing/internal/decimal/sql.go:9.49,11.27 1 0 +processing/internal/decimal/sql.go:12.14,15.13 3 0 +processing/internal/decimal/sql.go:17.14,20.13 3 0 +processing/internal/decimal/sql.go:22.10,23.78 1 0 +processing/internal/decimal/sql.go:28.48,30.2 1 0 +processing/internal/decimal/sql.go:32.43,34.69 1 0 +processing/internal/decimal/sql.go:34.69,36.3 1 0 +processing/internal/decimal/sql.go:38.2,38.14 1 0 +processing/internal/delivery/http/jwt/jwt.go:19.79,23.53 3 0 +processing/internal/delivery/http/jwt/jwt.go:23.53,25.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:27.2,42.16 7 0 +processing/internal/delivery/http/jwt/jwt.go:42.16,44.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:46.2,47.16 2 0 +processing/internal/delivery/http/jwt/jwt.go:47.16,49.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:51.2,62.16 4 0 +processing/internal/delivery/http/jwt/jwt.go:62.16,64.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:66.2,72.8 1 0 +processing/internal/delivery/http/jwt/jwt.go:75.76,80.43 2 0 +processing/internal/delivery/http/jwt/jwt.go:80.43,81.55 1 0 +processing/internal/delivery/http/jwt/jwt.go:81.55,83.5 1 0 +processing/internal/delivery/http/jwt/jwt.go:84.4,84.39 1 0 +processing/internal/delivery/http/jwt/jwt.go:87.2,87.16 1 0 +processing/internal/delivery/http/jwt/jwt.go:87.16,88.42 1 0 +processing/internal/delivery/http/jwt/jwt.go:88.42,90.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:90.9,90.58 1 0 +processing/internal/delivery/http/jwt/jwt.go:90.58,92.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:93.3,93.69 1 0 +processing/internal/delivery/http/jwt/jwt.go:96.2,97.9 2 0 +processing/internal/delivery/http/jwt/jwt.go:97.9,99.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:101.2,101.20 1 0 +processing/internal/delivery/http/jwt/jwt.go:104.78,110.43 2 0 +processing/internal/delivery/http/jwt/jwt.go:110.43,111.55 1 0 +processing/internal/delivery/http/jwt/jwt.go:111.55,113.5 1 0 +processing/internal/delivery/http/jwt/jwt.go:114.4,114.40 1 0 +processing/internal/delivery/http/jwt/jwt.go:117.2,117.16 1 0 +processing/internal/delivery/http/jwt/jwt.go:117.16,118.42 1 0 +processing/internal/delivery/http/jwt/jwt.go:118.42,120.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:120.9,120.58 1 0 +processing/internal/delivery/http/jwt/jwt.go:120.58,122.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:123.3,123.69 1 0 +processing/internal/delivery/http/jwt/jwt.go:126.2,127.9 2 0 +processing/internal/delivery/http/jwt/jwt.go:127.9,129.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:131.2,131.20 1 0 +processing/internal/domain/account.go:20.73,22.16 2 0 +processing/internal/domain/account.go:22.16,24.3 1 0 +processing/internal/domain/account.go:25.2,25.60 1 0 +processing/internal/domain/transactions.go:27.111,29.16 2 0 +processing/internal/domain/transactions.go:29.16,31.3 1 0 +processing/internal/domain/transactions.go:32.2,37.8 1 0 +processing/internal/infrastructure/config/config.go:37.30,38.40 1 0 +processing/internal/infrastructure/config/config.go:38.40,40.3 1 0 +processing/internal/infrastructure/config/config.go:42.2,65.17 4 0 +processing/internal/infrastructure/config/config.go:68.47,73.2 1 0 +processing/internal/infrastructure/config/config.go:75.41,80.2 1 0 +processing/internal/infrastructure/config/config.go:82.46,83.42 1 0 +processing/internal/infrastructure/config/config.go:83.42,85.3 1 0 +processing/internal/infrastructure/config/config.go:86.2,86.21 1 0 +processing/internal/infrastructure/config/config.go:89.56,90.42 1 0 +processing/internal/infrastructure/config/config.go:90.42,91.60 1 0 +processing/internal/infrastructure/config/config.go:91.60,93.4 1 0 +processing/internal/infrastructure/config/config.go:95.2,95.21 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:20.94,23.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:23.19,24.48 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:27.2,28.85 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:28.85,30.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:30.8,32.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:34.2,34.11 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:38.99,41.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:41.19,42.52 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:45.2,47.90 3 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:47.90,49.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:50.2,50.81 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:50.81,52.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:52.8,53.24 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:53.24,55.4 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:58.2,58.71 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:58.71,60.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:60.8,62.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:64.2,64.15 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:68.147,71.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:71.19,72.60 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:75.2,78.110 4 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:78.110,80.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:81.2,81.79 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:81.79,83.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:83.8,85.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:87.2,87.96 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:87.96,89.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:89.8,90.24 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:90.24,92.4 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:95.2,95.81 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:95.81,97.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:97.8,99.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:101.2,101.19 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:109.21,113.19 3 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:113.19,113.49 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:115.2,115.13 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:20.120,23.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:23.19,24.47 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:27.2,29.105 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:29.105,31.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:32.2,32.96 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:32.96,34.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:34.8,35.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:35.24,37.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:40.2,40.84 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:40.84,42.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:42.8,44.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:46.2,46.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:50.90,53.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:53.19,54.48 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:57.2,58.76 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:58.76,60.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:60.8,62.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:64.2,64.11 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:68.79,71.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:71.19,72.51 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:75.2,76.71 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:76.71,78.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:78.8,80.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:82.2,82.11 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:86.112,89.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:89.19,90.49 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:93.2,95.97 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:95.97,97.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:98.2,98.88 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:98.88,100.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:100.8,101.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:101.24,103.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:106.2,106.76 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:106.76,108.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:108.8,110.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:112.2,112.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:116.134,119.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:119.19,120.50 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:123.2,125.111 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:125.111,127.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:128.2,128.102 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:128.102,130.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:130.8,131.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:131.24,133.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:136.2,136.92 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:136.92,138.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:138.8,140.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:142.2,142.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:150.17,154.19 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:154.19,154.49 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:156.2,156.13 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:21.150,24.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:24.19,25.56 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:28.2,30.112 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:30.112,32.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:33.2,33.103 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:33.103,35.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:35.8,37.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:39.2,39.90 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:39.90,41.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:41.8,43.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:45.2,45.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:49.162,52.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:52.19,53.62 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:56.2,58.130 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:58.130,60.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:61.2,61.121 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:61.121,63.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:63.8,64.24 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:64.24,66.4 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:69.2,69.106 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:69.106,71.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:71.8,73.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:75.2,75.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:79.157,82.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:82.19,83.50 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:86.2,88.117 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:88.117,90.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:91.2,91.108 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:91.108,93.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:93.8,95.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:97.2,97.107 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:97.107,99.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:99.8,101.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:103.2,103.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:111.24,115.19 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:115.19,115.49 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:117.2,117.13 1 0 +processing/internal/infrastructure/logger/logger.go:8.67,10.62 2 0 +processing/internal/infrastructure/logger/logger.go:10.62,12.3 1 0 +processing/internal/infrastructure/logger/logger.go:14.2,15.13 2 0 +processing/internal/infrastructure/logger/logger.go:16.20,20.6 1 0 +processing/internal/infrastructure/logger/logger.go:21.10,25.6 1 0 +processing/internal/infrastructure/logger/logger.go:28.2,28.20 1 0 +processing/internal/infrastructure/logger/logger.go:31.68,32.19 1 0 +processing/internal/infrastructure/logger/logger.go:32.19,34.3 1 0 +processing/internal/infrastructure/logger/logger.go:35.2,35.40 1 0 +processing/internal/delivery/http/middleware/middleware.go:13.53,14.71 1 0 +processing/internal/delivery/http/middleware/middleware.go:14.71,16.17 2 0 +processing/internal/delivery/http/middleware/middleware.go:16.17,19.4 2 0 +processing/internal/delivery/http/middleware/middleware.go:21.3,23.40 3 0 +processing/internal/delivery/http/middleware/middleware.go:27.67,30.22 3 0 +processing/internal/delivery/http/middleware/middleware.go:30.22,31.48 1 0 +processing/internal/delivery/http/middleware/middleware.go:31.48,33.4 1 0 +processing/internal/delivery/http/middleware/middleware.go:34.3,34.52 1 0 +processing/internal/delivery/http/middleware/middleware.go:35.8,37.17 2 0 +processing/internal/delivery/http/middleware/middleware.go:37.17,39.4 1 0 +processing/internal/delivery/http/middleware/middleware.go:40.3,40.23 1 0 +processing/internal/delivery/http/middleware/middleware.go:43.2,43.17 1 0 +processing/internal/delivery/http/middleware/middleware.go:43.17,45.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:47.2,48.16 2 0 +processing/internal/delivery/http/middleware/middleware.go:48.16,50.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:52.2,52.25 1 0 +processing/internal/delivery/http/middleware/middleware.go:52.25,54.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:56.2,56.20 1 0 +processing/internal/usecase/accounts.go:25.96,32.2 2 0 +processing/internal/usecase/accounts.go:35.94,36.57 1 0 +processing/internal/usecase/accounts.go:36.57,39.3 2 0 +processing/internal/usecase/accounts.go:41.2,41.72 1 0 +processing/internal/usecase/accounts.go:41.72,44.3 2 0 +processing/internal/usecase/accounts.go:46.2,47.16 2 0 +processing/internal/usecase/accounts.go:47.16,50.3 2 0 +processing/internal/usecase/accounts.go:51.2,53.56 2 0 +processing/internal/usecase/accounts.go:53.56,56.3 2 0 +processing/internal/usecase/accounts.go:58.2,58.37 1 0 +processing/internal/usecase/accounts.go:58.37,61.3 2 0 +processing/internal/usecase/accounts.go:63.2,64.12 2 0 +processing/internal/usecase/accounts.go:68.99,69.66 1 0 +processing/internal/usecase/accounts.go:69.66,72.3 2 0 +processing/internal/usecase/accounts.go:74.2,75.16 2 0 +processing/internal/usecase/accounts.go:75.16,78.3 2 0 +processing/internal/usecase/accounts.go:79.2,82.16 3 0 +processing/internal/usecase/accounts.go:82.16,85.3 2 0 +processing/internal/usecase/accounts.go:87.2,87.37 1 0 +processing/internal/usecase/accounts.go:87.37,90.3 2 0 +processing/internal/usecase/accounts.go:92.2,93.17 2 0 +processing/internal/usecase/accounts.go:97.143,98.73 1 0 +processing/internal/usecase/accounts.go:98.73,101.3 2 0 +processing/internal/usecase/accounts.go:103.2,104.16 2 0 +processing/internal/usecase/accounts.go:104.16,107.3 2 0 +processing/internal/usecase/accounts.go:108.2,113.16 4 0 +processing/internal/usecase/accounts.go:113.16,116.3 2 0 +processing/internal/usecase/accounts.go:118.2,126.16 4 0 +processing/internal/usecase/accounts.go:126.16,129.3 2 0 +processing/internal/usecase/accounts.go:131.2,131.37 1 0 +processing/internal/usecase/accounts.go:131.37,134.3 2 0 +processing/internal/usecase/accounts.go:136.2,137.33 2 0 +processing/internal/usecase/auth.go:24.89,31.2 2 0 +processing/internal/usecase/auth.go:34.120,35.57 1 0 +processing/internal/usecase/auth.go:35.57,38.3 2 0 +processing/internal/usecase/auth.go:40.2,41.84 2 0 +processing/internal/usecase/auth.go:41.84,44.3 2 0 +processing/internal/usecase/auth.go:46.2,47.16 2 0 +processing/internal/usecase/auth.go:47.16,50.3 2 0 +processing/internal/usecase/auth.go:52.2,53.16 2 0 +processing/internal/usecase/auth.go:53.16,56.3 2 0 +processing/internal/usecase/auth.go:57.2,68.60 3 0 +processing/internal/usecase/auth.go:68.60,71.3 2 0 +processing/internal/usecase/auth.go:73.2,73.37 1 0 +processing/internal/usecase/auth.go:73.37,76.3 2 0 +processing/internal/usecase/auth.go:78.2,79.21 2 0 +processing/internal/usecase/auth.go:83.113,84.57 1 0 +processing/internal/usecase/auth.go:84.57,87.3 2 0 +processing/internal/usecase/auth.go:89.2,90.16 2 0 +processing/internal/usecase/auth.go:90.16,93.3 2 0 +processing/internal/usecase/auth.go:94.2,97.16 3 0 +processing/internal/usecase/auth.go:97.16,100.3 2 0 +processing/internal/usecase/auth.go:102.2,102.102 1 0 +processing/internal/usecase/auth.go:102.102,105.3 2 0 +processing/internal/usecase/auth.go:107.2,108.16 2 0 +processing/internal/usecase/auth.go:108.16,111.3 2 0 +processing/internal/usecase/auth.go:113.2,113.116 1 0 +processing/internal/usecase/auth.go:113.116,116.3 2 0 +processing/internal/usecase/auth.go:118.2,118.37 1 0 +processing/internal/usecase/auth.go:118.37,121.3 2 0 +processing/internal/usecase/auth.go:123.2,129.8 2 0 +processing/internal/usecase/auth.go:133.112,134.57 1 0 +processing/internal/usecase/auth.go:134.57,137.3 2 0 +processing/internal/usecase/auth.go:139.2,140.16 2 0 +processing/internal/usecase/auth.go:140.16,143.3 2 0 +processing/internal/usecase/auth.go:145.2,146.16 2 0 +processing/internal/usecase/auth.go:146.16,149.3 2 0 +processing/internal/usecase/auth.go:150.2,153.16 3 0 +processing/internal/usecase/auth.go:153.16,154.53 1 0 +processing/internal/usecase/auth.go:154.53,157.4 2 0 +processing/internal/usecase/auth.go:158.3,158.18 1 0 +processing/internal/usecase/auth.go:161.2,161.21 1 0 +processing/internal/usecase/auth.go:161.21,164.3 2 0 +processing/internal/usecase/auth.go:166.2,166.41 1 0 +processing/internal/usecase/auth.go:166.41,169.3 2 0 +processing/internal/usecase/auth.go:171.2,171.72 1 0 +processing/internal/usecase/auth.go:171.72,174.3 2 0 +processing/internal/usecase/auth.go:176.2,177.16 2 0 +processing/internal/usecase/auth.go:177.16,180.3 2 0 +processing/internal/usecase/auth.go:182.2,183.16 2 0 +processing/internal/usecase/auth.go:183.16,186.3 2 0 +processing/internal/usecase/auth.go:188.2,188.122 1 0 +processing/internal/usecase/auth.go:188.122,191.3 2 0 +processing/internal/usecase/auth.go:193.2,193.37 1 0 +processing/internal/usecase/auth.go:193.37,196.3 2 0 +processing/internal/usecase/auth.go:198.2,204.8 2 0 +processing/internal/usecase/auth.go:208.90,209.57 1 0 +processing/internal/usecase/auth.go:209.57,211.3 1 0 +processing/internal/usecase/auth.go:213.2,214.16 2 0 +processing/internal/usecase/auth.go:214.16,217.3 2 0 +processing/internal/usecase/auth.go:219.2,220.16 2 0 +processing/internal/usecase/auth.go:220.16,223.3 2 0 +processing/internal/usecase/auth.go:224.2,226.72 2 0 +processing/internal/usecase/auth.go:226.72,227.53 1 0 +processing/internal/usecase/auth.go:227.53,230.4 2 0 +processing/internal/usecase/auth.go:231.3,232.13 2 0 +processing/internal/usecase/auth.go:235.2,235.37 1 0 +processing/internal/usecase/auth.go:235.37,238.3 2 0 +processing/internal/usecase/auth.go:240.2,241.12 2 0 +processing/internal/usecase/auth.go:245.79,247.16 2 0 +processing/internal/usecase/auth.go:247.16,250.3 2 0 +processing/internal/usecase/auth.go:251.2,253.70 2 0 +processing/internal/usecase/auth.go:253.70,256.3 2 0 +processing/internal/usecase/auth.go:258.2,258.37 1 0 +processing/internal/usecase/auth.go:258.37,261.3 2 0 +processing/internal/usecase/auth.go:263.2,264.12 2 0 +processing/internal/usecase/transactions.go:20.108,27.2 2 0 +processing/internal/usecase/transactions.go:37.19,38.73 1 0 +processing/internal/usecase/transactions.go:38.73,41.3 2 0 +processing/internal/usecase/transactions.go:43.2,43.74 1 0 +processing/internal/usecase/transactions.go:43.74,46.3 2 0 +processing/internal/usecase/transactions.go:48.2,49.16 2 0 +processing/internal/usecase/transactions.go:49.16,52.3 2 0 +processing/internal/usecase/transactions.go:54.2,57.16 3 0 +processing/internal/usecase/transactions.go:57.16,60.3 2 0 +processing/internal/usecase/transactions.go:61.2,62.16 2 0 +processing/internal/usecase/transactions.go:62.16,65.3 2 0 +processing/internal/usecase/transactions.go:67.2,67.30 1 0 +processing/internal/usecase/transactions.go:67.30,70.3 2 0 +processing/internal/usecase/transactions.go:72.2,72.26 1 0 +processing/internal/usecase/transactions.go:72.26,75.3 2 0 +processing/internal/usecase/transactions.go:77.2,77.67 1 0 +processing/internal/usecase/transactions.go:77.67,80.3 2 0 +processing/internal/usecase/transactions.go:82.2,82.69 1 0 +processing/internal/usecase/transactions.go:82.69,85.3 2 0 +processing/internal/usecase/transactions.go:87.2,88.16 2 0 +processing/internal/usecase/transactions.go:88.16,91.3 2 0 +processing/internal/usecase/transactions.go:93.2,93.64 1 0 +processing/internal/usecase/transactions.go:93.64,96.3 2 0 +processing/internal/usecase/transactions.go:98.2,98.89 1 0 +processing/internal/usecase/transactions.go:98.89,101.3 2 0 +processing/internal/usecase/transactions.go:103.2,103.37 1 0 +processing/internal/usecase/transactions.go:103.37,106.3 2 0 +processing/internal/usecase/transactions.go:108.2,109.28 2 0 +processing/internal/usecase/transactions.go:118.31,119.70 1 0 +processing/internal/usecase/transactions.go:119.70,122.3 2 0 +processing/internal/usecase/transactions.go:124.2,125.16 2 0 +processing/internal/usecase/transactions.go:125.16,128.3 2 0 +processing/internal/usecase/transactions.go:129.2,132.16 3 0 +processing/internal/usecase/transactions.go:132.16,135.3 2 0 +processing/internal/usecase/transactions.go:137.2,137.74 1 0 +processing/internal/usecase/transactions.go:137.74,140.3 2 0 +processing/internal/usecase/transactions.go:142.2,142.37 1 0 +processing/internal/usecase/transactions.go:142.37,145.3 2 0 +processing/internal/usecase/transactions.go:147.2,147.25 1 0 +processing/internal/usecase/transactions.go:155.33,156.70 1 0 +processing/internal/usecase/transactions.go:156.70,159.3 2 0 +processing/internal/usecase/transactions.go:161.2,162.16 2 0 +processing/internal/usecase/transactions.go:162.16,165.3 2 0 +processing/internal/usecase/transactions.go:166.2,169.16 3 0 +processing/internal/usecase/transactions.go:169.16,172.3 2 0 +processing/internal/usecase/transactions.go:174.2,174.37 1 0 +processing/internal/usecase/transactions.go:174.37,177.3 2 0 +processing/internal/usecase/transactions.go:179.2,180.26 2 0 diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..9d0d684 --- /dev/null +++ b/coverage.out @@ -0,0 +1,749 @@ +mode: set +processing/internal/delivery/http/account_handler.go:20.70,26.16 5 1 +processing/internal/delivery/http/account_handler.go:26.16,29.3 2 1 +processing/internal/delivery/http/account_handler.go:31.2,32.9 2 1 +processing/internal/delivery/http/account_handler.go:32.9,35.3 2 0 +processing/internal/delivery/http/account_handler.go:36.2,36.37 1 1 +processing/internal/delivery/http/account_handler.go:36.37,39.3 2 1 +processing/internal/delivery/http/account_handler.go:41.2,42.16 2 1 +processing/internal/delivery/http/account_handler.go:42.16,45.3 2 1 +processing/internal/delivery/http/account_handler.go:47.2,47.61 1 1 +processing/internal/delivery/http/account_handler.go:47.61,49.3 1 0 +processing/internal/delivery/http/account_handler.go:58.79,64.16 5 1 +processing/internal/delivery/http/account_handler.go:64.16,67.3 2 1 +processing/internal/delivery/http/account_handler.go:69.2,70.9 2 1 +processing/internal/delivery/http/account_handler.go:70.9,73.3 2 0 +processing/internal/delivery/http/account_handler.go:74.2,74.37 1 1 +processing/internal/delivery/http/account_handler.go:74.37,77.3 2 1 +processing/internal/delivery/http/account_handler.go:79.2,82.16 4 1 +processing/internal/delivery/http/account_handler.go:82.16,84.3 1 1 +processing/internal/delivery/http/account_handler.go:85.2,86.16 2 1 +processing/internal/delivery/http/account_handler.go:86.16,88.3 1 1 +processing/internal/delivery/http/account_handler.go:90.2,91.16 2 1 +processing/internal/delivery/http/account_handler.go:91.16,94.3 2 1 +processing/internal/delivery/http/account_handler.go:96.2,101.57 2 1 +processing/internal/delivery/http/account_handler.go:101.57,103.3 1 0 +processing/internal/delivery/http/auth_handler.go:18.68,24.61 5 1 +processing/internal/delivery/http/auth_handler.go:24.61,27.3 2 1 +processing/internal/delivery/http/auth_handler.go:29.2,29.32 1 1 +processing/internal/delivery/http/auth_handler.go:29.32,31.3 1 1 +processing/internal/delivery/http/auth_handler.go:33.2,34.16 2 1 +processing/internal/delivery/http/auth_handler.go:34.16,35.52 1 1 +processing/internal/delivery/http/auth_handler.go:35.52,38.4 2 0 +processing/internal/delivery/http/auth_handler.go:39.3,40.9 2 1 +processing/internal/delivery/http/auth_handler.go:43.2,44.16 2 1 +processing/internal/delivery/http/auth_handler.go:44.16,47.3 2 1 +processing/internal/delivery/http/auth_handler.go:49.2,54.17 3 1 +processing/internal/delivery/http/auth_handler.go:54.17,56.3 1 0 +processing/internal/delivery/http/auth_handler.go:59.65,65.61 5 1 +processing/internal/delivery/http/auth_handler.go:65.61,68.3 2 1 +processing/internal/delivery/http/auth_handler.go:70.2,70.29 1 1 +processing/internal/delivery/http/auth_handler.go:70.29,72.3 1 1 +processing/internal/delivery/http/auth_handler.go:74.2,75.16 2 1 +processing/internal/delivery/http/auth_handler.go:75.16,76.51 1 1 +processing/internal/delivery/http/auth_handler.go:76.51,79.4 2 0 +processing/internal/delivery/http/auth_handler.go:80.3,81.9 2 1 +processing/internal/delivery/http/auth_handler.go:84.2,86.59 3 1 +processing/internal/delivery/http/auth_handler.go:86.59,88.3 1 0 +processing/internal/delivery/http/auth_handler.go:91.67,96.16 5 1 +processing/internal/delivery/http/auth_handler.go:96.16,98.3 1 1 +processing/internal/delivery/http/auth_handler.go:98.8,102.62 2 1 +processing/internal/delivery/http/auth_handler.go:102.62,104.4 1 1 +processing/internal/delivery/http/auth_handler.go:106.2,107.24 2 1 +processing/internal/delivery/http/auth_handler.go:107.24,110.3 2 1 +processing/internal/delivery/http/auth_handler.go:112.2,113.16 2 1 +processing/internal/delivery/http/auth_handler.go:113.16,116.3 2 1 +processing/internal/delivery/http/auth_handler.go:118.2,120.59 3 1 +processing/internal/delivery/http/auth_handler.go:120.59,122.3 1 0 +processing/internal/delivery/http/auth_handler.go:125.66,130.16 5 1 +processing/internal/delivery/http/auth_handler.go:130.16,132.3 1 1 +processing/internal/delivery/http/auth_handler.go:132.8,136.62 2 1 +processing/internal/delivery/http/auth_handler.go:136.62,139.4 2 1 +processing/internal/delivery/http/auth_handler.go:140.3,140.34 1 1 +processing/internal/delivery/http/auth_handler.go:142.2,143.24 2 1 +processing/internal/delivery/http/auth_handler.go:143.24,146.3 2 1 +processing/internal/delivery/http/auth_handler.go:148.2,148.61 1 1 +processing/internal/delivery/http/auth_handler.go:148.61,150.3 1 1 +processing/internal/delivery/http/auth_handler.go:151.2,153.93 3 1 +processing/internal/delivery/http/auth_handler.go:153.93,155.3 1 0 +processing/internal/delivery/http/auth_handler.go:158.69,163.9 4 1 +processing/internal/delivery/http/auth_handler.go:163.9,166.3 2 1 +processing/internal/delivery/http/auth_handler.go:168.2,169.16 2 1 +processing/internal/delivery/http/auth_handler.go:169.16,172.3 2 1 +processing/internal/delivery/http/auth_handler.go:174.2,174.54 1 1 +processing/internal/delivery/http/auth_handler.go:174.54,176.3 1 1 +processing/internal/delivery/http/auth_handler.go:177.2,179.93 3 1 +processing/internal/delivery/http/auth_handler.go:179.93,181.3 1 0 +processing/internal/delivery/http/handler.go:21.12,29.2 2 1 +processing/internal/delivery/http/helpers.go:18.80,20.16 2 1 +processing/internal/delivery/http/helpers.go:20.16,22.3 1 1 +processing/internal/delivery/http/helpers.go:23.2,24.16 2 1 +processing/internal/delivery/http/helpers.go:24.16,26.3 1 0 +processing/internal/delivery/http/helpers.go:28.2,32.16 4 1 +processing/internal/delivery/http/helpers.go:32.16,34.3 1 1 +processing/internal/delivery/http/helpers.go:35.2,36.16 2 1 +processing/internal/delivery/http/helpers.go:36.16,38.3 1 0 +processing/internal/delivery/http/helpers.go:40.2,41.16 2 1 +processing/internal/delivery/http/helpers.go:41.16,43.3 1 0 +processing/internal/delivery/http/helpers.go:45.2,46.16 2 1 +processing/internal/delivery/http/helpers.go:46.16,48.3 1 0 +processing/internal/delivery/http/helpers.go:50.2,59.8 1 1 +processing/internal/delivery/http/helpers.go:62.65,64.15 2 1 +processing/internal/delivery/http/helpers.go:64.15,66.3 1 1 +processing/internal/delivery/http/helpers.go:68.2,69.16 2 1 +processing/internal/delivery/http/helpers.go:69.16,71.3 1 1 +processing/internal/delivery/http/helpers.go:72.2,72.16 1 1 +processing/internal/delivery/http/helpers.go:75.65,77.15 2 1 +processing/internal/delivery/http/helpers.go:77.15,79.3 1 1 +processing/internal/delivery/http/helpers.go:81.2,82.16 2 1 +processing/internal/delivery/http/helpers.go:82.16,84.3 1 0 +processing/internal/delivery/http/helpers.go:86.2,86.15 1 1 +processing/internal/delivery/http/helpers.go:89.28,101.2 3 1 +processing/internal/delivery/http/helpers.go:105.71,109.15 3 1 +processing/internal/delivery/http/helpers.go:109.15,115.3 2 1 +processing/internal/delivery/http/helpers.go:116.2,116.69 1 1 +processing/internal/delivery/http/helpers.go:119.62,121.56 2 1 +processing/internal/delivery/http/helpers.go:121.56,125.3 3 0 +processing/internal/delivery/http/helpers.go:126.2,129.12 4 1 +processing/internal/delivery/http/helpers.go:132.81,142.2 1 1 +processing/internal/delivery/http/helpers.go:144.63,147.22 2 1 +processing/internal/delivery/http/helpers.go:147.22,150.3 2 1 +processing/internal/delivery/http/helpers.go:152.2,152.25 1 1 +processing/internal/delivery/http/helpers.go:152.25,155.3 2 1 +processing/internal/delivery/http/helpers.go:157.2,159.34 3 1 +processing/internal/delivery/http/helpers.go:159.34,166.3 2 1 +processing/internal/delivery/http/helpers.go:168.2,168.28 1 1 +processing/internal/delivery/http/helpers.go:168.28,171.3 2 1 +processing/internal/delivery/http/helpers.go:173.2,173.13 1 1 +processing/internal/delivery/http/helpers.go:176.66,180.21 3 1 +processing/internal/delivery/http/helpers.go:180.21,183.3 2 1 +processing/internal/delivery/http/helpers.go:185.2,185.22 1 1 +processing/internal/delivery/http/helpers.go:185.22,188.3 2 1 +processing/internal/delivery/http/helpers.go:190.2,190.25 1 1 +processing/internal/delivery/http/helpers.go:190.25,193.3 2 1 +processing/internal/delivery/http/helpers.go:195.2,195.24 1 1 +processing/internal/delivery/http/helpers.go:195.24,198.3 2 1 +processing/internal/delivery/http/helpers.go:200.2,202.34 3 1 +processing/internal/delivery/http/helpers.go:202.34,209.3 2 1 +processing/internal/delivery/http/helpers.go:211.2,211.28 1 1 +processing/internal/delivery/http/helpers.go:211.28,214.3 2 1 +processing/internal/delivery/http/helpers.go:216.2,216.13 1 1 +processing/internal/delivery/http/transaction_handler.go:20.68,27.9 5 1 +processing/internal/delivery/http/transaction_handler.go:27.9,30.3 2 0 +processing/internal/delivery/http/transaction_handler.go:32.2,33.16 2 1 +processing/internal/delivery/http/transaction_handler.go:33.16,36.3 2 0 +processing/internal/delivery/http/transaction_handler.go:38.2,40.61 3 1 +processing/internal/delivery/http/transaction_handler.go:40.61,43.3 2 1 +processing/internal/delivery/http/transaction_handler.go:45.2,46.16 2 1 +processing/internal/delivery/http/transaction_handler.go:46.16,49.3 2 1 +processing/internal/delivery/http/transaction_handler.go:51.2,52.16 2 1 +processing/internal/delivery/http/transaction_handler.go:52.16,53.187 1 1 +processing/internal/delivery/http/transaction_handler.go:53.187,56.4 2 0 +processing/internal/delivery/http/transaction_handler.go:57.3,58.9 2 1 +processing/internal/delivery/http/transaction_handler.go:61.2,61.109 1 1 +processing/internal/delivery/http/transaction_handler.go:61.109,63.3 1 0 +processing/internal/delivery/http/transaction_handler.go:73.74,77.16 4 1 +processing/internal/delivery/http/transaction_handler.go:77.16,80.3 2 1 +processing/internal/delivery/http/transaction_handler.go:82.2,84.9 3 1 +processing/internal/delivery/http/transaction_handler.go:84.9,87.3 2 0 +processing/internal/delivery/http/transaction_handler.go:89.2,90.16 2 1 +processing/internal/delivery/http/transaction_handler.go:90.16,93.3 2 0 +processing/internal/delivery/http/transaction_handler.go:95.2,97.16 3 1 +processing/internal/delivery/http/transaction_handler.go:97.16,98.45 1 1 +processing/internal/delivery/http/transaction_handler.go:98.45,101.4 2 1 +processing/internal/delivery/http/transaction_handler.go:102.3,103.9 2 1 +processing/internal/delivery/http/transaction_handler.go:106.2,106.65 1 1 +processing/internal/delivery/http/transaction_handler.go:106.65,108.3 1 0 +processing/internal/delivery/http/transaction_handler.go:115.77,121.9 5 1 +processing/internal/delivery/http/transaction_handler.go:121.9,124.3 2 0 +processing/internal/delivery/http/transaction_handler.go:126.2,127.16 2 1 +processing/internal/delivery/http/transaction_handler.go:127.16,130.3 2 0 +processing/internal/delivery/http/transaction_handler.go:132.2,135.16 4 1 +processing/internal/delivery/http/transaction_handler.go:135.16,138.3 2 1 +processing/internal/delivery/http/transaction_handler.go:140.2,141.16 2 1 +processing/internal/delivery/http/transaction_handler.go:141.16,144.3 2 1 +processing/internal/delivery/http/transaction_handler.go:146.2,146.66 1 1 +processing/internal/delivery/http/transaction_handler.go:146.66,148.3 1 0 +processing/internal/infrastructure/cache/redis.go:58.44,68.2 2 1 +processing/internal/infrastructure/cache/redis.go:72.96,74.16 2 1 +processing/internal/infrastructure/cache/redis.go:74.16,76.3 1 0 +processing/internal/infrastructure/cache/redis.go:78.2,78.10 1 1 +processing/internal/infrastructure/cache/redis.go:78.10,80.3 1 1 +processing/internal/infrastructure/cache/redis.go:81.2,81.12 1 1 +processing/internal/infrastructure/cache/redis.go:86.74,87.91 1 1 +processing/internal/infrastructure/cache/redis.go:87.91,89.3 1 1 +processing/internal/infrastructure/cache/redis.go:91.2,91.91 1 1 +processing/internal/infrastructure/cache/redis.go:91.91,93.3 1 0 +processing/internal/infrastructure/cache/redis.go:95.2,95.92 1 1 +processing/internal/infrastructure/cache/redis.go:95.92,97.3 1 0 +processing/internal/infrastructure/cache/redis.go:99.2,99.12 1 1 +processing/internal/infrastructure/cache/redis.go:102.121,109.16 6 1 +processing/internal/infrastructure/cache/redis.go:109.16,111.3 1 0 +processing/internal/infrastructure/cache/redis.go:113.2,114.9 2 1 +processing/internal/infrastructure/cache/redis.go:114.9,116.3 1 0 +processing/internal/infrastructure/cache/redis.go:118.2,118.18 1 1 +processing/internal/infrastructure/cache/redis.go:118.18,120.3 1 1 +processing/internal/infrastructure/cache/redis.go:122.2,122.12 1 1 +processing/internal/infrastructure/storage/helper.go:12.95,17.34 4 0 +processing/internal/infrastructure/storage/helper.go:17.34,21.3 3 0 +processing/internal/infrastructure/storage/helper.go:23.2,23.33 1 0 +processing/internal/infrastructure/storage/helper.go:23.33,27.3 3 0 +processing/internal/infrastructure/storage/helper.go:29.2,29.35 1 0 +processing/internal/infrastructure/storage/helper.go:29.35,33.3 3 0 +processing/internal/infrastructure/storage/helper.go:35.2,35.28 1 0 +processing/internal/infrastructure/storage/helper.go:35.28,37.17 2 0 +processing/internal/infrastructure/storage/helper.go:37.17,39.4 1 0 +processing/internal/infrastructure/storage/helper.go:40.3,42.15 3 0 +processing/internal/infrastructure/storage/helper.go:45.2,45.28 1 0 +processing/internal/infrastructure/storage/helper.go:45.28,47.17 2 0 +processing/internal/infrastructure/storage/helper.go:47.17,49.4 1 0 +processing/internal/infrastructure/storage/helper.go:50.3,52.15 3 0 +processing/internal/infrastructure/storage/helper.go:55.2,55.27 1 0 +processing/internal/infrastructure/storage/helper.go:55.27,59.3 3 0 +processing/internal/infrastructure/storage/helper.go:61.2,61.25 1 0 +processing/internal/infrastructure/storage/helper.go:61.25,65.3 3 0 +processing/internal/infrastructure/storage/helper.go:67.2,69.22 2 0 +processing/internal/infrastructure/storage/helper.go:69.22,73.3 3 0 +processing/internal/infrastructure/storage/helper.go:75.2,75.23 1 0 +processing/internal/infrastructure/storage/helper.go:75.23,78.3 2 0 +processing/internal/infrastructure/storage/helper.go:80.2,80.20 1 0 +processing/internal/infrastructure/storage/storage.go:36.58,36.79 1 0 +processing/internal/infrastructure/storage/storage.go:37.58,37.74 1 0 +processing/internal/infrastructure/storage/storage.go:38.58,38.76 1 0 +processing/internal/infrastructure/storage/storage.go:40.32,42.2 1 0 +processing/internal/infrastructure/storage/storage.go:44.34,46.2 1 0 +processing/internal/infrastructure/storage/storage.go:52.45,54.2 1 0 +processing/internal/infrastructure/storage/storage.go:57.76,59.16 2 0 +processing/internal/infrastructure/storage/storage.go:59.16,61.3 1 0 +processing/internal/infrastructure/storage/storage.go:62.2,67.8 1 0 +processing/internal/infrastructure/storage/storage.go:71.77,74.120 2 0 +processing/internal/infrastructure/storage/storage.go:74.120,76.54 2 0 +processing/internal/infrastructure/storage/storage.go:76.54,78.4 1 0 +processing/internal/infrastructure/storage/storage.go:79.3,79.68 1 0 +processing/internal/infrastructure/storage/storage.go:81.2,81.12 1 0 +processing/internal/infrastructure/storage/storage.go:85.91,89.16 4 0 +processing/internal/infrastructure/storage/storage.go:89.16,90.36 1 0 +processing/internal/infrastructure/storage/storage.go:90.36,92.4 1 0 +processing/internal/infrastructure/storage/storage.go:93.3,93.94 1 0 +processing/internal/infrastructure/storage/storage.go:95.2,95.16 1 0 +processing/internal/infrastructure/storage/storage.go:99.94,103.16 4 0 +processing/internal/infrastructure/storage/storage.go:103.16,104.36 1 0 +processing/internal/infrastructure/storage/storage.go:104.36,106.4 1 0 +processing/internal/infrastructure/storage/storage.go:107.3,107.97 1 0 +processing/internal/infrastructure/storage/storage.go:109.2,109.16 1 0 +processing/internal/infrastructure/storage/storage.go:113.99,120.16 3 0 +processing/internal/infrastructure/storage/storage.go:120.16,122.3 1 0 +processing/internal/infrastructure/storage/storage.go:124.2,125.16 2 0 +processing/internal/infrastructure/storage/storage.go:125.16,127.3 1 0 +processing/internal/infrastructure/storage/storage.go:128.2,128.15 1 0 +processing/internal/infrastructure/storage/storage.go:128.15,130.3 1 0 +processing/internal/infrastructure/storage/storage.go:132.2,132.12 1 0 +processing/internal/infrastructure/storage/storage.go:136.101,139.16 3 0 +processing/internal/infrastructure/storage/storage.go:139.16,141.3 1 0 +processing/internal/infrastructure/storage/storage.go:143.2,144.16 2 0 +processing/internal/infrastructure/storage/storage.go:144.16,146.3 1 0 +processing/internal/infrastructure/storage/storage.go:148.2,148.15 1 0 +processing/internal/infrastructure/storage/storage.go:148.15,150.3 1 0 +processing/internal/infrastructure/storage/storage.go:152.2,152.12 1 0 +processing/internal/infrastructure/storage/storage.go:156.81,162.48 2 0 +processing/internal/infrastructure/storage/storage.go:162.48,164.3 1 0 +processing/internal/infrastructure/storage/storage.go:165.2,165.12 1 0 +processing/internal/infrastructure/storage/storage.go:169.115,174.105 2 0 +processing/internal/infrastructure/storage/storage.go:174.105,176.3 1 0 +processing/internal/infrastructure/storage/storage.go:177.2,177.12 1 0 +processing/internal/infrastructure/storage/storage.go:180.100,193.16 4 0 +processing/internal/infrastructure/storage/storage.go:193.16,195.3 1 0 +processing/internal/infrastructure/storage/storage.go:196.2,196.25 1 0 +processing/internal/infrastructure/storage/storage.go:200.118,202.17 2 0 +processing/internal/infrastructure/storage/storage.go:202.17,204.3 1 0 +processing/internal/infrastructure/storage/storage.go:206.2,207.16 2 0 +processing/internal/infrastructure/storage/storage.go:207.16,209.3 1 0 +processing/internal/infrastructure/storage/storage.go:210.2,213.18 3 0 +processing/internal/infrastructure/storage/storage.go:213.18,223.17 3 0 +processing/internal/infrastructure/storage/storage.go:223.17,225.4 1 0 +processing/internal/infrastructure/storage/storage.go:226.3,226.41 1 0 +processing/internal/infrastructure/storage/storage.go:229.2,229.34 1 0 +processing/internal/infrastructure/storage/storage.go:229.34,231.3 1 0 +processing/internal/infrastructure/storage/storage.go:233.2,233.26 1 0 +processing/internal/infrastructure/storage/storage.go:236.88,240.78 3 0 +processing/internal/infrastructure/storage/storage.go:240.78,242.3 1 0 +processing/internal/infrastructure/storage/storage.go:244.2,244.19 1 0 +processing/internal/infrastructure/storage/storage.go:247.113,251.16 3 0 +processing/internal/infrastructure/storage/storage.go:251.16,253.3 1 0 +processing/internal/infrastructure/storage/storage.go:255.2,256.16 2 0 +processing/internal/infrastructure/storage/storage.go:256.16,258.3 1 0 +processing/internal/infrastructure/storage/storage.go:260.2,260.15 1 0 +processing/internal/infrastructure/storage/storage.go:260.15,262.3 1 0 +processing/internal/infrastructure/storage/storage.go:264.2,264.12 1 0 +processing/internal/infrastructure/storage/storage.go:267.100,272.16 4 0 +processing/internal/infrastructure/storage/storage.go:272.16,273.36 1 0 +processing/internal/infrastructure/storage/storage.go:273.36,275.4 1 0 +processing/internal/infrastructure/storage/storage.go:276.3,276.77 1 0 +processing/internal/infrastructure/storage/storage.go:279.2,279.21 1 0 +processing/internal/infrastructure/storage/storage.go:282.77,286.16 3 0 +processing/internal/infrastructure/storage/storage.go:286.16,288.3 1 0 +processing/internal/infrastructure/storage/storage.go:290.2,291.16 2 0 +processing/internal/infrastructure/storage/storage.go:291.16,293.3 1 0 +processing/internal/infrastructure/storage/storage.go:295.2,295.15 1 0 +processing/internal/infrastructure/storage/storage.go:295.15,297.3 1 0 +processing/internal/infrastructure/storage/storage.go:299.2,299.12 1 0 +processing/internal/infrastructure/storage/storage.go:302.84,306.16 3 0 +processing/internal/infrastructure/storage/storage.go:306.16,308.3 1 0 +processing/internal/infrastructure/storage/storage.go:310.2,311.16 2 0 +processing/internal/infrastructure/storage/storage.go:311.16,313.3 1 0 +processing/internal/infrastructure/storage/storage.go:315.2,315.15 1 0 +processing/internal/infrastructure/storage/storage.go:315.15,317.3 1 0 +processing/internal/infrastructure/storage/storage.go:319.2,319.12 1 0 +processing/internal/decimal/decimal.go:20.51,28.26 6 0 +processing/internal/decimal/decimal.go:28.26,29.27 1 0 +processing/internal/decimal/decimal.go:29.27,30.19 1 0 +processing/internal/decimal/decimal.go:30.19,32.5 1 0 +processing/internal/decimal/decimal.go:33.4,34.12 2 0 +processing/internal/decimal/decimal.go:37.3,37.15 1 0 +processing/internal/decimal/decimal.go:37.15,38.19 1 0 +processing/internal/decimal/decimal.go:38.19,40.5 1 0 +processing/internal/decimal/decimal.go:41.4,41.14 1 0 +processing/internal/decimal/decimal.go:45.2,45.18 1 0 +processing/internal/decimal/decimal.go:45.18,47.17 2 0 +processing/internal/decimal/decimal.go:47.17,48.73 1 0 +processing/internal/decimal/decimal.go:48.73,50.5 1 0 +processing/internal/decimal/decimal.go:51.4,51.95 1 0 +processing/internal/decimal/decimal.go:53.3,54.15 2 0 +processing/internal/decimal/decimal.go:57.2,57.18 1 0 +processing/internal/decimal/decimal.go:57.18,61.3 1 0 +processing/internal/decimal/decimal.go:61.8,62.28 1 0 +processing/internal/decimal/decimal.go:62.28,64.4 1 0 +processing/internal/decimal/decimal.go:64.9,66.4 1 0 +processing/internal/decimal/decimal.go:67.3,68.23 2 0 +processing/internal/decimal/decimal.go:71.2,73.26 2 0 +processing/internal/decimal/decimal.go:73.26,75.17 2 0 +processing/internal/decimal/decimal.go:75.17,77.4 1 0 +processing/internal/decimal/decimal.go:78.3,78.32 1 0 +processing/internal/decimal/decimal.go:79.8,82.10 3 0 +processing/internal/decimal/decimal.go:82.10,84.4 1 0 +processing/internal/decimal/decimal.go:87.2,87.48 1 0 +processing/internal/decimal/decimal.go:87.48,90.3 1 0 +processing/internal/decimal/decimal.go:92.2,95.8 1 0 +processing/internal/decimal/decimal.go:98.34,100.2 1 0 +processing/internal/decimal/decimal.go:103.42,111.2 3 0 +processing/internal/decimal/decimal.go:114.42,122.2 3 0 +processing/internal/decimal/decimal.go:125.61,126.21 1 0 +processing/internal/decimal/decimal.go:126.21,128.3 1 0 +processing/internal/decimal/decimal.go:128.8,128.28 1 0 +processing/internal/decimal/decimal.go:128.28,130.3 1 0 +processing/internal/decimal/decimal.go:132.2,132.15 1 0 +processing/internal/decimal/decimal.go:140.42,142.2 1 0 +processing/internal/decimal/decimal.go:144.38,145.21 1 0 +processing/internal/decimal/decimal.go:145.21,147.3 1 0 +processing/internal/decimal/decimal.go:149.2,151.42 2 0 +processing/internal/decimal/decimal.go:154.52,157.19 3 0 +processing/internal/decimal/decimal.go:157.19,159.3 1 0 +processing/internal/decimal/decimal.go:160.2,162.21 3 0 +processing/internal/decimal/decimal.go:162.21,165.3 2 0 +processing/internal/decimal/decimal.go:166.2,167.24 2 0 +processing/internal/decimal/decimal.go:167.24,169.3 1 0 +processing/internal/decimal/decimal.go:170.2,170.15 1 0 +processing/internal/decimal/decimal.go:173.79,174.16 1 0 +processing/internal/decimal/decimal.go:174.16,176.3 1 0 +processing/internal/decimal/decimal.go:177.2,177.16 1 0 +processing/internal/decimal/decimal.go:177.16,178.28 1 0 +processing/internal/decimal/decimal.go:178.28,180.4 1 0 +processing/internal/decimal/decimal.go:180.9,182.4 1 0 +processing/internal/decimal/decimal.go:185.2,193.25 5 0 +processing/internal/decimal/decimal.go:193.25,196.3 2 0 +processing/internal/decimal/decimal.go:196.8,201.3 3 0 +processing/internal/decimal/decimal.go:203.2,203.23 1 0 +processing/internal/decimal/decimal.go:203.23,205.21 2 0 +processing/internal/decimal/decimal.go:205.21,206.32 1 0 +processing/internal/decimal/decimal.go:206.32,207.10 1 0 +processing/internal/decimal/decimal.go:210.3,210.40 1 0 +processing/internal/decimal/decimal.go:213.2,214.29 2 0 +processing/internal/decimal/decimal.go:214.29,216.3 1 0 +processing/internal/decimal/decimal.go:218.2,218.29 1 0 +processing/internal/decimal/decimal.go:218.29,220.3 1 0 +processing/internal/decimal/decimal.go:222.2,222.15 1 0 +processing/internal/decimal/decimal.go:225.38,226.20 1 0 +processing/internal/decimal/decimal.go:226.20,228.3 1 0 +processing/internal/decimal/decimal.go:229.2,229.16 1 0 +processing/internal/decimal/decimal.go:232.45,233.18 1 0 +processing/internal/decimal/decimal.go:233.18,238.3 1 0 +processing/internal/decimal/decimal.go:241.2,245.17 4 0 +processing/internal/decimal/decimal.go:245.17,247.3 1 0 +processing/internal/decimal/decimal.go:247.8,247.24 1 0 +processing/internal/decimal/decimal.go:247.24,249.3 1 0 +processing/internal/decimal/decimal.go:251.2,254.3 1 0 +processing/internal/decimal/decimal.go:262.29,264.2 1 0 +processing/internal/decimal/decimal.go:271.36,273.2 1 0 +processing/internal/decimal/decimal.go:275.21,280.2 1 0 +processing/internal/decimal/decimal.go:283.48,285.2 1 0 +processing/internal/decimal/decimal.go:288.52,290.49 2 0 +processing/internal/decimal/decimal.go:290.49,293.52 2 0 +processing/internal/decimal/decimal.go:293.52,295.4 1 0 +processing/internal/decimal/decimal.go:295.9,297.4 1 0 +processing/internal/decimal/decimal.go:300.2,302.12 3 0 +processing/internal/decimal/sql.go:9.49,11.27 1 0 +processing/internal/decimal/sql.go:12.14,15.13 3 0 +processing/internal/decimal/sql.go:17.14,20.13 3 0 +processing/internal/decimal/sql.go:22.10,23.78 1 0 +processing/internal/decimal/sql.go:28.48,30.2 1 0 +processing/internal/decimal/sql.go:32.43,34.69 1 0 +processing/internal/decimal/sql.go:34.69,36.3 1 0 +processing/internal/decimal/sql.go:38.2,38.14 1 0 +processing/internal/infrastructure/config/config.go:37.30,38.40 1 0 +processing/internal/infrastructure/config/config.go:38.40,40.3 1 0 +processing/internal/infrastructure/config/config.go:42.2,65.17 4 0 +processing/internal/infrastructure/config/config.go:68.47,73.2 1 0 +processing/internal/infrastructure/config/config.go:75.41,80.2 1 0 +processing/internal/infrastructure/config/config.go:82.46,83.42 1 0 +processing/internal/infrastructure/config/config.go:83.42,85.3 1 0 +processing/internal/infrastructure/config/config.go:86.2,86.21 1 0 +processing/internal/infrastructure/config/config.go:89.56,90.42 1 0 +processing/internal/infrastructure/config/config.go:90.42,91.60 1 0 +processing/internal/infrastructure/config/config.go:91.60,93.4 1 0 +processing/internal/infrastructure/config/config.go:95.2,95.21 1 0 +processing/internal/delivery/http/jwt/jwt.go:19.79,23.53 3 0 +processing/internal/delivery/http/jwt/jwt.go:23.53,25.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:27.2,42.16 7 0 +processing/internal/delivery/http/jwt/jwt.go:42.16,44.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:46.2,47.16 2 0 +processing/internal/delivery/http/jwt/jwt.go:47.16,49.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:51.2,62.16 4 0 +processing/internal/delivery/http/jwt/jwt.go:62.16,64.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:66.2,72.8 1 0 +processing/internal/delivery/http/jwt/jwt.go:75.76,80.43 2 0 +processing/internal/delivery/http/jwt/jwt.go:80.43,81.55 1 0 +processing/internal/delivery/http/jwt/jwt.go:81.55,83.5 1 0 +processing/internal/delivery/http/jwt/jwt.go:84.4,84.39 1 0 +processing/internal/delivery/http/jwt/jwt.go:87.2,87.16 1 0 +processing/internal/delivery/http/jwt/jwt.go:87.16,88.42 1 0 +processing/internal/delivery/http/jwt/jwt.go:88.42,90.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:90.9,90.58 1 0 +processing/internal/delivery/http/jwt/jwt.go:90.58,92.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:93.3,93.69 1 0 +processing/internal/delivery/http/jwt/jwt.go:96.2,97.9 2 0 +processing/internal/delivery/http/jwt/jwt.go:97.9,99.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:101.2,101.20 1 0 +processing/internal/delivery/http/jwt/jwt.go:104.78,110.43 2 0 +processing/internal/delivery/http/jwt/jwt.go:110.43,111.55 1 0 +processing/internal/delivery/http/jwt/jwt.go:111.55,113.5 1 0 +processing/internal/delivery/http/jwt/jwt.go:114.4,114.40 1 0 +processing/internal/delivery/http/jwt/jwt.go:117.2,117.16 1 0 +processing/internal/delivery/http/jwt/jwt.go:117.16,118.42 1 0 +processing/internal/delivery/http/jwt/jwt.go:118.42,120.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:120.9,120.58 1 0 +processing/internal/delivery/http/jwt/jwt.go:120.58,122.4 1 0 +processing/internal/delivery/http/jwt/jwt.go:123.3,123.69 1 0 +processing/internal/delivery/http/jwt/jwt.go:126.2,127.9 2 0 +processing/internal/delivery/http/jwt/jwt.go:127.9,129.3 1 0 +processing/internal/delivery/http/jwt/jwt.go:131.2,131.20 1 0 +processing/internal/infrastructure/logger/logger.go:8.67,10.62 2 0 +processing/internal/infrastructure/logger/logger.go:10.62,12.3 1 0 +processing/internal/infrastructure/logger/logger.go:14.2,15.13 2 0 +processing/internal/infrastructure/logger/logger.go:16.20,20.6 1 0 +processing/internal/infrastructure/logger/logger.go:21.10,25.6 1 0 +processing/internal/infrastructure/logger/logger.go:28.2,28.20 1 0 +processing/internal/infrastructure/logger/logger.go:31.68,32.19 1 0 +processing/internal/infrastructure/logger/logger.go:32.19,34.3 1 0 +processing/internal/infrastructure/logger/logger.go:35.2,35.40 1 0 +processing/internal/delivery/http/middleware/middleware.go:13.53,14.71 1 0 +processing/internal/delivery/http/middleware/middleware.go:14.71,16.17 2 0 +processing/internal/delivery/http/middleware/middleware.go:16.17,19.4 2 0 +processing/internal/delivery/http/middleware/middleware.go:21.3,23.40 3 0 +processing/internal/delivery/http/middleware/middleware.go:27.67,30.22 3 0 +processing/internal/delivery/http/middleware/middleware.go:30.22,31.48 1 0 +processing/internal/delivery/http/middleware/middleware.go:31.48,33.4 1 0 +processing/internal/delivery/http/middleware/middleware.go:34.3,34.52 1 0 +processing/internal/delivery/http/middleware/middleware.go:35.8,37.17 2 0 +processing/internal/delivery/http/middleware/middleware.go:37.17,39.4 1 0 +processing/internal/delivery/http/middleware/middleware.go:40.3,40.23 1 0 +processing/internal/delivery/http/middleware/middleware.go:43.2,43.17 1 0 +processing/internal/delivery/http/middleware/middleware.go:43.17,45.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:47.2,48.16 2 0 +processing/internal/delivery/http/middleware/middleware.go:48.16,50.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:52.2,52.25 1 0 +processing/internal/delivery/http/middleware/middleware.go:52.25,54.3 1 0 +processing/internal/delivery/http/middleware/middleware.go:56.2,56.20 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:20.94,23.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:23.19,24.48 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:27.2,28.85 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:28.85,30.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:30.8,32.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:34.2,34.11 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:38.99,41.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:41.19,42.52 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:45.2,47.90 3 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:47.90,49.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:50.2,50.81 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:50.81,52.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:52.8,53.24 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:53.24,55.4 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:58.2,58.71 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:58.71,60.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:60.8,62.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:64.2,64.15 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:68.147,71.19 2 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:71.19,72.60 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:75.2,78.110 4 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:78.110,80.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:81.2,81.79 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:81.79,83.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:83.8,85.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:87.2,87.96 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:87.96,89.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:89.8,90.24 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:90.24,92.4 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:95.2,95.81 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:95.81,97.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:97.8,99.3 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:101.2,101.19 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:109.21,113.19 3 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:113.19,113.49 1 0 +processing/internal/delivery/http/mocks/AccountsUsecase.go:115.2,115.13 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:20.120,23.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:23.19,24.47 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:27.2,29.105 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:29.105,31.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:32.2,32.96 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:32.96,34.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:34.8,35.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:35.24,37.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:40.2,40.84 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:40.84,42.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:42.8,44.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:46.2,46.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:50.90,53.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:53.19,54.48 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:57.2,58.76 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:58.76,60.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:60.8,62.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:64.2,64.11 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:68.79,71.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:71.19,72.51 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:75.2,76.71 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:76.71,78.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:78.8,80.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:82.2,82.11 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:86.112,89.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:89.19,90.49 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:93.2,95.97 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:95.97,97.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:98.2,98.88 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:98.88,100.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:100.8,101.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:101.24,103.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:106.2,106.76 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:106.76,108.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:108.8,110.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:112.2,112.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:116.134,119.19 2 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:119.19,120.50 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:123.2,125.111 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:125.111,127.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:128.2,128.102 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:128.102,130.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:130.8,131.24 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:131.24,133.4 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:136.2,136.92 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:136.92,138.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:138.8,140.3 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:142.2,142.15 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:150.17,154.19 3 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:154.19,154.49 1 0 +processing/internal/delivery/http/mocks/AuthUseCase.go:156.2,156.13 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:21.150,24.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:24.19,25.56 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:28.2,30.112 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:30.112,32.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:33.2,33.103 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:33.103,35.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:35.8,37.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:39.2,39.90 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:39.90,41.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:41.8,43.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:45.2,45.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:49.162,52.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:52.19,53.62 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:56.2,58.130 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:58.130,60.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:61.2,61.121 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:61.121,63.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:63.8,64.24 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:64.24,66.4 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:69.2,69.106 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:69.106,71.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:71.8,73.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:75.2,75.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:79.157,82.19 2 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:82.19,83.50 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:86.2,88.117 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:88.117,90.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:91.2,91.108 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:91.108,93.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:93.8,95.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:97.2,97.107 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:97.107,99.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:99.8,101.3 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:103.2,103.15 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:111.24,115.19 3 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:115.19,115.49 1 0 +processing/internal/delivery/http/mocks/TransactionUsecase.go:117.2,117.13 1 0 +processing/internal/usecase/accounts.go:25.96,32.2 2 0 +processing/internal/usecase/accounts.go:35.94,36.57 1 0 +processing/internal/usecase/accounts.go:36.57,39.3 2 0 +processing/internal/usecase/accounts.go:41.2,41.72 1 0 +processing/internal/usecase/accounts.go:41.72,44.3 2 0 +processing/internal/usecase/accounts.go:46.2,47.16 2 0 +processing/internal/usecase/accounts.go:47.16,50.3 2 0 +processing/internal/usecase/accounts.go:51.2,53.56 2 0 +processing/internal/usecase/accounts.go:53.56,56.3 2 0 +processing/internal/usecase/accounts.go:58.2,58.37 1 0 +processing/internal/usecase/accounts.go:58.37,61.3 2 0 +processing/internal/usecase/accounts.go:63.2,64.12 2 0 +processing/internal/usecase/accounts.go:68.99,69.66 1 0 +processing/internal/usecase/accounts.go:69.66,72.3 2 0 +processing/internal/usecase/accounts.go:74.2,75.16 2 0 +processing/internal/usecase/accounts.go:75.16,78.3 2 0 +processing/internal/usecase/accounts.go:79.2,82.16 3 0 +processing/internal/usecase/accounts.go:82.16,85.3 2 0 +processing/internal/usecase/accounts.go:87.2,87.37 1 0 +processing/internal/usecase/accounts.go:87.37,90.3 2 0 +processing/internal/usecase/accounts.go:92.2,93.17 2 0 +processing/internal/usecase/accounts.go:97.143,98.73 1 0 +processing/internal/usecase/accounts.go:98.73,101.3 2 0 +processing/internal/usecase/accounts.go:103.2,104.16 2 0 +processing/internal/usecase/accounts.go:104.16,107.3 2 0 +processing/internal/usecase/accounts.go:108.2,113.16 4 0 +processing/internal/usecase/accounts.go:113.16,116.3 2 0 +processing/internal/usecase/accounts.go:118.2,126.16 4 0 +processing/internal/usecase/accounts.go:126.16,129.3 2 0 +processing/internal/usecase/accounts.go:131.2,131.37 1 0 +processing/internal/usecase/accounts.go:131.37,134.3 2 0 +processing/internal/usecase/accounts.go:136.2,137.33 2 0 +processing/internal/usecase/auth.go:24.89,31.2 2 0 +processing/internal/usecase/auth.go:34.120,35.57 1 0 +processing/internal/usecase/auth.go:35.57,38.3 2 0 +processing/internal/usecase/auth.go:40.2,41.84 2 0 +processing/internal/usecase/auth.go:41.84,44.3 2 0 +processing/internal/usecase/auth.go:46.2,47.16 2 0 +processing/internal/usecase/auth.go:47.16,50.3 2 0 +processing/internal/usecase/auth.go:52.2,53.16 2 0 +processing/internal/usecase/auth.go:53.16,56.3 2 0 +processing/internal/usecase/auth.go:57.2,68.60 3 0 +processing/internal/usecase/auth.go:68.60,71.3 2 0 +processing/internal/usecase/auth.go:73.2,73.37 1 0 +processing/internal/usecase/auth.go:73.37,76.3 2 0 +processing/internal/usecase/auth.go:78.2,79.21 2 0 +processing/internal/usecase/auth.go:83.113,84.57 1 0 +processing/internal/usecase/auth.go:84.57,87.3 2 0 +processing/internal/usecase/auth.go:89.2,90.16 2 0 +processing/internal/usecase/auth.go:90.16,93.3 2 0 +processing/internal/usecase/auth.go:94.2,97.16 3 0 +processing/internal/usecase/auth.go:97.16,100.3 2 0 +processing/internal/usecase/auth.go:102.2,102.102 1 0 +processing/internal/usecase/auth.go:102.102,105.3 2 0 +processing/internal/usecase/auth.go:107.2,108.16 2 0 +processing/internal/usecase/auth.go:108.16,111.3 2 0 +processing/internal/usecase/auth.go:113.2,113.116 1 0 +processing/internal/usecase/auth.go:113.116,116.3 2 0 +processing/internal/usecase/auth.go:118.2,118.37 1 0 +processing/internal/usecase/auth.go:118.37,121.3 2 0 +processing/internal/usecase/auth.go:123.2,129.8 2 0 +processing/internal/usecase/auth.go:133.112,134.57 1 0 +processing/internal/usecase/auth.go:134.57,137.3 2 0 +processing/internal/usecase/auth.go:139.2,140.16 2 0 +processing/internal/usecase/auth.go:140.16,143.3 2 0 +processing/internal/usecase/auth.go:145.2,146.16 2 0 +processing/internal/usecase/auth.go:146.16,149.3 2 0 +processing/internal/usecase/auth.go:150.2,153.16 3 0 +processing/internal/usecase/auth.go:153.16,154.53 1 0 +processing/internal/usecase/auth.go:154.53,157.4 2 0 +processing/internal/usecase/auth.go:158.3,158.18 1 0 +processing/internal/usecase/auth.go:161.2,161.21 1 0 +processing/internal/usecase/auth.go:161.21,164.3 2 0 +processing/internal/usecase/auth.go:166.2,166.41 1 0 +processing/internal/usecase/auth.go:166.41,169.3 2 0 +processing/internal/usecase/auth.go:171.2,171.72 1 0 +processing/internal/usecase/auth.go:171.72,174.3 2 0 +processing/internal/usecase/auth.go:176.2,177.16 2 0 +processing/internal/usecase/auth.go:177.16,180.3 2 0 +processing/internal/usecase/auth.go:182.2,183.16 2 0 +processing/internal/usecase/auth.go:183.16,186.3 2 0 +processing/internal/usecase/auth.go:188.2,188.122 1 0 +processing/internal/usecase/auth.go:188.122,191.3 2 0 +processing/internal/usecase/auth.go:193.2,193.37 1 0 +processing/internal/usecase/auth.go:193.37,196.3 2 0 +processing/internal/usecase/auth.go:198.2,204.8 2 0 +processing/internal/usecase/auth.go:208.90,209.57 1 0 +processing/internal/usecase/auth.go:209.57,211.3 1 0 +processing/internal/usecase/auth.go:213.2,214.16 2 0 +processing/internal/usecase/auth.go:214.16,217.3 2 0 +processing/internal/usecase/auth.go:219.2,220.16 2 0 +processing/internal/usecase/auth.go:220.16,223.3 2 0 +processing/internal/usecase/auth.go:224.2,226.72 2 0 +processing/internal/usecase/auth.go:226.72,227.53 1 0 +processing/internal/usecase/auth.go:227.53,230.4 2 0 +processing/internal/usecase/auth.go:231.3,232.13 2 0 +processing/internal/usecase/auth.go:235.2,235.37 1 0 +processing/internal/usecase/auth.go:235.37,238.3 2 0 +processing/internal/usecase/auth.go:240.2,241.12 2 0 +processing/internal/usecase/auth.go:245.79,247.16 2 0 +processing/internal/usecase/auth.go:247.16,250.3 2 0 +processing/internal/usecase/auth.go:251.2,253.70 2 0 +processing/internal/usecase/auth.go:253.70,256.3 2 0 +processing/internal/usecase/auth.go:258.2,258.37 1 0 +processing/internal/usecase/auth.go:258.37,261.3 2 0 +processing/internal/usecase/auth.go:263.2,264.12 2 0 +processing/internal/usecase/transactions.go:20.108,27.2 2 0 +processing/internal/usecase/transactions.go:37.19,38.73 1 0 +processing/internal/usecase/transactions.go:38.73,41.3 2 0 +processing/internal/usecase/transactions.go:43.2,43.74 1 0 +processing/internal/usecase/transactions.go:43.74,46.3 2 0 +processing/internal/usecase/transactions.go:48.2,49.16 2 0 +processing/internal/usecase/transactions.go:49.16,52.3 2 0 +processing/internal/usecase/transactions.go:54.2,57.16 3 0 +processing/internal/usecase/transactions.go:57.16,60.3 2 0 +processing/internal/usecase/transactions.go:61.2,62.16 2 0 +processing/internal/usecase/transactions.go:62.16,65.3 2 0 +processing/internal/usecase/transactions.go:67.2,67.30 1 0 +processing/internal/usecase/transactions.go:67.30,70.3 2 0 +processing/internal/usecase/transactions.go:72.2,72.26 1 0 +processing/internal/usecase/transactions.go:72.26,75.3 2 0 +processing/internal/usecase/transactions.go:77.2,77.67 1 0 +processing/internal/usecase/transactions.go:77.67,80.3 2 0 +processing/internal/usecase/transactions.go:82.2,82.69 1 0 +processing/internal/usecase/transactions.go:82.69,85.3 2 0 +processing/internal/usecase/transactions.go:87.2,88.16 2 0 +processing/internal/usecase/transactions.go:88.16,91.3 2 0 +processing/internal/usecase/transactions.go:93.2,93.64 1 0 +processing/internal/usecase/transactions.go:93.64,96.3 2 0 +processing/internal/usecase/transactions.go:98.2,98.89 1 0 +processing/internal/usecase/transactions.go:98.89,101.3 2 0 +processing/internal/usecase/transactions.go:103.2,103.37 1 0 +processing/internal/usecase/transactions.go:103.37,106.3 2 0 +processing/internal/usecase/transactions.go:108.2,109.28 2 0 +processing/internal/usecase/transactions.go:118.31,119.70 1 0 +processing/internal/usecase/transactions.go:119.70,122.3 2 0 +processing/internal/usecase/transactions.go:124.2,125.16 2 0 +processing/internal/usecase/transactions.go:125.16,128.3 2 0 +processing/internal/usecase/transactions.go:129.2,132.16 3 0 +processing/internal/usecase/transactions.go:132.16,135.3 2 0 +processing/internal/usecase/transactions.go:137.2,137.74 1 0 +processing/internal/usecase/transactions.go:137.74,140.3 2 0 +processing/internal/usecase/transactions.go:142.2,142.37 1 0 +processing/internal/usecase/transactions.go:142.37,145.3 2 0 +processing/internal/usecase/transactions.go:147.2,147.25 1 0 +processing/internal/usecase/transactions.go:155.33,156.70 1 0 +processing/internal/usecase/transactions.go:156.70,159.3 2 0 +processing/internal/usecase/transactions.go:161.2,162.16 2 0 +processing/internal/usecase/transactions.go:162.16,165.3 2 0 +processing/internal/usecase/transactions.go:166.2,169.16 3 0 +processing/internal/usecase/transactions.go:169.16,172.3 2 0 +processing/internal/usecase/transactions.go:174.2,174.37 1 0 +processing/internal/usecase/transactions.go:174.37,177.3 2 0 +processing/internal/usecase/transactions.go:179.2,180.26 2 0 +processing/internal/domain/account.go:20.73,22.16 2 0 +processing/internal/domain/account.go:22.16,24.3 1 0 +processing/internal/domain/account.go:25.2,25.60 1 0 +processing/internal/domain/transactions.go:27.111,29.16 2 0 +processing/internal/domain/transactions.go:29.16,31.3 1 0 +processing/internal/domain/transactions.go:32.2,37.8 1 0 diff --git a/docker-compose.yml b/docker-compose.yml index be32ae3..2fabcea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,33 @@ -services: - postgres: - image: postgres:16 - container_name: postgres_bd - environment: - POSTGRES_DB: postgres_bd - POSTGRES_USER: admin - POSTGRES_PASSWORD: secret - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - redis: - image: redis:latest - ports: - - "6379:6379" - volumes: - - redis_data:/data - - kafka: - image: apache/kafka:latest - ports: - - "9092:9092" - volumes: - - kafka_data:/var/lib/kafka/data - -volumes: - postgres_data: - redis_data: - kafka_data: +services: + postgres: + image: postgres:16 + container_name: postgres_bd + environment: + POSTGRES_DB: ${PG_DB} + POSTGRES_USER: ${PG_USER} + POSTGRES_PASSWORD: ${PG_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:latest + container_name: redis_bd + command: redis-server --user ${REDIS_USER} on >${REDIS_PASSWORD} ~* +@all + ports: + - "6379:6379" + volumes: + - redis_data:/data + + kafka: + image: apache/kafka:latest + ports: + - "9092:9092" + volumes: + - kafka_data:/var/lib/kafka/data + +volumes: + postgres_data: + redis_data: + kafka_data: diff --git a/e2e/accounts_test.go b/e2e/accounts_test.go new file mode 100644 index 0000000..894ff64 --- /dev/null +++ b/e2e/accounts_test.go @@ -0,0 +1,67 @@ +package e2e + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAccount(t *testing.T) { + ts := SetupTestServer(t) + + user := createTestUser(t, ts, "getaccount@example.com", "password123", "AccountUser") + + t.Run("получение своего аккаунта", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, user.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение чужого аккаунта", func(t *testing.T) { + otherUser := createTestUser(t, ts, "other@example.com", "password123", "OtherUser") + + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, otherUser.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) // токен user, запрашиваем otherUser + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение несуществующего аккаунта", func(t *testing.T) { + fakeID := uuid.New().String() + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, fakeID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение аккаунта без токена", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s", ts.Server.URL, user.AccountID) + req, _ := http.NewRequest("GET", url, nil) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} diff --git a/e2e/auth_test.go b/e2e/auth_test.go new file mode 100644 index 0000000..2314e6f --- /dev/null +++ b/e2e/auth_test.go @@ -0,0 +1,312 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthFlow(t *testing.T) { + ts := SetupTestServer(t) + + t.Run("успешная регистрация и логин", func(t *testing.T) { + registerPayload := map[string]string{ + "email": "test@example.com", + "password": "password123", + "username": "TestUser", + } + body, _ := json.Marshal(registerPayload) + + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var registerResp struct { + Account struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"account"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + err = json.NewDecoder(resp.Body).Decode(®isterResp) + require.NoError(t, err) + + assert.NotEmpty(t, registerResp.Account.ID) + assert.Equal(t, "test@example.com", registerResp.Account.Email) + assert.Equal(t, "TestUser", registerResp.Account.Username) + assert.NotEmpty(t, registerResp.Tokens.AccessToken) + assert.NotEmpty(t, registerResp.Tokens.RefreshToken) + + var count int + err = ts.DB.QueryRow("SELECT COUNT(*) FROM accounts WHERE email = $1", "test@example.com").Scan(&count) + require.NoError(t, err) + assert.Equal(t, 1, count) + }) + + t.Run("дублирующая регистрация", func(t *testing.T) { + email := "duplicate@example.com" + + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "User1", + } + body, _ := json.Marshal(registerPayload) + resp1, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp1.Body.Close() + assert.Equal(t, http.StatusCreated, resp1.StatusCode) + + resp2, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp2.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp2.StatusCode) + }) + + t.Run("логин с корректными данными", func(t *testing.T) { + email := "login@example.com" + password := "mypassword" + + registerPayload := map[string]string{ + "email": email, + "password": password, + "username": "LoginUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + loginPayload := map[string]string{ + "email": email, + "password": password, + } + body, _ = json.Marshal(loginPayload) + resp, err = http.Post(ts.Server.URL+"/auth/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var loginResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.NewDecoder(resp.Body).Decode(&loginResp) + require.NoError(t, err) + + assert.NotEmpty(t, loginResp.AccessToken) + assert.NotEmpty(t, loginResp.RefreshToken) + }) + + t.Run("логин с неверным паролем", func(t *testing.T) { + email := "wrongpass@example.com" + + registerPayload := map[string]string{ + "email": email, + "password": "correctpassword", + "username": "WrongPassUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + loginPayload := map[string]string{ + "email": email, + "password": "wrongpassword", + } + body, _ = json.Marshal(loginPayload) + resp, err = http.Post(ts.Server.URL+"/auth/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("refresh token", func(t *testing.T) { + email := "refresh@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "RefreshUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + refreshToken := registerResp.Tokens.RefreshToken + + refreshPayload := map[string]string{ + "refresh_token": refreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var refreshResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + err = json.NewDecoder(resp.Body).Decode(&refreshResp) + require.NoError(t, err) + + assert.NotEmpty(t, refreshResp.AccessToken) + assert.NotEmpty(t, refreshResp.RefreshToken) + }) + + t.Run("logout", func(t *testing.T) { + email := "logout@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "LogoutUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + logoutPayload := map[string]string{ + "refresh_token": registerResp.Tokens.RefreshToken, + } + body, _ = json.Marshal(logoutPayload) + req, _ := http.NewRequest("POST", ts.Server.URL+"/auth/logout", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+registerResp.Tokens.AccessToken) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + refreshPayload := map[string]string{ + "refresh_token": registerResp.Tokens.RefreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestLogoutAll(t *testing.T) { + ts := SetupTestServer(t) + + email := "logoutall@example.com" + registerPayload := map[string]string{ + "email": email, + "password": "password123", + "username": "LogoutAllUser", + } + body, _ := json.Marshal(registerPayload) + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var registerResp struct { + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + json.NewDecoder(resp.Body).Decode(®isterResp) + resp.Body.Close() + + firstRefreshToken := registerResp.Tokens.RefreshToken + + refreshPayload := map[string]string{ + "refresh_token": firstRefreshToken, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + + var refreshResp struct { + RefreshToken string `json:"refresh_token"` + } + json.NewDecoder(resp.Body).Decode(&refreshResp) + resp.Body.Close() + + secondRefreshToken := refreshResp.RefreshToken + + req, _ := http.NewRequest("POST", ts.Server.URL+"/auth/logout-all", nil) + req.Header.Set("Authorization", "Bearer "+registerResp.Tokens.AccessToken) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + for _, token := range []string{firstRefreshToken, secondRefreshToken} { + refreshPayload := map[string]string{ + "refresh_token": token, + } + body, _ = json.Marshal(refreshPayload) + resp, err = http.Post(ts.Server.URL+"/auth/refresh", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode, "токен %s должен быть невалидным", token) + } +} + +func TestInvalidToken(t *testing.T) { + ts := SetupTestServer(t) + + accountID := uuid.New().String() + req, _ := http.NewRequest("GET", ts.Server.URL+"/accounts/"+accountID, nil) + req.Header.Set("Authorization", "Bearer invalid_token_here") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestMissingToken(t *testing.T) { + ts := SetupTestServer(t) + + accountID := uuid.New().String() + req, _ := http.NewRequest("GET", ts.Server.URL+"/accounts/"+accountID, nil) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} diff --git a/e2e/setup_test.go b/e2e/setup_test.go new file mode 100644 index 0000000..a2eb98f --- /dev/null +++ b/e2e/setup_test.go @@ -0,0 +1,214 @@ +package e2e + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + handlers "processing/internal/delivery/http" + "processing/internal/delivery/http/middleware" + "processing/internal/infrastructure/cache" + "processing/internal/infrastructure/logger" + "processing/internal/infrastructure/storage" + "processing/internal/usecase" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/modules/redis" + "github.com/testcontainers/testcontainers-go/wait" +) + +type TestServer struct { + Server *httptest.Server + DB *sql.DB + Logger *slog.Logger + Postgres *postgres.PostgresContainer + Redis *redis.RedisContainer +} + +func SetupTestServer(t *testing.T) *TestServer { + ctx := context.Background() + + t.Setenv("accessSecretKey", "test-access-secret-key-for-testing-only") + t.Setenv("refreshSecretKey", "test-refresh-secret-key-for-testing-only") + + postgresContainer, err := postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("testdb"), + postgres.WithUsername("testuser"), + postgres.WithPassword("testpass"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second)), + ) + if err != nil { + t.Fatalf("не удалось запустить postgres контейнер: %v", err) + } + + connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("не удалось получить connection string: %v", err) + } + + db, err := sql.Open("pgx", connStr) + if err != nil { + t.Fatalf("не удалось подключиться к БД: %v", err) + } + + if err := applyMigrations(db); err != nil { + t.Fatalf("не удалось применить миграции: %v", err) + } + + redisContainer, err := redis.Run(ctx, + "redis:7-alpine", + redis.WithSnapshotting(10, 1), + redis.WithLogLevel(redis.LogLevelVerbose), + ) + if err != nil { + t.Fatalf("не удалось запустить redis контейнер: %v", err) + } + + redisAddr, err := redisContainer.ConnectionString(ctx) + if err != nil { + t.Fatalf("не удалось получить redis connection string: %v", err) + } + + if len(redisAddr) > 8 && redisAddr[:8] == "redis://" { + redisAddr = redisAddr[8:] + } + + testCache := cache.NewRedis(cache.NewRedisOptions{ + Addr: redisAddr, + RateLimitMin: 10000, + RateLimitHour: 100000, + RateLimitDay: 1000000, + }) + + testLogger, err := logger.NewLogger("debug", "test") + if err != nil { + t.Fatalf("не удалось создать логгер: %v", err) + } + + tx := storage.NewUoWFactory(db) + transactionService := usecase.NewTransactionsService(tx, testCache, testLogger) + accountsService := usecase.NewAccountService(tx, testCache, testLogger) + authService := usecase.NewAuthService(tx, testCache, testLogger) + + handler := handlers.NewHandler(transactionService, accountsService, authService, testLogger) + + router := http.NewServeMux() + + router.HandleFunc("POST /auth/register", handler.Register) + router.HandleFunc("POST /auth/login", handler.Login) + router.HandleFunc("POST /auth/refresh", handler.Refresh) + + router.Handle("POST /auth/logout", middleware.AuthMiddleware(http.HandlerFunc(handler.Logout))) + router.Handle("POST /auth/logout-all", middleware.AuthMiddleware(http.HandlerFunc(handler.LogoutAll))) + router.Handle("GET /accounts/{id}", middleware.AuthMiddleware(http.HandlerFunc(handler.GetAccount))) + router.Handle("GET /accounts/{id}/transactions", middleware.AuthMiddleware(http.HandlerFunc(handler.AccountTransactions))) + router.Handle("POST /transactions", middleware.AuthMiddleware(http.HandlerFunc(handler.Transfer))) + router.Handle("GET /transactions/{id}", middleware.AuthMiddleware(http.HandlerFunc(handler.GetTransaction))) + + server := httptest.NewServer(router) + + t.Cleanup(func() { + server.Close() + db.Close() + if err := postgresContainer.Terminate(ctx); err != nil { + t.Logf("не удалось остановить postgres контейнер: %v", err) + } + if err := redisContainer.Terminate(ctx); err != nil { + t.Logf("не удалось остановить redis контейнер: %v", err) + } + }) + + return &TestServer{ + Server: server, + DB: db, + Logger: testLogger, + Postgres: postgresContainer, + Redis: redisContainer, + } +} + +func applyMigrations(db *sql.DB) error { + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("не удалось установить диалект postgres: %w", err) + } + + migrationDir := filepath.Join("..", "migrations") + + if err := goose.Up(db, migrationDir); err != nil { + return fmt.Errorf("не удалось применить миграции: %w", err) + } + + return nil +} + +type TestUser struct { + AccountID string + Email string + Username string + AccessToken string + RefreshToken string +} + +func createTestUser(t *testing.T, ts *TestServer, email, password, username string) *TestUser { + t.Helper() + + registerPayload := map[string]string{ + "email": email, + "password": password, + "username": username, + } + + body, err := json.Marshal(registerPayload) + if err != nil { + t.Fatalf("не удалось сериализовать payload: %v", err) + } + + resp, err := http.Post(ts.Server.URL+"/auth/register", "application/json", bytes.NewBuffer(body)) + if err != nil { + t.Fatalf("не удалось выполнить запрос регистрации: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + t.Fatalf("регистрация не удалась, статус: %d", resp.StatusCode) + } + + var registerResp struct { + Account struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"account"` + Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"tokens"` + } + + if err := json.NewDecoder(resp.Body).Decode(®isterResp); err != nil { + t.Fatalf("не удалось декодировать ответ: %v", err) + } + + return &TestUser{ + AccountID: registerResp.Account.ID, + Email: registerResp.Account.Email, + Username: registerResp.Account.Username, + AccessToken: registerResp.Tokens.AccessToken, + RefreshToken: registerResp.Tokens.RefreshToken, + } +} diff --git a/e2e/transactions_test.go b/e2e/transactions_test.go new file mode 100644 index 0000000..08b6b62 --- /dev/null +++ b/e2e/transactions_test.go @@ -0,0 +1,316 @@ +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransactionFlow(t *testing.T) { + ts := SetupTestServer(t) + + // Создаем двух пользователей + sender := createTestUser(t, ts, "sender@example.com", "password123", "Sender") + receiver := createTestUser(t, ts, "receiver@example.com", "password123", "Receiver") + + // Добавляем баланс отправителю напрямую в БД + _, err := ts.DB.Exec("UPDATE accounts SET balance = 1000 WHERE id = $1", sender.AccountID) + require.NoError(t, err) + + t.Run("успешный перевод", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "100.50", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + + var transactionResp struct { + TransactionID string `json:"transaction_id"` + } + err = json.NewDecoder(resp.Body).Decode(&transactionResp) + require.NoError(t, err) + assert.NotEmpty(t, transactionResp.TransactionID) + + // Проверяем балансы в БД + var senderBalance, receiverBalance float64 + err = ts.DB.QueryRow("SELECT balance FROM accounts WHERE id = $1", sender.AccountID).Scan(&senderBalance) + require.NoError(t, err) + assert.InDelta(t, 899.50, senderBalance, 0.01) + + err = ts.DB.QueryRow("SELECT balance FROM accounts WHERE id = $1", receiver.AccountID).Scan(&receiverBalance) + require.NoError(t, err) + assert.InDelta(t, 100.50, receiverBalance, 0.01) + + // Проверяем, что транзакция записалась в БД + var status string + err = ts.DB.QueryRow("SELECT status FROM transactions WHERE id = $1", transactionResp.TransactionID).Scan(&status) + require.NoError(t, err) + assert.Equal(t, "completed", status) + }) + + t.Run("перевод с недостаточным балансом", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "10000.00", // Больше, чем есть на счету + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод самому себе", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": sender.AccountID, // Отправитель = получатель + "amount": "50.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод с отрицательной суммой", func(t *testing.T) { + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "-10.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("перевод несуществующему получателю", func(t *testing.T) { + fakeReceiverID := uuid.New().String() + transferPayload := map[string]interface{}{ + "receiver_id": fakeReceiverID, + "amount": "10.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusCreated, resp.StatusCode) + }) +} + +func TestGetTransaction(t *testing.T) { + ts := SetupTestServer(t) + + // Создаем пользователей и делаем транзакцию + sender := createTestUser(t, ts, "getsender@example.com", "password123", "GetSender") + receiver := createTestUser(t, ts, "getreceiver@example.com", "password123", "GetReceiver") + + // Добавляем баланс + _, err := ts.DB.Exec("UPDATE accounts SET balance = 500 WHERE id = $1", sender.AccountID) + require.NoError(t, err) + + // Создаем транзакцию + transferPayload := map[string]interface{}{ + "receiver_id": receiver.AccountID, + "amount": "50.00", + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + var transactionResp struct { + TransactionID string `json:"transaction_id"` + } + json.NewDecoder(resp.Body).Decode(&transactionResp) + resp.Body.Close() + + transactionID := transactionResp.TransactionID + + t.Run("получение транзакции отправителем", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+sender.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var tx struct { + Amount string `json:"amount"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` + Status string `json:"status"` + } + err = json.NewDecoder(resp.Body).Decode(&tx) + require.NoError(t, err) + + assert.Equal(t, "50", tx.Amount) + assert.Equal(t, sender.AccountID, tx.SenderID) + assert.Equal(t, receiver.AccountID, tx.ReceiverID) + assert.Equal(t, "completed", tx.Status) + }) + + t.Run("получение транзакции получателем", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+receiver.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("получение чужой транзакции", func(t *testing.T) { + stranger := createTestUser(t, ts, "stranger@example.com", "password123", "Stranger") + + req, _ := http.NewRequest("GET", ts.Server.URL+"/transactions/"+transactionID, nil) + req.Header.Set("Authorization", "Bearer "+stranger.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestAccountTransactionHistory(t *testing.T) { + ts := SetupTestServer(t) + + user1 := createTestUser(t, ts, "history1@example.com", "password123", "HistoryUser1") + user2 := createTestUser(t, ts, "history2@example.com", "password123", "HistoryUser2") + user3 := createTestUser(t, ts, "history3@example.com", "password123", "HistoryUser3") + + // Добавляем баланс + _, err := ts.DB.Exec("UPDATE accounts SET balance = 1000 WHERE id = $1", user1.AccountID) + require.NoError(t, err) + + // Создаем несколько транзакций + for i := 0; i < 5; i++ { + receiver := user2.AccountID + if i%2 == 0 { + receiver = user3.AccountID + } + + transferPayload := map[string]interface{}{ + "receiver_id": receiver, + "amount": fmt.Sprintf("%d.00", 10*(i+1)), + } + body, _ := json.Marshal(transferPayload) + + req, _ := http.NewRequest("POST", ts.Server.URL+"/transactions", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + req.Header.Set("Idempotency-Key", fmt.Sprintf("test-transaction-%d", i)) // уникальный ключ для каждого запроса + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + } + + t.Run("получение истории транзакций", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=10&offset=0", ts.Server.URL, user1.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var historyResp struct { + Total int `json:"total"` + Transactions []struct { + Amount string `json:"amount"` + SenderID string `json:"sender_id"` + ReceiverID string `json:"receiver_id"` + Status string `json:"status"` + } `json:"transactions"` + } + err = json.NewDecoder(resp.Body).Decode(&historyResp) + require.NoError(t, err) + + assert.Equal(t, 5, historyResp.Total) + assert.Len(t, historyResp.Transactions, 5) + }) + + t.Run("пагинация истории транзакций", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=2&offset=0", ts.Server.URL, user1.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + var historyResp struct { + Total int `json:"total"` + Transactions []interface{} `json:"transactions"` + } + err = json.NewDecoder(resp.Body).Decode(&historyResp) + require.NoError(t, err) + + assert.Equal(t, 5, historyResp.Total) + assert.Len(t, historyResp.Transactions, 2) + }) + + t.Run("получение истории чужого аккаунта", func(t *testing.T) { + url := fmt.Sprintf("%s/accounts/%s/transactions?limit=10&offset=0", ts.Server.URL, user2.AccountID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+user1.AccessToken) // токен user1, но запрашиваем историю user2 + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.NotEqual(t, http.StatusOK, resp.StatusCode) + }) +} diff --git a/endpoints.png b/endpoints.png deleted file mode 100644 index eb7ca68..0000000 Binary files a/endpoints.png and /dev/null differ diff --git a/go.mod b/go.mod index fb84584..8d68355 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,26 @@ module processing -go 1.25.5 +go 1.25.7 + +require ( + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.27.1 + github.com/redis/go-redis/v9 v9.20.0 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.43.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.43.0 + golang.org/x/crypto v0.53.0 +) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/alicebob/miniredis/v2 v2.38.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -14,29 +28,27 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/go-redis/redis v6.15.9+incompatible // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx v3.6.2+incompatible // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/moby/api v1.54.1 // indirect - github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect @@ -44,32 +56,25 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pierrec/lz4/v4 v4.1.15 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/pressly/goose v2.7.0+incompatible // indirect - github.com/redis/go-redis v6.15.9+incompatible // indirect - github.com/redis/go-redis/v9 v9.20.0 // indirect - github.com/segmentio/kafka-go v0.4.51 // indirect - github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c952332..3c8474d 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -20,15 +26,19 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -40,37 +50,51 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= -github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= -github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= -github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -81,39 +105,44 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= -github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/pressly/goose v2.7.0+incompatible h1:PWejVEv07LCerQEzMMeAtjuyCKbyprZ/LBa6K5P0OCQ= -github.com/pressly/goose v2.7.0+incompatible/go.mod h1:m+QHWCqxR3k8D9l7qfzuC/djtlfzxr34mozWDYEu1z8= -github.com/redis/go-redis v6.15.9+incompatible h1:F+tnlesQSl3h9V8DdmtcYFdvkHLhbb7AgcLW6UJxnC4= -github.com/redis/go-redis v6.15.9+incompatible/go.mod h1:ic6dLmR0d9rkHSzaa0Ab3QVRZcjopJ9hSSPCrecj/+s= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= -github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= -github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= -github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks= +github.com/testcontainers/testcontainers-go/modules/redis v0.43.0 h1:qzATMhrltLr07KcGl/d674ouqI0AFtf6wnQb3VnqP7M= +github.com/testcontainers/testcontainers-go/modules/redis v0.43.0/go.mod h1:ygEcEUIZzmIlOKpjBfnPn/lUIRNorr1kPj3XfFPTQXM= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= @@ -122,31 +151,55 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/decimal/decimal.go b/internal/decimal/decimal.go index aaac483..43a2f7c 100644 --- a/internal/decimal/decimal.go +++ b/internal/decimal/decimal.go @@ -1,272 +1,303 @@ -package decimal - -import ( - "fmt" - "math" - "math/big" - "strconv" - "strings" -) - -var zeroInt = big.NewInt(0) -var tenInt = big.NewInt(10) - -type Decimal struct { - value *big.Int - exp int32 -} - -func NewFromString(value string) (Decimal, error) { - originalInput := value - var intString string - var exp int64 - - // Check if number is using scientific notation and find dots - eIndex := -1 - pIndex := -1 - for i, r := range value { - if r == 'E' || r == 'e' { - if eIndex > -1 { - return Decimal{}, fmt.Errorf("can't convert %s to decimal: multiple 'E' characters found", value) - } - eIndex = i - continue - } - - if r == '.' { - if pIndex > -1 { - return Decimal{}, fmt.Errorf("can't convert %s to decimal: too many .s", value) - } - pIndex = i - } - } - - if eIndex != -1 { - expInt, err := strconv.ParseInt(value[eIndex+1:], 10, 32) - if err != nil { - if e, ok := err.(*strconv.NumError); ok && e.Err == strconv.ErrRange { - return Decimal{}, fmt.Errorf("can't convert %s to decimal: fractional part too long", value) - } - return Decimal{}, fmt.Errorf("can't convert %s to decimal: exponent is not numeric", value) - } - value = value[:eIndex] - exp = expInt - } - - if pIndex == -1 { - // There is no decimal point, we can just parse the original string as - // an int - intString = value - } else { - if pIndex+1 < len(value) { - intString = value[:pIndex] + value[pIndex+1:] - } else { - intString = value[:pIndex] - } - expInt := -len(value[pIndex+1:]) - exp += int64(expInt) - } - - var dValue *big.Int - // strconv.ParseInt is faster than new(big.Int).SetString so this is just a shortcut for strings we know won't overflow - if len(intString) <= 18 { - parsed64, err := strconv.ParseInt(intString, 10, 64) - if err != nil { - return Decimal{}, fmt.Errorf("can't convert %s to decimal", value) - } - dValue = big.NewInt(parsed64) - } else { - dValue = new(big.Int) - _, ok := dValue.SetString(intString, 10) - if !ok { - return Decimal{}, fmt.Errorf("can't convert %s to decimal", value) - } - } - - if exp < math.MinInt32 || exp > math.MaxInt32 { - // NOTE(vadim): I doubt a string could realistically be this long - return Decimal{}, fmt.Errorf("can't convert %s to decimal: fractional part too long", originalInput) - } - - return Decimal{ - value: dValue, - exp: int32(exp), - }, nil -} - -func (d Decimal) String() string { - return d.string(true, false) -} - -// Add returns d + d2. -func (d Decimal) Add(d2 Decimal) Decimal { - rd, rd2 := RescalePair(d, d2) - - d3Value := new(big.Int).Add(rd.getValue(), rd2.getValue()) - return Decimal{ - value: d3Value, - exp: rd.exp, - } -} - -// Sub returns d - d2. -func (d Decimal) Sub(d2 Decimal) Decimal { - rd, rd2 := RescalePair(d, d2) - - d3Value := new(big.Int).Sub(rd.getValue(), rd2.getValue()) - return Decimal{ - value: d3Value, - exp: rd.exp, - } -} - -// RescalePair rescales two decimals to common exponential value (minimal exp of both decimals) -func RescalePair(d1 Decimal, d2 Decimal) (Decimal, Decimal) { - if d1.exp < d2.exp { - return d1, d2.rescale(d1.exp) - } else if d1.exp > d2.exp { - return d1.rescale(d2.exp), d2 - } - - return d1, d2 -} - -// Compare compares the numbers represented by d and d2 and returns: -// -// -1 if d < d2 -// 0 if d == d2 -// +1 if d > d2 -func (d Decimal) Compare(d2 Decimal) int { - return d.Cmp(d2) -} - -func (d Decimal) Cmp(d2 Decimal) int { - if d.exp == d2.exp { - return d.getValue().Cmp(d2.getValue()) - } - - rd, rd2 := RescalePair(d, d2) - - return rd.getValue().Cmp(rd2.getValue()) -} - -func (d Decimal) ScientificNotationString() string { - exp := int(d.exp) - intStr := new(big.Int).Abs(d.getValue()).String() - if intStr == "0" { - return intStr - } - first := intStr[0] - var remaining string - if len(intStr) > 1 { - remaining = "." + intStr[1:] - exp = exp + len(intStr) - 1 - } - number := string(first) + remaining + "E" + strconv.Itoa(exp) - if d.value.Sign() < 0 { - return "-" + number - } - return number -} - -func (d Decimal) string(trimTrailingZeros, useScientificNotation bool) string { - if d.exp == 0 { - return d.rescale(0).getValue().String() - } - if d.exp >= 0 { - if useScientificNotation { - return d.ScientificNotationString() - } else { - return d.rescale(0).value.String() - } - } - - abs := new(big.Int).Abs(d.getValue()) - str := abs.String() - - var intPart, fractionalPart string - - // NOTE(vadim): this cast to int will cause bugs if d.exp == INT_MIN - // and you are on a 32-bit machine. Won't fix this super-edge case. - dExpInt := int(d.exp) - if len(str) > -dExpInt { - intPart = str[:len(str)+dExpInt] - fractionalPart = str[len(str)+dExpInt:] - } else { - intPart = "0" - - num0s := -dExpInt - len(str) - fractionalPart = strings.Repeat("0", num0s) + str - } - - if trimTrailingZeros { - i := len(fractionalPart) - 1 - for ; i >= 0; i-- { - if fractionalPart[i] != '0' { - break - } - } - fractionalPart = fractionalPart[:i+1] - } - - number := intPart - if len(fractionalPart) > 0 { - number += "." + fractionalPart - } - - if d.getValue().Sign() < 0 { - return "-" + number - } - - return number -} - -func (d Decimal) getValue() *big.Int { - if d.value == nil { - return zeroInt - } - return d.value -} - -func (d Decimal) rescale(exp int32) Decimal { - if d.exp == exp { - return Decimal{ - new(big.Int).Set(d.getValue()), - d.exp, - } - } - - // NOTE(vadim): must convert exps to float64 before - to prevent overflow - diff := math.Abs(float64(exp) - float64(d.exp)) - value := new(big.Int).Set(d.getValue()) - - expScale := new(big.Int).Exp(tenInt, big.NewInt(int64(diff)), nil) - if exp > d.exp { - value = value.Quo(value, expScale) - } else if exp < d.exp { - value = value.Mul(value, expScale) - } - - return Decimal{ - value: value, - exp: exp, - } -} - -// Sign returns: -// -// -1 if d < 0 -// 0 if d == 0 -// +1 if d > 0 -func (d Decimal) Sign() int { - return d.getValue().Sign() -} - -// IsPositive return -// -// true if d > 0 -// false if d == 0 -// false if d < 0 -func (d Decimal) IsPositive() bool { - return d.Sign() == 1 -} +package decimal + +import ( + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + "strings" +) + +var zeroInt = big.NewInt(0) +var tenInt = big.NewInt(10) + +type Decimal struct { + value *big.Int + exp int32 +} + +func NewFromString(value string) (Decimal, error) { + originalInput := value + var intString string + var exp int64 + + // Check if number is using scientific notation and find dots + eIndex := -1 + pIndex := -1 + for i, r := range value { + if r == 'E' || r == 'e' { + if eIndex > -1 { + return Decimal{}, fmt.Errorf("can't convert %s to decimal: multiple 'E' characters found", value) + } + eIndex = i + continue + } + + if r == '.' { + if pIndex > -1 { + return Decimal{}, fmt.Errorf("can't convert %s to decimal: too many .s", value) + } + pIndex = i + } + } + + if eIndex != -1 { + expInt, err := strconv.ParseInt(value[eIndex+1:], 10, 32) + if err != nil { + if e, ok := err.(*strconv.NumError); ok && e.Err == strconv.ErrRange { + return Decimal{}, fmt.Errorf("can't convert %s to decimal: fractional part too long", value) + } + return Decimal{}, fmt.Errorf("can't convert %s to decimal: exponent is not numeric", value) + } + value = value[:eIndex] + exp = expInt + } + + if pIndex == -1 { + // There is no decimal point, we can just parse the original string as + // an int + intString = value + } else { + if pIndex+1 < len(value) { + intString = value[:pIndex] + value[pIndex+1:] + } else { + intString = value[:pIndex] + } + expInt := -len(value[pIndex+1:]) + exp += int64(expInt) + } + + var dValue *big.Int + // strconv.ParseInt is faster than new(big.Int).SetString so this is just a shortcut for strings we know won't overflow + if len(intString) <= 18 { + parsed64, err := strconv.ParseInt(intString, 10, 64) + if err != nil { + return Decimal{}, fmt.Errorf("can't convert %s to decimal", value) + } + dValue = big.NewInt(parsed64) + } else { + dValue = new(big.Int) + _, ok := dValue.SetString(intString, 10) + if !ok { + return Decimal{}, fmt.Errorf("can't convert %s to decimal", value) + } + } + + if exp < math.MinInt32 || exp > math.MaxInt32 { + // NOTE(vadim): I doubt a string could realistically be this long + return Decimal{}, fmt.Errorf("can't convert %s to decimal: fractional part too long", originalInput) + } + + return Decimal{ + value: dValue, + exp: int32(exp), + }, nil +} + +func (d Decimal) String() string { + return d.string(true, false) +} + +// Add returns d + d2. +func (d Decimal) Add(d2 Decimal) Decimal { + rd, rd2 := RescalePair(d, d2) + + d3Value := new(big.Int).Add(rd.getValue(), rd2.getValue()) + return Decimal{ + value: d3Value, + exp: rd.exp, + } +} + +// Sub returns d - d2. +func (d Decimal) Sub(d2 Decimal) Decimal { + rd, rd2 := RescalePair(d, d2) + + d3Value := new(big.Int).Sub(rd.getValue(), rd2.getValue()) + return Decimal{ + value: d3Value, + exp: rd.exp, + } +} + +// RescalePair rescales two decimals to common exponential value (minimal exp of both decimals) +func RescalePair(d1 Decimal, d2 Decimal) (Decimal, Decimal) { + if d1.exp < d2.exp { + return d1, d2.rescale(d1.exp) + } else if d1.exp > d2.exp { + return d1.rescale(d2.exp), d2 + } + + return d1, d2 +} + +// Compare compares the numbers represented by d and d2 and returns: +// +// -1 if d < d2 +// 0 if d == d2 +// +1 if d > d2 +func (d Decimal) Compare(d2 Decimal) int { + return d.Cmp(d2) +} + +func (d Decimal) Cmp(d2 Decimal) int { + if d.exp == d2.exp { + return d.getValue().Cmp(d2.getValue()) + } + + rd, rd2 := RescalePair(d, d2) + + return rd.getValue().Cmp(rd2.getValue()) +} + +func (d Decimal) ScientificNotationString() string { + exp := int(d.exp) + intStr := new(big.Int).Abs(d.getValue()).String() + if intStr == "0" { + return intStr + } + first := intStr[0] + var remaining string + if len(intStr) > 1 { + remaining = "." + intStr[1:] + exp = exp + len(intStr) - 1 + } + number := string(first) + remaining + "E" + strconv.Itoa(exp) + if d.value.Sign() < 0 { + return "-" + number + } + return number +} + +func (d Decimal) string(trimTrailingZeros, useScientificNotation bool) string { + if d.exp == 0 { + return d.rescale(0).getValue().String() + } + if d.exp >= 0 { + if useScientificNotation { + return d.ScientificNotationString() + } else { + return d.rescale(0).value.String() + } + } + + abs := new(big.Int).Abs(d.getValue()) + str := abs.String() + + var intPart, fractionalPart string + + // NOTE(vadim): this cast to int will cause bugs if d.exp == INT_MIN + // and you are on a 32-bit machine. Won't fix this super-edge case. + dExpInt := int(d.exp) + if len(str) > -dExpInt { + intPart = str[:len(str)+dExpInt] + fractionalPart = str[len(str)+dExpInt:] + } else { + intPart = "0" + + num0s := -dExpInt - len(str) + fractionalPart = strings.Repeat("0", num0s) + str + } + + if trimTrailingZeros { + i := len(fractionalPart) - 1 + for ; i >= 0; i-- { + if fractionalPart[i] != '0' { + break + } + } + fractionalPart = fractionalPart[:i+1] + } + + number := intPart + if len(fractionalPart) > 0 { + number += "." + fractionalPart + } + + if d.getValue().Sign() < 0 { + return "-" + number + } + + return number +} + +func (d Decimal) getValue() *big.Int { + if d.value == nil { + return zeroInt + } + return d.value +} + +func (d Decimal) rescale(exp int32) Decimal { + if d.exp == exp { + return Decimal{ + new(big.Int).Set(d.getValue()), + d.exp, + } + } + + // NOTE(vadim): must convert exps to float64 before - to prevent overflow + diff := math.Abs(float64(exp) - float64(d.exp)) + value := new(big.Int).Set(d.getValue()) + + expScale := new(big.Int).Exp(tenInt, big.NewInt(int64(diff)), nil) + if exp > d.exp { + value = value.Quo(value, expScale) + } else if exp < d.exp { + value = value.Mul(value, expScale) + } + + return Decimal{ + value: value, + exp: exp, + } +} + +// Sign returns: +// +// -1 if d < 0 +// 0 if d == 0 +// +1 if d > 0 +func (d Decimal) Sign() int { + return d.getValue().Sign() +} + +// IsPositive return +// +// true if d > 0 +// false if d == 0 +// false if d < 0 +func (d Decimal) IsPositive() bool { + return d.Sign() == 1 +} + +func Zero() Decimal { + return Decimal{ + value: big.NewInt(0), + exp: 0, + } +} + +// MarshalJSON implements json.Marshaler +func (d Decimal) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// UnmarshalJSON implements json.Unmarshaler +func (d *Decimal) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + // If it's not a string, try to unmarshal as number and convert to string + var n float64 + if err2 := json.Unmarshal(data, &n); err2 == nil { + s = strconv.FormatFloat(n, 'f', -1, 64) + } else { + return err + } + } + + var err error + *d, err = NewFromString(s) + return err +} diff --git a/internal/decimal/sql.go b/internal/decimal/sql.go index 858ca2a..2ce1da2 100644 --- a/internal/decimal/sql.go +++ b/internal/decimal/sql.go @@ -1,39 +1,39 @@ -package decimal - -import ( - "database/sql/driver" - "fmt" -) - -// Scan implements the sql.Scanner interface for database deserialization. -func (d *Decimal) Scan(value interface{}) error { - // first try to see if the data is stored in database as a Numeric datatype - switch v := value.(type) { - case string: - var err error - *d, err = NewFromString(unquoteIfQuoted(v)) - return err - - case []byte: - var err error - *d, err = NewFromString(unquoteIfQuoted(string(v))) - return err - - default: - return fmt.Errorf("could not convert value '%+v' to any known type", value) - } -} - -// Value implements the driver.Valuer interface for database serialization. -func (d Decimal) Value() (driver.Value, error) { - return d.String(), nil -} - -func unquoteIfQuoted(value string) string { - // If the amount is quoted, strip the quotes - if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' { - return value[1 : len(value)-1] - } - - return value -} +package decimal + +import ( + "database/sql/driver" + "fmt" +) + +// Scan implements the sql.Scanner interface for database deserialization. +func (d *Decimal) Scan(value interface{}) error { + // first try to see if the data is stored in database as a Numeric datatype + switch v := value.(type) { + case string: + var err error + *d, err = NewFromString(unquoteIfQuoted(v)) + return err + + case []byte: + var err error + *d, err = NewFromString(unquoteIfQuoted(string(v))) + return err + + default: + return fmt.Errorf("could not convert value '%+v' to any known type", value) + } +} + +// Value implements the driver.Valuer interface for database serialization. +func (d Decimal) Value() (driver.Value, error) { + return d.String(), nil +} + +func unquoteIfQuoted(value string) string { + // If the amount is quoted, strip the quotes + if len(value) > 2 && value[0] == '"' && value[len(value)-1] == '"' { + return value[1 : len(value)-1] + } + + return value +} diff --git a/internal/delivery/http/account_handler.go b/internal/delivery/http/account_handler.go new file mode 100644 index 0000000..bd7e7fe --- /dev/null +++ b/internal/delivery/http/account_handler.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "errors" + "net/http" + "processing/internal/domain" + "strconv" + + "github.com/google/uuid" +) + +type AccountDTO struct { + Name string `json:"name"` + Password string `json:"password"` + Email string `json:"email"` +} + +// выводит информацию об аккаунте по айди +// GET /accounts/:id +func (h *handler) GetAccount(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + accountIDStr := r.PathValue("id") + accountID, err := uuid.Parse(accountIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + ctxUserID, ok := ctx.Value("user_id").(string) + if !ok { + writeError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + if ctxUserID != accountID.String() { + writeError(w, http.StatusForbidden, domain.ErrAccessDenied, 0) + return + } + + account, err := h.as.GetAccount(ctx, accountID) + if err != nil { + writeError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := writeJSON(w, http.StatusOK, account); err != nil { + h.log.Error("[GetAccount] json encode", "err", err) + } +} + +type AccountTransactions struct { + Transactions []domain.Transaction `json:"transactions"` + Total int `json:"total"` +} + +// Get /accounts/:id/transactions?limit=..&offset=... +func (h *handler) AccountTransactions(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + accountIDStr := r.PathValue("id") + accountID, err := uuid.Parse(accountIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + ctxUserID, ok := ctx.Value("user_id").(string) + if !ok { + writeError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + if ctxUserID != accountID.String() { + writeError(w, http.StatusForbidden, domain.ErrAccessDenied, 0) + return + } + + limit := r.URL.Query().Get("limit") + offset := r.URL.Query().Get("offset") + l, err := strconv.Atoi(limit) + if err != nil { + l = 10 + } + o, err := strconv.Atoi(offset) + if err != nil { + o = 0 + } + + total, transactions, err := h.as.TransactionHistory(ctx, accountID, l, o) + if err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + return + } + + dto := AccountTransactions{ + Transactions: transactions, + Total: total, + } + + if err := writeJSON(w, http.StatusOK, dto); err != nil { + h.log.Error("[AccountTransactions] json encode", "err", err) + } +} diff --git a/internal/delivery/http/account_test.go b/internal/delivery/http/account_test.go new file mode 100644 index 0000000..005cc1d --- /dev/null +++ b/internal/delivery/http/account_test.go @@ -0,0 +1,337 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/domain" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetAccountHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + + tests := []struct { + name string + accountID string + setupMock func(*mocks.AccountsUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешное получение аккаунта", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + balance, _ := decimal.NewFromString("1000.50") + expectedAccount := &domain.Account{ + ID: testUserID, + Name: "Test User", + Email: "test@example.com", + Balance: balance, + PasswordHash: "hashed_password", + Role: "user", + } + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(expectedAccount, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный UUID аккаунта", + accountID: "invalid-uuid", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "доступ запрещен к чужому аккаунту", + accountID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001").String(), + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusForbidden, + expectError: true, + }, + { + name: "аккаунт не найден", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(nil, errors.New("аккаунт не найден")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "ошибка БД при получении аккаунта", + accountID: testUserID.String(), + setupMock: func(m *mocks.AccountsUsecase) { + m.On("GetAccount", + mock.Anything, + testUserID, + ).Return(nil, errors.New("database connection error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockTransactionUsecase := mocks.NewTransactionUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockTransactionUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + tt.setupMock(mockAccountUsecase) + + req := httptest.NewRequest(http.MethodGet, "/accounts/"+tt.accountID, nil) + req.SetPathValue("id", tt.accountID) + ctx := context.WithValue(context.Background(), "user_id", testUserID.String()) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.GetAccount(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response domain.Account + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + assert.Equal(t, testUserID, response.ID) + assert.NotEmpty(t, response.Name) + assert.NotEmpty(t, response.Email) + } + }) + t.Log("\n\n\n") + } +} + +func TestAccountTransactionsHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + validTransactionID1 := uuid.MustParse("223e4567-e89b-12d3-a456-426614174001") + validTransactionID2 := uuid.MustParse("323e4567-e89b-12d3-a456-426614174002") + + tests := []struct { + name string + accountID string + limit string + offset string + setupMock func(*mocks.AccountsUsecase) + expectedStatusCode int + expectError bool + expectedTotal int + expectedCount int + }{ + { + name: "успешное получение истории транзакций", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + amount1, _ := decimal.NewFromString("500.00") + amount2, _ := decimal.NewFromString("250.50") + expectedTransactions := []domain.Transaction{ + { + ID: validTransactionID1, + Amount: amount1, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("423e4567-e89b-12d3-a456-426614174003"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + { + ID: validTransactionID2, + Amount: amount2, + Sender_id: uuid.MustParse("523e4567-e89b-12d3-a456-426614174004"), + Receiver_id: testUserID, + Status: domain.StatusCompleted, + Created_at: time.Now().Add(-24 * time.Hour), + }, + } + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 2, + }, + { + name: "получение с пагинацией offset", + accountID: testUserID.String(), + limit: "5", + offset: "10", + setupMock: func(m *mocks.AccountsUsecase) { + amount, _ := decimal.NewFromString("100.00") + expectedTransactions := []domain.Transaction{ + { + ID: validTransactionID1, + Amount: amount, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("623e4567-e89b-12d3-a456-426614174005"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("TransactionHistory", + mock.Anything, + testUserID, + 5, + 10, + ).Return(25, expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 1, + }, + { + name: "пустая история транзакций", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(0, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 0, + expectedCount: 0, + }, + { + name: "доступ запрещен к чужой истории", + accountID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001").String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusForbidden, + expectError: true, + }, + { + name: "невалидный UUID аккаунта", + accountID: "invalid-uuid", + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный limit параметр", + accountID: testUserID.String(), + limit: "invalid", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 0, + }, + { + name: "невалидный offset параметр", + accountID: testUserID.String(), + limit: "10", + offset: "invalid", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(25, []domain.Transaction{}, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + expectedTotal: 25, + expectedCount: 0, + }, + { + name: "ошибка от usecase", + accountID: testUserID.String(), + limit: "10", + offset: "0", + setupMock: func(m *mocks.AccountsUsecase) { + m.On("TransactionHistory", + mock.Anything, + testUserID, + 10, + 0, + ).Return(0, nil, errors.New("database error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockTransactionUsecase := mocks.NewTransactionUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockTransactionUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + tt.setupMock(mockAccountUsecase) + + url := "/accounts/" + tt.accountID + "/transactions?limit=" + tt.limit + "&offset=" + tt.offset + req := httptest.NewRequest(http.MethodGet, url, nil) + req.SetPathValue("id", tt.accountID) + ctx := context.WithValue(context.Background(), "user_id", testUserID.String()) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.AccountTransactions(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + + if !tt.expectError { + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + var response AccountTransactions + err := json.Unmarshal(rr.Body.Bytes(), &response) + require.NoError(t, err, "ответ должен быть валидным JSON") + assert.Equal(t, tt.expectedTotal, response.Total, "неожиданное количество страниц") + assert.Equal(t, tt.expectedCount, len(response.Transactions), "неожиданное количество транзакций") + } else { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } + }) + t.Log("\n\n\n") + } +} diff --git a/internal/delivery/http/auth_handler.go b/internal/delivery/http/auth_handler.go new file mode 100644 index 0000000..b87dec4 --- /dev/null +++ b/internal/delivery/http/auth_handler.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "processing/internal/domain" + + "github.com/google/uuid" +) + +type AuthDTO struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"username"` +} + +func (h *handler) Register(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + ip := r.RemoteAddr + + var dto AuthDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + if !validateRegister(w, &dto) { + return + } + + account, err := h.auth.Register(ctx, dto.Email, dto.Password, dto.Name, ip) + if err != nil { + if errors.Is(err, domain.ErrAccountAlreadyExist) { + writeError(w, http.StatusConflict, err, 0) + return + } + writeError(w, http.StatusInternalServerError, err, 0) + return + } + + token, err := h.auth.Login(ctx, dto.Email, dto.Password, ip) + if err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + return + } + + setAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + setAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := writeJSON(w, http.StatusCreated, map[string]interface{}{ + "account": account, + "tokens": token, + }); err != nil { + writeError(w, http.StatusInternalServerError, err, 1) // + } +} + +func (h *handler) Login(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + ip := r.RemoteAddr + + var dto AuthDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + if !validateLogin(w, &dto) { + return + } + + token, err := h.auth.Login(ctx, dto.Email, dto.Password, ip) + if err != nil { + if errors.Is(err, domain.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, err, 0) + return + } + writeError(w, http.StatusInternalServerError, err, 1) + return + } + + setAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + setAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := writeJSON(w, http.StatusOK, token); err != nil { + writeError(w, http.StatusInternalServerError, err, 1) // + } +} + +func (h *handler) Refresh(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + cookie, err := r.Cookie("refresh_token") + var refreshToken string + if err == nil { + refreshToken = cookie.Value + } else { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + refreshToken = req.RefreshToken + } + } + ip := r.RemoteAddr + if refreshToken == "" { + writeError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 1) + return + } + + token, err := h.auth.Refresh(ctx, refreshToken, ip) + if err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + return + } + + setAuthCookie(w, "/api", "access_token", token.AccessToken, 900) + setAuthCookie(w, "/auth/refresh", "refresh_token", token.RefreshToken, 604800) + if err := writeJSON(w, http.StatusOK, token); err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + } +} + +func (h *handler) Logout(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + var refreshToken string + cookie, err := r.Cookie("refresh_token") + if err == nil { + refreshToken = cookie.Value + } else { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 0) + return + } + refreshToken = req.RefreshToken + } + ip := r.RemoteAddr + if refreshToken == "" { + writeError(w, http.StatusBadRequest, errors.New("refresh token отсутствует"), 0) + return + } + + if err := h.auth.Logout(ctx, refreshToken, ip); err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + } + setAuthCookie(w, "/api", "access_token", "", -1) + setAuthCookie(w, "/auth/refresh", "refresh_token", "", -1) + if err := writeJSON(w, http.StatusOK, map[string]string{"message": "success"}); err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + } +} + +func (h *handler) LogoutAll(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + ctx := r.Context() + + ctxUserID, ok := r.Context().Value("user_id").(string) + if !ok { + writeError(w, http.StatusBadRequest, errors.New("поле user_id должно быть string"), 1) + return + } + + userID, err := uuid.Parse(ctxUserID) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + if err := h.auth.LogoutAll(ctx, userID); err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + } + setAuthCookie(w, "/api", "access_token", "", -1) + setAuthCookie(w, "/auth/refresh", "refresh_token", "", -1) + if err := writeJSON(w, http.StatusOK, map[string]string{"message": "success"}); err != nil { + writeError(w, http.StatusInternalServerError, err, 0) + } +} diff --git a/internal/delivery/http/auth_test.go b/internal/delivery/http/auth_test.go new file mode 100644 index 0000000..377011d --- /dev/null +++ b/internal/delivery/http/auth_test.go @@ -0,0 +1,1084 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/domain" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRegisterHandler(t *testing.T) { + testUserID := uuid.New() + testBalance, _ := decimal.NewFromString("0") + testAccount := &domain.Account{ + ID: testUserID, + Name: "TestUser", + Email: "test@example.com", + Balance: testBalance, + Role: "user", + } + testTokenPair := &domain.TokenPair{ + AccessToken: "test_access_token", + RefreshToken: "test_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешная регистрация", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusCreated, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "account") + assert.Contains(t, response, "tokens") + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "test_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "test_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "невалидный JSON", + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустое имя", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с именем не может быть пустым") + }, + }, + { + name: "имя короче 3 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "ab", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "имя не может быть меньше 3 букв") + }, + }, + { + name: "пустой email", + requestBody: AuthDTO{ + Email: "", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с почтой не может быть пустым") + }, + }, + { + name: "невалидный формат email", + requestBody: AuthDTO{ + Email: "invalid-email", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "введите корректный адрес электронной почты") + }, + }, + { + name: "пустой пароль", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с паролем не может быть пустым") + }, + }, + { + name: "пароль короче 8 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "pass123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "длина пароля не может быть меньше 8 символов") + }, + }, + { + name: "ошибка при регистрации", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(nil, errors.New("database error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при автоматическом логине после регистрации", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + Name: "TestUser", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(nil, errors.New("login failed")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "email с пробелами обрезается", + requestBody: AuthDTO{ + Email: " test@example.com ", + Password: "password123", + Name: " TestUser ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Register", mock.Anything, "test@example.com", "password123", "TestUser", mock.Anything). + Return(testAccount, nil) + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusCreated, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "account") + assert.Contains(t, response, "tokens") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + + handler.Register(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLoginHandler(t *testing.T) { + testTokenPair := &domain.TokenPair{ + AccessToken: "test_access_token", + RefreshToken: "test_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный логин", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "test_access_token", response.AccessToken) + assert.Equal(t, "test_refresh_token", response.RefreshToken) + assert.Equal(t, int64(900), response.ExpiresIn) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "test_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "test_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "невалидный JSON", + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустой email", + requestBody: AuthDTO{ + Email: "", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с почтой не может быть пустым") + }, + }, + { + name: "невалидный формат email", + requestBody: AuthDTO{ + Email: "invalid-email", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "введите корректный адрес электронной почты") + }, + }, + { + name: "пустой пароль", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с паролем не может быть пустым") + }, + }, + { + name: "пароль короче 8 символов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "pass123", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "длина пароля не может быть меньше 8 символов") + }, + }, + { + name: "ошибка при логине - неверные креды", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", "password123", mock.Anything). + Return(nil, errors.New("invalid credentials")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + }, + }, + { + name: "email с пробелами обрезается", + requestBody: AuthDTO{ + Email: " test@example.com ", + Password: " password123 ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", " password123 ", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "test_access_token", response.AccessToken) + assert.Equal(t, "test_refresh_token", response.RefreshToken) + }, + }, + { + name: "email только из пробелов", + requestBody: AuthDTO{ + Email: " ", + Password: "password123", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusUnprocessableEntity, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + assert.Contains(t, rec.Body.String(), "поле с почтой не может быть пустым") + }, + }, + { + name: "пароль только из пробелов", + requestBody: AuthDTO{ + Email: "test@example.com", + Password: " ", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Login", mock.Anything, "test@example.com", " ", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + + handler.Login(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestRefreshHandler(t *testing.T) { + testTokenPair := &domain.TokenPair{ + AccessToken: "new_access_token", + RefreshToken: "new_refresh_token", + ExpiresIn: 900, + } + + tests := []struct { + name string + setupRequest func(*http.Request) + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный refresh через cookie", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "test_refresh_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + assert.Equal(t, "new_refresh_token", response.RefreshToken) + assert.Equal(t, int64(900), response.ExpiresIn) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "new_access_token", accessCookie.Value) + assert.Equal(t, "/api", accessCookie.Path) + assert.Equal(t, 900, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "new_refresh_token", refreshCookie.Value) + assert.Equal(t, "/auth/refresh", refreshCookie.Path) + assert.Equal(t, 604800, refreshCookie.MaxAge) + }, + }, + { + name: "успешный refresh через JSON body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "test_refresh_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "test_refresh_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + assert.Equal(t, "new_refresh_token", response.RefreshToken) + }, + }, + { + name: "отсутствует refresh token - нет cookie и body", + setupRequest: func(req *http.Request) { + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "пустой refresh token в body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "невалидный JSON", + setupRequest: func(req *http.Request) { + }, + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "refresh token отсутствует") + }, + }, + { + name: "ошибка при refresh", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "invalid_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "invalid_refresh_token", mock.Anything). + Return(nil, errors.New("invalid refresh token")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "приоритет cookie над body", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "cookie_token", + }) + }, + requestBody: map[string]string{ + "refresh_token": "body_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Refresh", mock.Anything, "cookie_token", mock.Anything). + Return(testTokenPair, nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response domain.TokenPair + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "new_access_token", response.AccessToken) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if tt.requestBody != nil { + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + + if tt.setupRequest != nil { + tt.setupRequest(req) + } + + rec := httptest.NewRecorder() + + handler.Refresh(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLogoutHandler(t *testing.T) { + tests := []struct { + name string + setupRequest func(*http.Request) + requestBody interface{} + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный логаут через cookie", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "", accessCookie.Value) + assert.Equal(t, -1, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "", refreshCookie.Value) + assert.Equal(t, -1, refreshCookie.MaxAge) + }, + }, + { + name: "успешный логаут через JSON body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "test_refresh_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + }, + }, + { + name: "отсутствует refresh token - нет cookie и body", + setupRequest: func(req *http.Request) { + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустой refresh token в body", + setupRequest: func(req *http.Request) { + }, + requestBody: map[string]string{ + "refresh_token": "", + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "невалидный JSON", + setupRequest: func(req *http.Request) { + }, + requestBody: "invalid json", + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при логауте", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "test_refresh_token", + }) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "test_refresh_token", mock.Anything). + Return(errors.New("database error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "приоритет cookie над body", + setupRequest: func(req *http.Request) { + req.AddCookie(&http.Cookie{ + Name: "refresh_token", + Value: "cookie_token", + }) + }, + requestBody: map[string]string{ + "refresh_token": "body_token", + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("Logout", mock.Anything, "cookie_token", mock.Anything). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + var body []byte + var err error + if tt.requestBody != nil { + if str, ok := tt.requestBody.(string); ok { + body = []byte(str) + } else { + body, err = json.Marshal(tt.requestBody) + assert.NoError(t, err) + } + } + + req := httptest.NewRequest(http.MethodPost, "/auth/logout", bytes.NewBuffer(body)) + req.RemoteAddr = "127.0.0.1:1234" + req = req.WithContext(context.Background()) + + if tt.setupRequest != nil { + tt.setupRequest(req) + } + + rec := httptest.NewRecorder() + + handler.Logout(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} + +func TestLogoutAllHandler(t *testing.T) { + testUserID := uuid.New() + + tests := []struct { + name string + setupContext func(context.Context) context.Context + setupMock func(*mocks.AuthUseCase) + expectedStatusCode int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "успешный logout всех сессий", + setupContext: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "user_id", testUserID.String()) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, testUserID). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "access_token" { + accessCookie = cookie + } + if cookie.Name == "refresh_token" { + refreshCookie = cookie + } + } + + assert.NotNil(t, accessCookie) + assert.Equal(t, "", accessCookie.Value) + assert.Equal(t, -1, accessCookie.MaxAge) + + assert.NotNil(t, refreshCookie) + assert.Equal(t, "", refreshCookie.Value) + assert.Equal(t, -1, refreshCookie.MaxAge) + }, + }, + { + name: "отсутствует user_id в контексте", + setupContext: func(ctx context.Context) context.Context { + return ctx + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "поле user_id должно быть string") + }, + }, + { + name: "user_id не является строкой", + setupContext: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "user_id", 12345) + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + assert.Contains(t, response, "message") + assert.Contains(t, response["message"], "поле user_id должно быть string") + }, + }, + { + name: "невалидный формат UUID", + setupContext: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "user_id", "invalid-uuid-format") + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "пустая строка вместо UUID", + setupContext: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "user_id", "") + }, + setupMock: func(authMock *mocks.AuthUseCase) {}, + expectedStatusCode: http.StatusBadRequest, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "ошибка при LogoutAll в usecase", + setupContext: func(ctx context.Context) context.Context { + return context.WithValue(ctx, "user_id", testUserID.String()) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, testUserID). + Return(errors.New("database connection error")) + }, + expectedStatusCode: http.StatusInternalServerError, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]interface{} + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Contains(t, response, "error") + }, + }, + { + name: "валидный UUID с другим пользователем", + setupContext: func(ctx context.Context) context.Context { + anotherUserID := uuid.New() + return context.WithValue(ctx, "user_id", anotherUserID.String()) + }, + setupMock: func(authMock *mocks.AuthUseCase) { + authMock.On("LogoutAll", mock.Anything, mock.AnythingOfType("uuid.UUID")). + Return(nil) + }, + expectedStatusCode: http.StatusOK, + checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) { + var response map[string]string + err := json.NewDecoder(rec.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response["message"]) + + cookies := rec.Result().Cookies() + assert.Len(t, cookies, 2) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authMock := mocks.NewAuthUseCase(t) + tt.setupMock(authMock) + + handler := &handler{ + auth: authMock, + } + + req := httptest.NewRequest(http.MethodPost, "/auth/logout-all", nil) + req.RemoteAddr = "127.0.0.1:1234" + + ctx := context.Background() + if tt.setupContext != nil { + ctx = tt.setupContext(ctx) + } + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + + handler.LogoutAll(rec, req) + + assert.Equal(t, tt.expectedStatusCode, rec.Code) + if tt.checkResponse != nil { + tt.checkResponse(t, rec) + } + }) + } +} diff --git a/internal/delivery/http/error.go b/internal/delivery/http/error.go deleted file mode 100644 index 3b000af..0000000 --- a/internal/delivery/http/error.go +++ /dev/null @@ -1,30 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" -) - -func status(id int) string { - m := map[int]string{ - 200: "OK", - 400: "StatusBadRequest", - 401: "Unauthorized", - 403: "Forbidden", - 404: "StatusNotFound", - 429: "too many requests", - 500: "internal server error", - } - value, _ := m[id] - return value -} - -// writeError пишет ошибку клиенту. -// flag: 1 - полная ошибка, любой другой - только часть -func writeError(w http.ResponseWriter, code int, err error, flag int) { - if flag == 1 { - json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) - json.NewEncoder(w).Encode(map[string]string{"message": err.Error()}) - } - json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) -} diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go new file mode 100644 index 0000000..2876e4e --- /dev/null +++ b/internal/delivery/http/handler.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "log/slog" + "processing/internal/domain" + "processing/internal/infrastructure/logger" +) + +type handler struct { + ts domain.TransactionUsecase + as domain.AccountsUsecase + auth domain.AuthUseCase + log *slog.Logger +} + +func NewHandler( + ts domain.TransactionUsecase, + as domain.AccountsUsecase, + auth domain.AuthUseCase, + log *slog.Logger, +) *handler { + log = logger.WithService(log, "handler") + return &handler{ + ts: ts, + as: as, + auth: auth, + log: log, + } +} diff --git a/internal/delivery/http/handler_test.go b/internal/delivery/http/handler_test.go deleted file mode 100644 index 0bebd61..0000000 --- a/internal/delivery/http/handler_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package handlers - -type \ No newline at end of file diff --git a/internal/delivery/http/helper.go b/internal/delivery/http/helper.go deleted file mode 100644 index 059fd95..0000000 --- a/internal/delivery/http/helper.go +++ /dev/null @@ -1,77 +0,0 @@ -package handlers - -import ( - "fmt" - "net/url" - "processing/internal/domain" - "strconv" - "time" - - "github.com/google/uuid" -) - -func newTransactionFilter(query url.Values) (*domain.TransactionFilter, error) { - senderID, err := parseUUID(query, "sender_id") - if err != nil { - return nil, err - } - receiverID, err := parseUUID(query, "receiver_id") - if err != nil { - return nil, err - } - - minAmount := query.Get("min_amount") - maxAmount := query.Get("max_amount") - - limit, err := strconv.Atoi(query.Get("limit")) - if err != nil { - return nil, fmt.Errorf("невалидный limit: %w", err) - } - offset, err := strconv.Atoi(query.Get("offset")) - if err != nil { - return nil, fmt.Errorf("невалидный offset: %w", err) - } - - from, err := parseTime(query, "from") - if err != nil { - return nil, err - } - - to, err := parseTime(query, "to") - if err != nil { - return nil, err - } - - return &domain.TransactionFilter{ - SenderID: senderID, - ReceiverID: receiverID, - MinAmount: minAmount, - MaxAmount: maxAmount, - From: from, - To: to, - Limit: limit, - Offset: offset, - }, nil -} - -func parseUUID(query url.Values, key string) (uuid.UUID, error) { - val := query.Get(key) - if val == "" { - return uuid.UUID{}, nil - } - - id, err := uuid.Parse(val) - if err != nil { - return uuid.UUID{}, fmt.Errorf("невилдный %s", val) - } - return id, nil -} - -func parseTime(query url.Values, key string) (time.Time, error) { - t, err := time.Parse("2006-01-02", query.Get(key)) - if err != nil { - return time.Time{}, err - } - - return t, nil -} diff --git a/internal/delivery/http/helpers.go b/internal/delivery/http/helpers.go new file mode 100644 index 0000000..da4dd04 --- /dev/null +++ b/internal/delivery/http/helpers.go @@ -0,0 +1,217 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "processing/internal/domain" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +func newTransactionFilter(query url.Values) (*domain.TransactionFilter, error) { + senderID, err := parseUUID(query, "sender_id") + if err != nil { + return nil, err + } + receiverID, err := parseUUID(query, "receiver_id") + if err != nil { + return nil, err + } + + minAmount := query.Get("min_amount") + maxAmount := query.Get("max_amount") + + limit, err := strconv.Atoi(query.Get("limit")) + if err != nil { + return nil, fmt.Errorf("невалидный limit: %w", err) + } + offset, err := strconv.Atoi(query.Get("offset")) + if err != nil { + return nil, fmt.Errorf("невалидный offset: %w", err) + } + + from, err := parseTime(query, "from") + if err != nil { + return nil, err + } + + to, err := parseTime(query, "to") + if err != nil { + return nil, err + } + + return &domain.TransactionFilter{ + SenderID: senderID, + ReceiverID: receiverID, + MinAmount: minAmount, + MaxAmount: maxAmount, + From: from, + To: to, + Limit: limit, + Offset: offset, + }, nil +} + +func parseUUID(query url.Values, key string) (uuid.UUID, error) { + val := query.Get(key) + if val == "" { + return uuid.UUID{}, nil + } + + id, err := uuid.Parse(val) + if err != nil { + return uuid.UUID{}, fmt.Errorf("невилдный %s", val) + } + return id, nil +} + +func parseTime(query url.Values, key string) (time.Time, error) { + val := query.Get(key) + if val == "" { + return time.Time{}, nil + } + + t, err := time.Parse("2006-01-02", val) + if err != nil { + return time.Time{}, fmt.Errorf("невалидная дата %s: %w", key, err) + } + + return t, nil +} + +func status(id int) string { + m := map[int]string{ + 200: "OK", + 400: "StatusBadRequest", + 401: "Unauthorized", + 403: "Forbidden", + 404: "StatusNotFound", + 429: "too many requests", + 500: "internal server error", + } + value, _ := m[id] + return value +} + +// writeError пишет ошибку клиенту. +// flag: 1 - полная ошибка, любой другой - только часть +func writeError(w http.ResponseWriter, code int, err error, flag int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + + if flag == 1 { + json.NewEncoder(w).Encode(map[string]string{ + "error": status(code), + "message": err.Error(), + }) + return + } + json.NewEncoder(w).Encode(map[string]string{"error": status(code)}) +} + +func writeJSON(w http.ResponseWriter, code int, v any) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(v); err != nil { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + return err + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(code) + _, err := buf.WriteTo(w) + return err +} + +func setAuthCookie(w http.ResponseWriter, path, name, token string, maxage int) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: token, + Path: path, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: maxage, + }) +} + +func validateLogin(w http.ResponseWriter, data *AuthDTO) bool { + data.Email = strings.TrimSpace(data.Email) + + if data.Email == "" { + http.Error(w, "поле с почтой не может быть пустым", http.StatusUnprocessableEntity) + return false + } + + if data.Password == "" { + http.Error(w, "поле с паролем не может быть пустым", http.StatusUnprocessableEntity) + return false + } + + regmail := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + reg := regexp.MustCompile(regmail) + if !reg.MatchString(data.Email) { + http.Error( + w, + "Пожалуйста, введите корректный адрес электронной почты (например, example@mail.com)", + http.StatusUnprocessableEntity, + ) + return false + } + + if len(data.Password) < 8 { + http.Error(w, "длина пароля не может быть меньше 8 символов", http.StatusUnprocessableEntity) + return false + } + + return true +} + +func validateRegister(w http.ResponseWriter, data *AuthDTO) bool { + data.Email = strings.TrimSpace(data.Email) + data.Name = strings.TrimSpace(data.Name) + + if data.Name == "" { + http.Error(w, "поле с именем не может быть пустым", http.StatusUnprocessableEntity) + return false + } + + if data.Email == "" { + http.Error(w, "поле с почтой не может быть пустым", http.StatusUnprocessableEntity) + return false + } + + if data.Password == "" { + http.Error(w, "поле с паролем не может быть пустым", http.StatusUnprocessableEntity) + return false + } + + if len(data.Name) < 3 { + http.Error(w, "имя не может быть меньше 3 букв", http.StatusUnprocessableEntity) + return false + } + + regmail := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + reg := regexp.MustCompile(regmail) + if !reg.MatchString(data.Email) { + http.Error( + w, + "Пожалуйста, введите корректный адрес электронной почты (например, example@mail.com)", + http.StatusUnprocessableEntity, + ) + return false + } + + if len(data.Password) < 8 { + http.Error(w, "длина пароля не может быть меньше 8 символов", http.StatusUnprocessableEntity) + return false + } + + return true +} diff --git a/internal/delivery/http/jwt/.env.example b/internal/delivery/http/jwt/.env.example new file mode 100644 index 0000000..22e1afd --- /dev/null +++ b/internal/delivery/http/jwt/.env.example @@ -0,0 +1,3 @@ +#ключи для подписи токенов +accessSecretKey = +refreshSecretKey = \ No newline at end of file diff --git a/internal/delivery/http/jwt/.gitignore b/internal/delivery/http/jwt/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/internal/delivery/http/jwt/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/internal/delivery/http/jwt/jwt.go b/internal/delivery/http/jwt/jwt.go new file mode 100644 index 0000000..bd1ffa2 --- /dev/null +++ b/internal/delivery/http/jwt/jwt.go @@ -0,0 +1,132 @@ +package jwtLayer + +import ( + "errors" + "fmt" + "os" + "processing/internal/domain" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const ( + AccessTokenDuration = 15 * time.Minute + RefreshTokenDuration = 7 * 24 * time.Hour +) + +func GenerateTokenPair(userID string, role string) (*domain.TokenPair, error) { + accessSecretKey := os.Getenv("accessSecretKey") + refreshSecretKey := os.Getenv("refreshSecretKey") + + if accessSecretKey == "" || refreshSecretKey == "" { + return nil, errors.New("секретные ключи не установлены в переменных окружения") + } + + now := time.Now() + accessExpiresAt := now.Add(AccessTokenDuration) + refreshExpiresAt := now.Add(RefreshTokenDuration) + + accessClaims := domain.AccessClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "my-app", + }, + } + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessSigned, err := accessToken.SignedString([]byte(accessSecretKey)) + if err != nil { + return nil, fmt.Errorf("ошибка создания access token: %w", err) + } + + jti, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("ошибка генерации JTI: %w", err) + } + + refreshClaims := domain.RefreshClaims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti.String(), + ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "my-app", + }, + } + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshSigned, err := refreshToken.SignedString([]byte(refreshSecretKey)) + if err != nil { + return nil, fmt.Errorf("ошибка создания refresh token: %w", err) + } + + return &domain.TokenPair{ + AccessToken: accessSigned, + RefreshToken: refreshSigned, + ExpiresIn: int64(AccessTokenDuration.Seconds()), + JTI: jti.String(), + ExpiresAt: refreshExpiresAt, + }, nil +} + +func ValidateAccessToken(tokenString string) (*domain.AccessClaims, error) { + accessSecretKey := os.Getenv("accessSecretKey") + + token, err := jwt.ParseWithClaims( + tokenString, &domain.AccessClaims{}, + func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("неверный алгоритм: %v", t.Header["alg"]) + } + return []byte(accessSecretKey), nil + }, + ) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, errors.New("истекший токен") + } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return nil, errors.New("подпись неверна") + } + return nil, fmt.Errorf("парсинг jwt токена: %w", err) + } + + claims, ok := token.Claims.(*domain.AccessClaims) + if !ok { + return nil, errors.New("невалидные claims") + } + + return claims, nil +} + +func ValidateRefreshToken(tokenString string) (*domain.RefreshClaims, error) { + refreshSecretKey := os.Getenv("refreshSecretKey") + + token, err := jwt.ParseWithClaims( + tokenString, + &domain.RefreshClaims{}, + func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("невалидный алгоритм: %v", t.Header["alg"]) + } + return []byte(refreshSecretKey), nil + }, + ) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, errors.New("истекший токен") + } else if errors.Is(err, jwt.ErrTokenSignatureInvalid) { + return nil, errors.New("подпись неверна") + } + return nil, fmt.Errorf("парсинг jwt токена: %w", err) + } + + claims, ok := token.Claims.(*domain.RefreshClaims) + if !ok { + return nil, errors.New("невалидные claims") + } + + return claims, nil +} diff --git a/internal/delivery/http/middleware/middleware.go b/internal/delivery/http/middleware/middleware.go new file mode 100644 index 0000000..1af5290 --- /dev/null +++ b/internal/delivery/http/middleware/middleware.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "net/http" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/domain" + "strings" +) + +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, err := validateToken(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "user_id", claims.UserID) + ctx = context.WithValue(ctx, "role", claims.Role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func validateToken(r *http.Request) (*domain.AccessClaims, error) { + var token string + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + if !strings.HasPrefix(authHeader, "Bearer ") { + return nil, errors.New("неверный формат authorization header") + } + token = strings.TrimPrefix(authHeader, "Bearer ") + } else { + cookie, err := r.Cookie("access_token") + if err != nil { + return nil, errors.New("токен отсутствует") + } + token = cookie.Value + } + + if token == "" { + return nil, errors.New("токен пустой") + } + + claims, err := jwtLayer.ValidateAccessToken(token) + if err != nil { + return nil, fmt.Errorf("невалидный токен: %w", err) + } + + if claims.UserID == "" { + return nil, errors.New("user_id отсутствует в токене") + } + + return claims, nil +} diff --git a/internal/delivery/http/mocks/AccountsUsecase.go b/internal/delivery/http/mocks/AccountsUsecase.go new file mode 100644 index 0000000..56ea0cc --- /dev/null +++ b/internal/delivery/http/mocks/AccountsUsecase.go @@ -0,0 +1,116 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// AccountsUsecase is an autogenerated mock type for the AccountsUsecase type +type AccountsUsecase struct { + mock.Mock +} + +// Create provides a mock function with given fields: ctx, acc, ip +func (_m *AccountsUsecase) Create(ctx context.Context, acc *domain.Account, ip string) error { + ret := _m.Called(ctx, acc, ip) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.Account, string) error); ok { + r0 = rf(ctx, acc, ip) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAccount provides a mock function with given fields: ctx, id +func (_m *AccountsUsecase) GetAccount(ctx context.Context, id uuid.UUID) (*domain.Account, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetAccount") + } + + var r0 *domain.Account + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*domain.Account, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *domain.Account); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.Account) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TransactionHistory provides a mock function with given fields: ctx, accountID, limit, offset +func (_m *AccountsUsecase) TransactionHistory(ctx context.Context, accountID uuid.UUID, limit int, offset int) (int, []domain.Transaction, error) { + ret := _m.Called(ctx, accountID, limit, offset) + + if len(ret) == 0 { + panic("no return value specified for TransactionHistory") + } + + var r0 int + var r1 []domain.Transaction + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int, int) (int, []domain.Transaction, error)); ok { + return rf(ctx, accountID, limit, offset) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, int, int) int); ok { + r0 = rf(ctx, accountID, limit, offset) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, int, int) []domain.Transaction); ok { + r1 = rf(ctx, accountID, limit, offset) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]domain.Transaction) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, uuid.UUID, int, int) error); ok { + r2 = rf(ctx, accountID, limit, offset) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewAccountsUsecase creates a new instance of AccountsUsecase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAccountsUsecase(t interface { + mock.TestingT + Cleanup(func()) +}) *AccountsUsecase { + mock := &AccountsUsecase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/mocks/AuthUseCase.go b/internal/delivery/http/mocks/AuthUseCase.go new file mode 100644 index 0000000..3074f09 --- /dev/null +++ b/internal/delivery/http/mocks/AuthUseCase.go @@ -0,0 +1,157 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// AuthUseCase is an autogenerated mock type for the AuthUseCase type +type AuthUseCase struct { + mock.Mock +} + +// Login provides a mock function with given fields: ctx, email, password, ip +func (_m *AuthUseCase) Login(ctx context.Context, email string, password string, ip string) (*domain.TokenPair, error) { + ret := _m.Called(ctx, email, password, ip) + + if len(ret) == 0 { + panic("no return value specified for Login") + } + + var r0 *domain.TokenPair + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*domain.TokenPair, error)); ok { + return rf(ctx, email, password, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *domain.TokenPair); ok { + r0 = rf(ctx, email, password, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.TokenPair) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, email, password, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Logout provides a mock function with given fields: ctx, refreshToken, ip +func (_m *AuthUseCase) Logout(ctx context.Context, refreshToken string, ip string) error { + ret := _m.Called(ctx, refreshToken, ip) + + if len(ret) == 0 { + panic("no return value specified for Logout") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, refreshToken, ip) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// LogoutAll provides a mock function with given fields: ctx, userID +func (_m *AuthUseCase) LogoutAll(ctx context.Context, userID uuid.UUID) error { + ret := _m.Called(ctx, userID) + + if len(ret) == 0 { + panic("no return value specified for LogoutAll") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) error); ok { + r0 = rf(ctx, userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Refresh provides a mock function with given fields: ctx, refreshToken, ip +func (_m *AuthUseCase) Refresh(ctx context.Context, refreshToken string, ip string) (*domain.TokenPair, error) { + ret := _m.Called(ctx, refreshToken, ip) + + if len(ret) == 0 { + panic("no return value specified for Refresh") + } + + var r0 *domain.TokenPair + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*domain.TokenPair, error)); ok { + return rf(ctx, refreshToken, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *domain.TokenPair); ok { + r0 = rf(ctx, refreshToken, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.TokenPair) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, refreshToken, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Register provides a mock function with given fields: ctx, email, password, name, ip +func (_m *AuthUseCase) Register(ctx context.Context, email string, password string, name string, ip string) (*domain.Account, error) { + ret := _m.Called(ctx, email, password, name, ip) + + if len(ret) == 0 { + panic("no return value specified for Register") + } + + var r0 *domain.Account + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) (*domain.Account, error)); ok { + return rf(ctx, email, password, name, ip) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) *domain.Account); ok { + r0 = rf(ctx, email, password, name, ip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*domain.Account) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { + r1 = rf(ctx, email, password, name, ip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewAuthUseCase creates a new instance of AuthUseCase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewAuthUseCase(t interface { + mock.TestingT + Cleanup(func()) +}) *AuthUseCase { + mock := &AuthUseCase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/mocks/TransactionUsecase.go b/internal/delivery/http/mocks/TransactionUsecase.go new file mode 100644 index 0000000..fb46819 --- /dev/null +++ b/internal/delivery/http/mocks/TransactionUsecase.go @@ -0,0 +1,118 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + decimal "processing/internal/decimal" + domain "processing/internal/domain" + + mock "github.com/stretchr/testify/mock" + + uuid "github.com/google/uuid" +) + +// TransactionUsecase is an autogenerated mock type for the TransactionUsecase type +type TransactionUsecase struct { + mock.Mock +} + +// GetTransaction provides a mock function with given fields: ctx, transactionID, userID, key +func (_m *TransactionUsecase) GetTransaction(ctx context.Context, transactionID uuid.UUID, userID uuid.UUID, key string) (domain.Transaction, error) { + ret := _m.Called(ctx, transactionID, userID, key) + + if len(ret) == 0 { + panic("no return value specified for GetTransaction") + } + + var r0 domain.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string) (domain.Transaction, error)); ok { + return rf(ctx, transactionID, userID, key) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string) domain.Transaction); ok { + r0 = rf(ctx, transactionID, userID, key) + } else { + r0 = ret.Get(0).(domain.Transaction) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, string) error); ok { + r1 = rf(ctx, transactionID, userID, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTransactionFilter provides a mock function with given fields: ctx, t, userID, key +func (_m *TransactionUsecase) GetTransactionFilter(ctx context.Context, t *domain.TransactionFilter, userID uuid.UUID, key string) ([]domain.Transaction, error) { + ret := _m.Called(ctx, t, userID, key) + + if len(ret) == 0 { + panic("no return value specified for GetTransactionFilter") + } + + var r0 []domain.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) ([]domain.Transaction, error)); ok { + return rf(ctx, t, userID, key) + } + if rf, ok := ret.Get(0).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) []domain.Transaction); ok { + r0 = rf(ctx, t, userID, key) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]domain.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *domain.TransactionFilter, uuid.UUID, string) error); ok { + r1 = rf(ctx, t, userID, key) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Transfer provides a mock function with given fields: ctx, sender_id, receiver_id, key, amount +func (_m *TransactionUsecase) Transfer(ctx context.Context, sender_id uuid.UUID, receiver_id uuid.UUID, key string, amount decimal.Decimal) (string, error) { + ret := _m.Called(ctx, sender_id, receiver_id, key, amount) + + if len(ret) == 0 { + panic("no return value specified for Transfer") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) (string, error)); ok { + return rf(ctx, sender_id, receiver_id, key, amount) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) string); ok { + r0 = rf(ctx, sender_id, receiver_id, key, amount) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID, uuid.UUID, string, decimal.Decimal) error); ok { + r1 = rf(ctx, sender_id, receiver_id, key, amount) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewTransactionUsecase creates a new instance of TransactionUsecase. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTransactionUsecase(t interface { + mock.TestingT + Cleanup(func()) +}) *TransactionUsecase { + mock := &TransactionUsecase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/delivery/http/transaction_handler.go b/internal/delivery/http/transaction_handler.go index 23fe5a6..174fb33 100644 --- a/internal/delivery/http/transaction_handler.go +++ b/internal/delivery/http/transaction_handler.go @@ -1,119 +1,149 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "processing/internal/decimal" - "processing/internal/usecase" - - "github.com/google/uuid" -) - -type handler struct { - service *usecase.TransferService -} - -func NewHandler(transferService *usecase.TransferService) *handler { - return &handler{service: transferService} -} - -type transferDTO struct { - Sender_id uuid.UUID `json:"sender_id"` - Receiver_id uuid.UUID `json:"receiver_id"` - Amount string `json:"amount"` -} - -// Transfer хэндлер для отправки транзакции платежа -// POST /transactions -func (h *handler) Transfer(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - ctx := r.Context() - - key := r.Header.Get("Idempotency-Key") - var dto transferDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 400, err, 0) - return - } - - amount, err := decimal.NewFromString(dto.Amount) - if err != nil { - writeError(w, 500, err, 0) - return - } - - if err := h.service.Transfer(ctx, dto.Sender_id, dto.Receiver_id, key, amount); err != nil { - writeError(w, 500, err, 1) - return - } -} - -type transactionDTO struct { - UserID uuid.UUID `json:"user_id"` - IdempotencyKEY string `json:"Idempotency-Key"` -} - -// получение транзакции по id -// GET /transactions/:id -func (h *handler) GetTransaction(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeError(w, 400, err, 0) - return - } - - var dto transactionDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 500, err, 0) - return - } - ctx := r.Context() - - transaction, err := h.service.GetTransaction(ctx, id, dto.UserID, dto.IdempotencyKEY) - if err != nil { - writeError(w, 500, err, 1) - return - } - - if err := json.NewEncoder(w).Encode(transaction); err != nil { - writeError(w, 500, err, 0) - return - } -} - -// выводит транзакции по фильтрам -// Примеры запросов: -// GET /transactions?sender_id=uuid&from=2024-01-01&limit=20&offset=0 -// GET /transactions?receiver_id=uuid&min_amount=1000 -func (h *handler) TransactionFilter(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - w.Header().Set("Content-Type", "application/json") - - var dto transactionDTO - if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { - writeError(w, 400, err, 0) - return - } - - ctx := r.Context() - query := r.URL.Query() - filter, err := newTransactionFilter(query) - if err != nil { - writeError(w, 400, err, 1) - return - } - - transactions, err := h.service.GetTransactionFilter(ctx, filter, dto.UserID, dto.IdempotencyKEY) - if err != nil { - writeError(w, 500, err, 1) - return - } - - if err := json.NewEncoder(w).Encode(transactions); err != nil { - writeError(w, 500, err, 0) - return - } -} +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "processing/internal/decimal" + "processing/internal/domain" + + "github.com/google/uuid" +) + +type transferDTO struct { + Receiver_id uuid.UUID `json:"receiver_id"` + Amount string `json:"amount"` +} + +// Transfer хэндлер для оплаты +// POST /transactions +func (h *handler) Transfer(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "application/json") + ctx := r.Context() + + // Get user_id from context (middleware) + ctxUserID, ok := ctx.Value("user_id").(string) + if !ok { + writeError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + senderID, err := uuid.Parse(ctxUserID) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + var dto transferDTO + if err := json.NewDecoder(r.Body).Decode(&dto); err != nil { + writeError(w, 400, err, 0) + return + } + + amount, err := decimal.NewFromString(dto.Amount) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + transactionID, err := h.ts.Transfer(ctx, senderID, dto.Receiver_id, key, amount) + if err != nil { + if errors.Is(err, domain.ErrSameAccount) || errors.Is(err, domain.ErrInvalidAmount) || errors.Is(err, domain.ErrInsufficientFunds) || errors.Is(err, domain.ErrReceiverAccountNotFound) { + writeError(w, http.StatusBadRequest, err, 1) + return + } + writeError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := writeJSON(w, http.StatusCreated, map[string]string{"transaction_id": transactionID}); err != nil { + h.log.Error("[transfer] json encode", "err", err) + } +} + +type transactionDTO struct { + UserID uuid.UUID `json:"user_id"` + IdempotencyKEY string `json:"Idempotency-Key"` +} + +// получение транзакции по id +// GET /transactions/:id +func (h *handler) GetTransaction(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + id, err := uuid.Parse(r.PathValue("id")) + w.Header().Set("Content-Type", "application/json") + if err != nil { + writeError(w, 400, err, 1) + return + } + + ctx := r.Context() + ctxUserID, ok := ctx.Value("user_id").(string) + if !ok { + writeError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + userID, err := uuid.Parse(ctxUserID) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + transaction, err := h.ts.GetTransaction(ctx, id, userID, key) + if err != nil { + if errors.Is(err, domain.ErrAccessDenied) { + writeError(w, http.StatusNotFound, err, 0) + return + } + writeError(w, http.StatusInternalServerError, err, 1) + return + } + + if err := writeJSON(w, http.StatusOK, transaction); err != nil { + h.log.Error("[GetTransaction] json encode", "err", err) + } +} + +// выводит транзакции по фильтрам +// Примеры запросов: +// GET /transactions?sender_id=uuid&from=2024-01-01&limit=20&offset=0 +// GET /transactions?receiver_id=uuid&min_amount=1000 +func (h *handler) TransactionFilter(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + w.Header().Set("Content-Type", "application/json") + + ctx := r.Context() + ctxUserID, ok := ctx.Value("user_id").(string) + if !ok { + writeError(w, http.StatusUnauthorized, errors.New("user_id не найден в контексте"), 0) + return + } + + userID, err := uuid.Parse(ctxUserID) + if err != nil { + writeError(w, http.StatusBadRequest, err, 0) + return + } + + key := r.Header.Get("Idempotency-Key") + query := r.URL.Query() + filter, err := newTransactionFilter(query) + if err != nil { + writeError(w, 400, err, 1) + return + } + + transactions, err := h.ts.GetTransactionFilter(ctx, filter, userID, key) + if err != nil { + writeError(w, 500, err, 1) + return + } + + if err := writeJSON(w, http.StatusOK, transactions); err != nil { + h.log.Error("[TransactionFilter] json encode", "err", err) + } +} diff --git a/internal/delivery/http/transaction_test.go b/internal/delivery/http/transaction_test.go new file mode 100644 index 0000000..4d8b6b5 --- /dev/null +++ b/internal/delivery/http/transaction_test.go @@ -0,0 +1,427 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "processing/internal/decimal" + "processing/internal/delivery/http/mocks" + "processing/internal/domain" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTransactionTransferHandler(t *testing.T) { + testSenderID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + + tests := []struct { + name string + requestBody interface{} + idempotencyKey string + setupMock func(*mocks.TransactionUsecase, uuid.UUID, uuid.UUID, decimal.Decimal) + expectedStatusCode int + expectError bool + }{ + { + name: "успешный перевод", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "500.50", + }, + idempotencyKey: "test-key-123", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "test-key-123", + amount, + ).Return("test-transaction-id", nil).Once() + }, + expectedStatusCode: http.StatusCreated, + expectError: false, + }, + { + name: "невалидный JSON", + requestBody: `{"invalid json`, + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // не вызываем Transfer, т.к. ошибка парсинга раньше + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный amount формат", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "invalid-amount", + }, + idempotencyKey: "test-key-456", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + // не вызываем Transfer, т.к. ошибка парсинга amount + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "ошибка от usecase", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "1000.00", + }, + idempotencyKey: "test-key-789", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "test-key-789", + amount, + ).Return("", errors.New("недостаточно средств")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "пустой idempotency key", + requestBody: transferDTO{ + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174001"), + Amount: "100.00", + }, + idempotencyKey: "", + setupMock: func(m *mocks.TransactionUsecase, senderID, receiverID uuid.UUID, amount decimal.Decimal) { + m.On("Transfer", + mock.Anything, + senderID, + receiverID, + "", + amount, + ).Return("test-transaction-id-2", nil).Once() + }, + expectedStatusCode: http.StatusCreated, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecase := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecase, nil) + + var receiverID uuid.UUID + var amount decimal.Decimal + if dto, ok := tt.requestBody.(transferDTO); ok { + receiverID = dto.Receiver_id + amount, _ = decimal.NewFromString(dto.Amount) + } + + tt.setupMock(mockUsecase, testSenderID, receiverID, amount) + + var bodyBytes []byte + var err error + if strBody, ok := tt.requestBody.(string); ok { + bodyBytes = []byte(strBody) + } else { + bodyBytes, err = json.Marshal(tt.requestBody) + require.NoError(t, err) + } + + req := httptest.NewRequest(http.MethodPost, "/transactions", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + if tt.idempotencyKey != "" { + req.Header.Set("Idempotency-Key", tt.idempotencyKey) + } + ctx := context.WithValue(context.Background(), "user_id", testSenderID.String()) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + handler.Transfer(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var resp map[string]string + err = json.Unmarshal(rr.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.NotEmpty(t, resp["transaction_id"]) + } + }) + t.Log("\n\n\n") + } +} + +func TestGetTransactionHandler(t *testing.T) { + validTransactionID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174001") + + tests := []struct { + name string + transactionID string + setupMock func(*mocks.TransactionUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешное получение транзакции", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + amount, _ := decimal.NewFromString("500.50") + expectedTransaction := domain.Transaction{ + ID: validTransactionID, + Amount: amount, + Sender_id: testUserID, + Receiver_id: uuid.MustParse("123e4567-e89b-12d3-a456-426614174002"), + Status: domain.StatusCompleted, + Created_at: time.Now(), + } + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(expectedTransaction, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный UUID транзакции", + transactionID: "invalid-uuid", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransaction, т.к. ошибка парсинга UUID раньше + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "транзакция не найдена", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(domain.Transaction{}, errors.New("транзакция не найдена")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + { + name: "доступ запрещен к чужой транзакции", + transactionID: validTransactionID.String(), + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransaction", + mock.Anything, + validTransactionID, + testUserID, + "", + ).Return(domain.Transaction{}, domain.ErrAccessDenied).Once() + }, + expectedStatusCode: http.StatusNotFound, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecae := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecae, nil) + + tt.setupMock(mockUsecase) + + req := httptest.NewRequest(http.MethodGet, "/transactions/"+tt.transactionID, nil) + req.SetPathValue("id", tt.transactionID) + ctx := context.WithValue(context.Background(), "user_id", testUserID.String()) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.GetTransaction(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response domain.Transaction + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + } + }) + } +} + +func TestTransactionFilterHandler(t *testing.T) { + testUserID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174001") + validSenderID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174002") + validReceiverID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174003") + amount, _ := decimal.NewFromString("500.50") + amount2, _ := decimal.NewFromString("250") + tests := []struct { + name string + queryParams string + idempotencyKey string + setupMock func(*mocks.TransactionUsecase) + expectedStatusCode int + expectError bool + }{ + { + name: "успешная фильтрация с sender_id", + queryParams: "sender_id=" + validSenderID.String() + "&limit=10&offset=0", + idempotencyKey: "test-key-123", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{ + { + ID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + Amount: amount, + Sender_id: validSenderID, + Receiver_id: validReceiverID, + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.SenderID == validSenderID && + filter.Limit == 10 && + filter.Offset == 0 + }), + testUserID, + "test-key-123", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "успешная фильтрация с receiver_id и датами", + queryParams: "receiver_id=" + validReceiverID.String() + "&from=2024-01-01&to=2024-12-31&limit=20&offset=5", + idempotencyKey: "test-key-456", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{} + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.ReceiverID == validReceiverID && + filter.Limit == 20 && + filter.Offset == 5 + }), + testUserID, + "test-key-456", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "фильтрация с min_amount и max_amount", + queryParams: "min_amount=100.00&max_amount=1000.00&limit=15&offset=0", + idempotencyKey: "test-key-789", + setupMock: func(m *mocks.TransactionUsecase) { + expectedTransactions := []domain.Transaction{ + { + ID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + Amount: amount2, + Sender_id: validSenderID, + Receiver_id: validReceiverID, + Status: domain.StatusCompleted, + Created_at: time.Now(), + }, + } + m.On("GetTransactionFilter", + mock.Anything, + mock.MatchedBy(func(filter *domain.TransactionFilter) bool { + return filter.MinAmount == "100.00" && + filter.MaxAmount == "1000.00" && + filter.Limit == 15 && + filter.Offset == 0 + }), + testUserID, + "test-key-789", + ).Return(expectedTransactions, nil).Once() + }, + expectedStatusCode: http.StatusOK, + expectError: false, + }, + { + name: "невалидный limit параметр", + queryParams: "limit=invalid&offset=0", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransactionFilter, т.к. ошибка парсинга limit + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "невалидный UUID в sender_id", + queryParams: "sender_id=invalid-uuid&limit=10&offset=0", + setupMock: func(m *mocks.TransactionUsecase) { + // не вызываем GetTransactionFilter, т.к. ошибка парсинга UUID + }, + expectedStatusCode: http.StatusBadRequest, + expectError: true, + }, + { + name: "ошибка от usecase", + queryParams: "sender_id=" + validSenderID.String() + "&limit=10&offset=0", + idempotencyKey: "test-key-777", + setupMock: func(m *mocks.TransactionUsecase) { + m.On("GetTransactionFilter", + mock.Anything, + mock.Anything, + testUserID, + "test-key-777", + ).Return([]domain.Transaction{}, errors.New("database error")).Once() + }, + expectedStatusCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockUsecase := mocks.NewTransactionUsecase(t) + mockAccountUsecase := mocks.NewAccountsUsecase(t) + mockAuthUsecae := mocks.NewAuthUseCase(t) + handler := NewHandler(mockUsecase, mockAccountUsecase, mockAuthUsecae, nil) + + tt.setupMock(mockUsecase) + + req := httptest.NewRequest(http.MethodGet, "/transactions?"+tt.queryParams, nil) + if tt.idempotencyKey != "" { + req.Header.Set("Idempotency-Key", tt.idempotencyKey) + } + ctx := context.WithValue(context.Background(), "user_id", testUserID.String()) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.TransactionFilter(rr, req) + + assert.Equal(t, tt.expectedStatusCode, rr.Code, "неожиданный статус код") + assert.Equal(t, "application/json", rr.Header().Get("Content-Type")) + + if tt.expectError { + assert.NotEmpty(t, rr.Body.String(), "ожидался response body с ошибкой") + } else { + var response []domain.Transaction + err := json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err, "ответ должен быть валидным JSON") + } + }) + } +} diff --git a/internal/domain/account.go b/internal/domain/account.go index 4188b5a..a37e6f2 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -1 +1,32 @@ -package domain +package domain + +import ( + "context" + "fmt" + "processing/internal/decimal" + + "github.com/google/uuid" +) + +type Account struct { + ID uuid.UUID `json:"id"` + Name string `json:"username"` + Email string `json:"email"` + Balance decimal.Decimal `json:"balance"` + PasswordHash string `json:"-"` + Role string `json:"role"` +} + +func NewAccount(name string, balance decimal.Decimal) (*Account, error) { + id, err := uuid.NewUUID() + if err != nil { + return nil, fmt.Errorf("создание uuid: %w", err) + } + return &Account{ID: id, Name: name, Balance: balance}, nil +} + +type AccountsUsecase interface { + Create(ctx context.Context, acc *Account, ip string) error + GetAccount(ctx context.Context, id uuid.UUID) (*Account, error) + TransactionHistory(ctx context.Context, accountID uuid.UUID, limit, offset int) (int, []Transaction, error) +} diff --git a/internal/domain/cache.go b/internal/domain/cache.go index 921bd3e..f0b6be8 100644 --- a/internal/domain/cache.go +++ b/internal/domain/cache.go @@ -1,15 +1,13 @@ -package domain - -import ( - "context" - "time" - - "github.com/google/uuid" -) - -// Cache - рейтлимитит запросы пользователя -// и кэширует запросы пользователей для последующей дедупликации -type Cache interface { - CheckRateLimit(ctx context.Context, userID uuid.UUID) error - IdempotencyCheck(ctx context.Context, key string, limit int64, TTL time.Duration) error -} +package domain + +import ( + "context" + "time" +) + +// Cache - рейтлимитит запросы пользователя +// и кэширует запросы пользователей для последующей дедупликации +type Cache interface { + CheckRateLimit(ctx context.Context, id string) error + IdempotencyCheck(ctx context.Context, key string, TTL time.Duration) error +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 14ef62d..7a2a6c3 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1,9 +1,22 @@ -package domain - -import "errors" - -var ( - ErrInsufficientFunds = errors.New("недостаточно средств") - ErrInvalidAmount = errors.New("сумма должна быть положительной") - ErrSameAccount = errors.New("отправитель и получатель должны быть разными") -) +package domain + +import "errors" + +var ( + ErrAccessDenied = errors.New("доступ запрещен") + ErrInsufficientFunds = errors.New("недостаточно средств") + ErrInvalidAmount = errors.New("сумма должна быть положительной") + ErrSameAccount = errors.New("отправитель и получатель должны быть разными") + ErrReceiverAccountNotFound = errors.New("receiver аккаунт не найден") + ErrAccountAlreadyExist = errors.New("аккаунт уже существует") +) + +var ( + ErrSaveRefreshToken = errors.New("ошибка сохранения рефреш токена") + ErrRefreshTokenNotFound = errors.New("refresh токен не найден") + ErrUserNotFound = errors.New("связаннй с токеном юзер не найден") + ErrRefreshTokenRevoked = errors.New("refresh токен отозван") + ErrRefreshTokenExpired = errors.New("refresh токен истек") + ErrInvalidCredentials = errors.New("неверные учетные данные") + ErrInvalidRefreshToken = errors.New("невалидный refresh токен") +) diff --git a/internal/domain/jwt.go b/internal/domain/jwt.go new file mode 100644 index 0000000..d036b61 --- /dev/null +++ b/internal/domain/jwt.go @@ -0,0 +1,44 @@ +package domain + +import ( + "context" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type AuthUseCase interface { + Register(ctx context.Context, email, password, name string, ip string) (*Account, error) + Login(ctx context.Context, email, password string, ip string) (*TokenPair, error) + Refresh(ctx context.Context, refreshToken string, ip string) (*TokenPair, error) + Logout(ctx context.Context, refreshToken string, ip string) error + LogoutAll(ctx context.Context, userID uuid.UUID) error +} + +// RefreshSession представляет сессию refresh токена в БД +type RefreshSession struct { + UserID uuid.UUID + Revoked bool + ExpiresAt time.Time +} + +// TokenPair пара токенов для клиента +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + JTI string `json:"-"` + ExpiresAt time.Time `json:"-"` +} + +type AccessClaims struct { + UserID string `json:"user_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type RefreshClaims struct { + UserID string `json:"user_id"` + jwt.RegisteredClaims +} diff --git a/internal/domain/repositories.go b/internal/domain/repositories.go index f7fe91e..4a8c070 100644 --- a/internal/domain/repositories.go +++ b/internal/domain/repositories.go @@ -1,96 +1,56 @@ -package domain - -import ( - "context" - "fmt" - "processing/internal/decimal" - - "time" - - "github.com/google/uuid" -) - -type TransactionStatus string - -const ( - StatusPending TransactionStatus = "pending" - StatusCompleted TransactionStatus = "completed" - StatusFailed TransactionStatus = "failed" -) - -// Интерфейсы репозиториев (контракты для infrastructure слоя) -type TransactionStorage interface { - Transaction(ctx context.Context, tx *Transaction) error - UpdateStatus(ctx context.Context, tx *Transaction, status TransactionStatus) error - GetByID(ctx context.Context, transactionID uuid.UUID) (Transaction, error) - GetTransactions(ctx context.Context, filter TransactionFilter) ([]Transaction, error) -} - -type AccountsStorage interface { - Create(ctx context.Context, ac *Account) error - GetById(ctx context.Context, id uuid.UUID) (*Account, error) - Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error - Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error -} - -// UnitOfWork управляет аккаунтами, транзакциями и транзакциями самой бд -type UnitOfWork interface { - Accounts() AccountsStorage - Transactions() TransactionStorage - Commit() error - Rollback() error -} - -// TxUOW нужен для создания транзакции (фабрика) -type TxUOW interface { - NewTX(ctx context.Context) (UnitOfWork, error) -} - -// Доменные сущности -type Transaction struct { - ID uuid.UUID `json:"-"` - Amount decimal.Decimal `json:"amount"` - Sender_id uuid.UUID `json:"sender_id"` - Receiver_id uuid.UUID `json:"receiver_id"` - Status TransactionStatus `json:"status"` - Created_at time.Time `json:"created_at"` -} - -type Account struct { - ID uuid.UUID `json:"account_id"` - Name string `json:"name"` - Balance decimal.Decimal `json:"balance"` -} - -// Фабричные методы для создания доменных сущностей -func NewTransaction(amount decimal.Decimal, sender_id uuid.UUID, receiver_id uuid.UUID) (*Transaction, error) { - id, err := uuid.NewUUID() - if err != nil { - return nil, fmt.Errorf("создание uuid: %w", err) - } - return &Transaction{ - ID: id, - Amount: amount, - Sender_id: sender_id, - Receiver_id: receiver_id, - }, nil -} - -func NewAccount(name string, balance decimal.Decimal) (*Account, error) { - id, err := uuid.NewUUID() - if err != nil { - return nil, fmt.Errorf("создание uuid: %w", err) - } - return &Account{ID: id, Name: name, Balance: balance}, nil -} - -type TransactionFilter struct { - SenderID uuid.UUID - ReceiverID uuid.UUID - MinAmount string - MaxAmount string - From time.Time - To time.Time - Limit int - Offset int -} +package domain + +import ( + "context" + "processing/internal/decimal" + "time" + + "github.com/google/uuid" +) + +type TransactionStatus string + +const ( + StatusPending TransactionStatus = "pending" + StatusCompleted TransactionStatus = "completed" + StatusFailed TransactionStatus = "failed" +) + +// Интерфейсы репозиториев (контракты для infrastructure слоя) +type TransactionStorage interface { + Transaction(ctx context.Context, tx *Transaction) error + UpdateStatus(ctx context.Context, tx *Transaction, status TransactionStatus) error + GetByID(ctx context.Context, transactionID uuid.UUID) (Transaction, error) + GetTransactions(ctx context.Context, filter TransactionFilter) ([]Transaction, error) + TotalTransactions(ctx context.Context, userID uuid.UUID) (int, error) +} + +type AccountsStorage interface { + Create(ctx context.Context, ac *Account) error + GetById(ctx context.Context, id uuid.UUID) (*Account, error) + GetByEmail(ctx context.Context, email string) (*Account, error) + Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error + Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error +} + +// TokenStorage управляет refresh токенами +type TokenStorage interface { + SaveRefreshToken(ctx context.Context, jti string, user_id string, expires_at time.Time) error + GetRefreshToken(ctx context.Context, jti string) (*RefreshSession, error) + RevokeRefreshToken(ctx context.Context, jti string) error + RevokeAllUserTokens(ctx context.Context, userID uuid.UUID) error +} + +// UnitOfWork управляет аккаунтами, транзакциями и транзакциями самой бд +type UnitOfWork interface { + Accounts() AccountsStorage + Transactions() TransactionStorage + Tokens() TokenStorage + Commit() error + Rollback() error +} + +type TxUOW interface { + // TxUOW нужен для создания транзакции (фабрика) + NewTX(ctx context.Context) (UnitOfWork, error) +} diff --git a/internal/domain/transactions.go b/internal/domain/transactions.go index 45c579f..2c45449 100644 --- a/internal/domain/transactions.go +++ b/internal/domain/transactions.go @@ -1,28 +1,50 @@ -package domain - -import ( - "processing/internal/decimal" - - "github.com/google/uuid" -) - -// validateTransferRequest - валидирует реквест, проверяет достаточно ли денег на балансе сендера -// не является ли получатель отправителем, положительная ли сумма -func ValidateTransferRequest( - sender uuid.UUID, - receiver uuid.UUID, - sender_balance decimal.Decimal, - amount decimal.Decimal) error { - if sender == receiver { - return ErrSameAccount - } - - if !amount.IsPositive() { - return ErrInvalidAmount - } - - if sender_balance.Compare(amount) == -1 { - return ErrInsufficientFunds - } - return nil -} +package domain + +import ( + "context" + "fmt" + "processing/internal/decimal" + "time" + + "github.com/google/uuid" +) + +type TransactionUsecase interface { + Transfer(ctx context.Context, sender_id, receiver_id uuid.UUID, key string, amount decimal.Decimal) (string, error) + GetTransaction(ctx context.Context, transactionID, userID uuid.UUID, key string) (Transaction, error) + GetTransactionFilter(ctx context.Context, t *TransactionFilter, userID uuid.UUID, key string) ([]Transaction, error) +} + +type Transaction struct { + ID uuid.UUID `json:"-"` + Amount decimal.Decimal `json:"amount"` + Sender_id uuid.UUID `json:"sender_id"` + Receiver_id uuid.UUID `json:"receiver_id"` + Status TransactionStatus `json:"status"` + Created_at time.Time `json:"created_at"` +} + +func NewTransaction(amount decimal.Decimal, sender_id uuid.UUID, receiver_id uuid.UUID) (*Transaction, error) { + id, err := uuid.NewUUID() + if err != nil { + return nil, fmt.Errorf("создание uuid: %w", err) + } + return &Transaction{ + ID: id, + Amount: amount, + Sender_id: sender_id, + Receiver_id: receiver_id, + }, nil +} + +type TransactionFilter struct { + AccountID uuid.UUID + SenderID uuid.UUID + ReceiverID uuid.UUID + MinAmount string + MaxAmount string + From time.Time + To time.Time + Limit int + Offset int +} diff --git a/internal/infrastructure/cache/redis.go b/internal/infrastructure/cache/redis.go index f63f4bd..eb66e23 100644 --- a/internal/infrastructure/cache/redis.go +++ b/internal/infrastructure/cache/redis.go @@ -1,87 +1,123 @@ -package cache - -import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - "github.com/google/uuid" - "github.com/redis/go-redis/v9" -) - -var ( - ErrRateLimitExceed = errors.New("превышен лимит запросов") - ErrDupRequest = errors.New("запрос дубликат") -) - -type Redis struct { - client *redis.Client - log *slog.Logger -} - -func NewRedis(addr string, log *slog.Logger) *Redis { - c := redis.NewClient(&redis.Options{ - Addr: addr, - }) - return &Redis{client: c, log: log} -} - -// IdempotencyCheck - функция счётчик, проверяет не был ли уже такой запрос от пользователя -func (redis *Redis) IdempotencyCheck(ctx context.Context, key string, limit int64, TTL time.Duration) error { - count, err := redis.client.Incr(ctx, key).Result() - if err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) - return err - } - - if count == 1 { - redis.client.Expire(ctx, key, TTL) - } - - if count > limit { - redis.log.InfoContext(ctx, "получен запрос дубликат", "err", err) - return ErrDupRequest - } - - return nil -} - -// CheckRateLimit ограничивает запросы от пользователя -func (redis *Redis) CheckRateLimit(ctx context.Context, userID uuid.UUID) error { - if err := redis.checkWindow(ctx, userID, 5, time.Minute, "min"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) - return err - } - - if err := redis.checkWindow(ctx, userID, 60, time.Hour, "hour"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) - return err - } - - if err := redis.checkWindow(ctx, userID, 200, 24*time.Hour, "day"); err != nil { - redis.log.InfoContext(ctx, "увеличение счетчика окна", "err", err) - return err - } - - return nil -} - -func (redis *Redis) checkWindow(ctx context.Context, userID uuid.UUID, limit int64, window time.Duration, suffix string) error { - key := fmt.Sprintf("ratelimit:%s:%s", userID, suffix) - - count, err := redis.client.Incr(ctx, key).Result() - if err != nil { - return err - } - - if count == 1 { - redis.client.Expire(ctx, key, window) - } - - if count > limit { - return ErrRateLimitExceed - } - return nil -} +package cache + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +var ( + ErrRateLimitExceed = errors.New("превышен лимит запросов") + ErrDupRequest = errors.New("запрос дубликат") +) + +// Lua скрипт для sliding window rate limiting +// KEYS[1] - ключ для sorted set +// ARGV[1] - текущее время (timestamp) +// ARGV[2] - окно времени в секундах +// ARGV[3] - лимит запросов +// ARGV[4] - уникальный идентификатор запроса +var rateLimitScript = redis.NewScript( + ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local limit = tonumber(ARGV[3]) + local request_id = ARGV[4] + local min_time = now - window + redis.call('ZREMRANGEBYSCORE', key, '-inf', min_time) + + local current = redis.call('ZCARD', key) + + if current >= limit then + return 0 + end + redis.call('ZADD', key, now, request_id) + redis.call('EXPIRE', key, window + 10) + return 1 +`, +) + +type Redis struct { + client *redis.Client + rateLimitMin int64 + rateLimitHour int64 + rateLimitDay int64 +} + +type NewRedisOptions struct { + Addr string + RateLimitMin int64 + RateLimitHour int64 + RateLimitDay int64 +} + +func NewRedis(opts NewRedisOptions) *Redis { + c := redis.NewClient(&redis.Options{ + Addr: opts.Addr, + }) + return &Redis{ + client: c, + rateLimitMin: opts.RateLimitMin, + rateLimitHour: opts.RateLimitHour, + rateLimitDay: opts.RateLimitDay, + } +} + +// IdempotencyCheck добавляет идемпотентности операции, проверяет не был ли уже такой запрос от ключа +// Атомарно устанавливает флаг на TTL. Повторный вызов с тем же ключом вернет ErrDupRequest +func (redis *Redis) IdempotencyCheck(ctx context.Context, key string, TTL time.Duration) error { + set, err := redis.client.SetNX(ctx, key, 1, TTL).Result() + if err != nil { + return err + } + + if !set { + return ErrDupRequest + } + return nil +} + +// CheckRateLimit ограничивает запросы от пользователя +// принимает контекст и какой то id(user_id, ip, etc..) +func (redis *Redis) CheckRateLimit(ctx context.Context, id string) error { + if err := redis.checkWindow(ctx, id, redis.rateLimitMin, time.Minute, "min"); err != nil { + return err + } + + if err := redis.checkWindow(ctx, id, redis.rateLimitHour, time.Hour, "hour"); err != nil { + return err + } + + if err := redis.checkWindow(ctx, id, redis.rateLimitDay, 24*time.Hour, "day"); err != nil { + return err + } + + return nil +} + +func (redis *Redis) checkWindow(ctx context.Context, id string, limit int64, window time.Duration, suffix string) error { + key := fmt.Sprintf("ratelimit:%s:%s", id, suffix) + now := time.Now().Unix() + windowSeconds := int64(window.Seconds()) + requestID := fmt.Sprintf("%d-%d", now, time.Now().UnixNano()) + + result, err := rateLimitScript.Run(ctx, redis.client, []string{key}, now, windowSeconds, limit, requestID).Result() + if err != nil { + return fmt.Errorf("ошибка выполнения lua скрипта: %w", err) + } + + allowed, ok := result.(int64) + if !ok { + return fmt.Errorf("неожиданный тип результата из lua скрипта") + } + + if allowed == 0 { + return ErrRateLimitExceed + } + + return nil +} diff --git a/internal/infrastructure/cache/redis_test.go b/internal/infrastructure/cache/redis_test.go index 6e67ca3..e57fd9f 100644 --- a/internal/infrastructure/cache/redis_test.go +++ b/internal/infrastructure/cache/redis_test.go @@ -1,136 +1,129 @@ -package cache - -import ( - "context" - "io" - "log/slog" - "os" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/google/uuid" -) - -func TestIdempotencyCheck(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - - client := NewRedis(mr.Addr(), logger) - key := "somekey" - if err := client.IdempotencyCheck(context.Background(), key, 1, 24*time.Hour); err != nil { - t.Log(err) - return - } - t.Log("запрос уникальный") - if err := client.IdempotencyCheck(context.Background(), key, 1, 24*time.Hour); err != nil { - t.Log(err) - } -} - -func TestRedisMinutes(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - for range 5 { - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Minute) - t.Log("промотали время вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} - -func TestRedisHours(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - - for range 60 { - mr.FastForward(time.Minute) - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - t.Log("Отослали 60 запросов") - - t.Log("отслыаем еще один запрос") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Hour) - t.Log("промотали время на 1 час вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} - -func TestRedisDay(t *testing.T) { - file, err := os.OpenFile("redis_test.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - t.Fatal(err) - } - defer file.Close() - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - mr := miniredis.RunT(t) - client := NewRedis(mr.Addr(), logger) - userID, _ := uuid.NewUUID() - - for range 200 { - mr.FastForward(time.Minute) - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - } - t.Log("Отослали 200 запросов") - - t.Log("отслыаем еще один запрос") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(time.Hour) - t.Log("промотали время на 1 час вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - } - - mr.FastForward(24 * time.Hour) - t.Log("промотали время на 24 часа вперед") - if err := client.CheckRateLimit(context.Background(), userID); err != nil { - t.Log(err) - return - } - t.Log("успех") -} +package cache + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" +) + +func TestIdempotencyCheck(t *testing.T) { + mr := miniredis.RunT(t) + + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + key := "somekey" + if err := client.IdempotencyCheck(context.Background(), key, 24*time.Hour); err != nil { + t.Log(err) + return + } + t.Log("запрос уникальный") + if err := client.IdempotencyCheck(context.Background(), key, 24*time.Hour); err != nil { + t.Log(err) + } +} + +func TestRedisMinutes(t *testing.T) { + mr := miniredis.RunT(t) + + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + for range 5 { + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Minute) + t.Log("промотали время вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} + +func TestRedisHours(t *testing.T) { + mr := miniredis.RunT(t) + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + + for range 60 { + mr.FastForward(time.Minute) + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + t.Log("Отослали 60 запросов") + + t.Log("отслыаем еще один запрос") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Hour) + t.Log("промотали время на 1 час вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} + +func TestRedisDay(t *testing.T) { + mr := miniredis.RunT(t) + client := NewRedis(NewRedisOptions{ + Addr: mr.Addr(), + RateLimitMin: 5, + RateLimitHour: 60, + RateLimitDay: 200, + }) + userID, _ := uuid.NewUUID() + + for range 200 { + mr.FastForward(time.Minute) + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + } + t.Log("Отослали 200 запросов") + + t.Log("отслыаем еще один запрос") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(time.Hour) + t.Log("промотали время на 1 час вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + } + + mr.FastForward(24 * time.Hour) + t.Log("промотали время на 24 часа вперед") + if err := client.CheckRateLimit(context.Background(), userID.String()); err != nil { + t.Log(err) + return + } + t.Log("успех") +} diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go new file mode 100644 index 0000000..bd1b8f0 --- /dev/null +++ b/internal/infrastructure/config/config.go @@ -0,0 +1,96 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + Environment string + LogLevel string + Postgres PostgresConfig + Redis RedisConfig +} + +type PostgresConfig struct { + HOST string + PORT string + DBNAME string + USER string + PASSWORD string + SSLMODE string +} + +type RedisConfig struct { + HOST string + PORT string + USER string + PASSWORD string + RateLimitMin int64 + RateLimitHour int64 + RateLimitDay int64 +} + +func Load() (*Config, error) { + if err := godotenv.Load(); err != nil { + return nil, err + } + + cfg := &Config{ + Environment: getEnv("ENVIRONMENT", "development"), + LogLevel: getEnv("LOG_LEVEL", "info"), + } + + cfg.Postgres = PostgresConfig{ + HOST: getEnv("POSTGRES_HOST", "localhost"), + PORT: getEnv("POSTGRES_PORT", "5432"), + DBNAME: getEnv("POSTGRES_DB", "postgres_bd"), + USER: getEnv("POSTGRES_USER", "admin"), + PASSWORD: getEnv("POSTGRES_PASSWORD", "secret"), + SSLMODE: getEnv("POSTGRES_SSL", "disable"), + } + + cfg.Redis = RedisConfig{ + HOST: getEnv("REDIS_HOST", "localhost"), + PORT: getEnv("REDIS_PORT", "6379"), + USER: getEnv("REDIS_USER", ""), + PASSWORD: getEnv("REDIS_PASSWORD", ""), + RateLimitMin: getEnvAsInt("REDIS_RATE_LIMIT_MIN", 20), + RateLimitHour: getEnvAsInt("REDIS_RATE_LIMIT_HOUR", 100), + RateLimitDay: getEnvAsInt("REDIS_RATE_LIMIT_DAY", 500), + } + return cfg, nil +} + +func (c *PostgresConfig) PostgresDSN() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=%s", + c.USER, c.PASSWORD, c.HOST, c.PORT, c.DBNAME, c.SSLMODE, + ) +} + +func (c *RedisConfig) RedisDSN() string { + return fmt.Sprintf( + "%s:%s@%s:%s", + c.USER, c.PASSWORD, c.HOST, c.PORT, + ) +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int64) int64 { + if value := os.Getenv(key); value != "" { + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i + } + } + return defaultValue +} diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go new file mode 100644 index 0000000..6fd56eb --- /dev/null +++ b/internal/infrastructure/logger/logger.go @@ -0,0 +1,36 @@ +package logger + +import ( + "log/slog" + "os" +) + +func NewLogger(loglevel string, env string) (*slog.Logger, error) { + var level slog.Level + if err := level.UnmarshalText([]byte(loglevel)); err != nil { + level = slog.LevelInfo + } + + var logger *slog.Logger + switch env { + case "production": + logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + AddSource: true, + })) + default: + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + AddSource: true, + })) + } + + return logger, nil +} + +func WithService(logger *slog.Logger, service string) *slog.Logger { + if logger == nil { + return slog.Default().With("service", service) + } + return logger.With("service", service) +} diff --git a/internal/infrastructure/storage/helper.go b/internal/infrastructure/storage/helper.go index b7284c9..e4c878c 100644 --- a/internal/infrastructure/storage/helper.go +++ b/internal/infrastructure/storage/helper.go @@ -1,79 +1,81 @@ -package storage - -import ( - "context" - "fmt" - "log/slog" - "processing/internal/decimal" - "processing/internal/domain" - - "github.com/google/uuid" -) - -func sqlrequest(ctx context.Context, filter domain.TransactionFilter, log *slog.Logger) (string, []interface{}) { - query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE 1=1` - args := []interface{}{} - argCounter := 1 - - // Добавляем условия в зависимости от фильтров - if filter.SenderID != uuid.Nil { - query += fmt.Sprintf(" AND sender_id = $%d", argCounter) - args = append(args, filter.SenderID) - argCounter++ - } - - if filter.ReceiverID != uuid.Nil { - query += fmt.Sprintf(" AND receiver_id = $%d", argCounter) - args = append(args, filter.ReceiverID) - argCounter++ - } - - if filter.MinAmount != "" { - minAmount, err := decimal.NewFromString(filter.MinAmount) - if err != nil { - log.ErrorContext(ctx, "ошибка конвертации минимальной суммы", "error", err, "min_amount", filter.MinAmount) - return "", nil - } - query += fmt.Sprintf(" AND amount >= $%d", argCounter) - args = append(args, minAmount) - argCounter++ - } - - if filter.MaxAmount != "" { - maxAmount, err := decimal.NewFromString(filter.MaxAmount) - if err != nil { - log.ErrorContext(ctx, "ошибка конвертации максимальной суммы", "error", err, "max_amount", filter.MaxAmount) - return "", nil - } - query += fmt.Sprintf(" AND amount <= $%d", argCounter) - args = append(args, maxAmount) - argCounter++ - } - - if !filter.From.IsZero() { - query += fmt.Sprintf(" AND created_at >= $%d", argCounter) - args = append(args, filter.From) - argCounter++ - } - - if !filter.To.IsZero() { - query += fmt.Sprintf(" AND created_at <= $%d", argCounter) - args = append(args, filter.To) - argCounter++ - } - - query += " ORDER BY created_at DESC" - - if filter.Limit > 0 { - query += fmt.Sprintf(" LIMIT $%d", argCounter) - args = append(args, filter.Limit) - argCounter++ - } - - if filter.Offset > 0 { - query += fmt.Sprintf(" OFFSET $%d", argCounter) - args = append(args, filter.Offset) - } - - return query, args -} +package storage + +import ( + "context" + "fmt" + "processing/internal/decimal" + "processing/internal/domain" + + "github.com/google/uuid" +) + +func sqlrequest(ctx context.Context, filter domain.TransactionFilter) (string, []interface{}) { + query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE 1=1` + args := []interface{}{} + argCounter := 1 + + if filter.AccountID != uuid.Nil { + query += fmt.Sprintf(" AND(receiver_id = $%d OR sender_id = $%d)", argCounter, argCounter) + args = append(args, filter.AccountID) + argCounter++ + } + + if filter.SenderID != uuid.Nil { + query += fmt.Sprintf(" AND sender_id = $%d", argCounter) + args = append(args, filter.SenderID) + argCounter++ + } + + if filter.ReceiverID != uuid.Nil { + query += fmt.Sprintf(" AND receiver_id = $%d", argCounter) + args = append(args, filter.ReceiverID) + argCounter++ + } + + if filter.MinAmount != "" { + minAmount, err := decimal.NewFromString(filter.MinAmount) + if err != nil { + return "", nil + } + query += fmt.Sprintf(" AND amount >= $%d", argCounter) + args = append(args, minAmount) + argCounter++ + } + + if filter.MaxAmount != "" { + maxAmount, err := decimal.NewFromString(filter.MaxAmount) + if err != nil { + return "", nil + } + query += fmt.Sprintf(" AND amount <= $%d", argCounter) + args = append(args, maxAmount) + argCounter++ + } + + if !filter.From.IsZero() { + query += fmt.Sprintf(" AND created_at >= $%d", argCounter) + args = append(args, filter.From) + argCounter++ + } + + if !filter.To.IsZero() { + query += fmt.Sprintf(" AND created_at <= $%d", argCounter) + args = append(args, filter.To) + argCounter++ + } + + query += " ORDER BY created_at DESC" + + if filter.Limit > 0 { + query += fmt.Sprintf(" LIMIT $%d", argCounter) + args = append(args, filter.Limit) + argCounter++ + } + + if filter.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argCounter) + args = append(args, filter.Offset) + } + + return query, args +} diff --git a/internal/infrastructure/storage/storage.go b/internal/infrastructure/storage/storage.go index 543a8bd..7d44e30 100644 --- a/internal/infrastructure/storage/storage.go +++ b/internal/infrastructure/storage/storage.go @@ -1,233 +1,320 @@ -package storage - -import ( - "context" - "database/sql" - "errors" - "fmt" - "log/slog" - "processing/internal/decimal" - "processing/internal/domain" - - "github.com/google/uuid" -) - -type accountRepo struct { - tx *sql.Tx - log *slog.Logger -} -type txRepo struct { - tx *sql.Tx - log *slog.Logger -} - -// транзакция которую мы будем раздавать -type sqlTx struct { - tx *sql.Tx - accounts *accountRepo - txs *txRepo - log *slog.Logger -} - -func (u *sqlTx) Accounts() domain.AccountsStorage { return u.accounts } -func (u *sqlTx) Transactions() domain.TransactionStorage { return u.txs } - -func (u *sqlTx) Commit() error { - err := u.tx.Commit() - if err != nil { - u.log.Error("ошибка при коммите транзакции", "error", err) - return err - } - u.log.Debug("транзакция успешно закоммичена") - return nil -} - -func (u *sqlTx) Rollback() error { - err := u.tx.Rollback() - if err != nil { - u.log.Error("ошибка при откате транзакции", "error", err) - return err - } - u.log.Debug("транзакция успешно откачена") - return nil -} - -type uowFactory struct { - db *sql.DB - log *slog.Logger -} - -func NewUoWFactory(db *sql.DB, log *slog.Logger) domain.TxUOW { - return &uowFactory{db: db, log: log} -} - -// NewTX создает новую транзакцию базы данных -func (u *uowFactory) NewTX(ctx context.Context) (domain.UnitOfWork, error) { - tx, err := u.db.BeginTx(ctx, nil) - if err != nil { - u.log.ErrorContext(ctx, "не удалось начать транзакцию", "error", err) - return nil, fmt.Errorf("tx begin: %w", err) - } - u.log.DebugContext(ctx, "транзакция успешно создана") - return &sqlTx{ - tx: tx, - accounts: &accountRepo{tx: tx, log: u.log}, - txs: &txRepo{tx: tx, log: u.log}, - log: u.log, - }, nil -} - -// Create - создаёт аккаунт и возвращает ID -func (s *accountRepo) Create(ctx context.Context, ac *domain.Account) error { - query := `INSERT INTO accounts(id, name, balance) VALUES($1, $2, $3)` - s.log.DebugContext(ctx, "создание аккаунта", "account_id", ac.ID, "name", ac.Name, "balance", ac.Balance) - if _, err := s.tx.ExecContext(ctx, query, ac.ID, ac.Name, ac.Balance); err != nil { - s.log.ErrorContext(ctx, "ошибка создания аккаунта", "error", err, "account_id", ac.ID) - return fmt.Errorf("создание аккакунта: %w", err) - } - s.log.InfoContext(ctx, "аккаунт успешно создан", "account_id", ac.ID) - return nil -} - -// GetById - возвращает аккаунт по id -func (s *accountRepo) GetById(ctx context.Context, id uuid.UUID) (*domain.Account, error) { - s.log.DebugContext(ctx, "получение аккаунта по id", "account_id", id) - ac := &domain.Account{} - query := `SELECT id, name, balance FROM accounts WHERE id = $1` - err := s.tx.QueryRowContext(ctx, query, id).Scan(&ac.ID, &ac.Name, &ac.Balance) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.WarnContext(ctx, "аккаунт не найден", "account_id", id) - return nil, fmt.Errorf("аккаунт не найден или не создан: %w", err) - } - s.log.ErrorContext(ctx, "ошибка получения аккаунта", "error", err, "account_id", id) - return nil, fmt.Errorf("получение данных аккаунта по id: %w", err) - } - s.log.DebugContext(ctx, "аккаунт успешно получен", "account_id", id, "balance", ac.Balance) - return ac, nil -} - -// Sub - вычетает сумму с баланса аккаунта -func (s *accountRepo) Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error { - s.log.DebugContext(ctx, "вычет суммы с баланса", "account_id", sender_id, "amount", amount) - query := `UPDATE accounts SET balance = balance - $1 WHERE id = $2` - if _, err := s.tx.ExecContext(ctx, query, amount, sender_id); err != nil { - s.log.ErrorContext(ctx, "ошибка вычета суммы с баланса", "error", err, "account_id", sender_id, "amount", amount) - return fmt.Errorf("вычет суммы с баланса: %w", err) - } - s.log.InfoContext(ctx, "сумма успешно вычтена с баланса", "account_id", sender_id, "amount", amount) - return nil -} - -// Add - добавляет сумму на баланс аккаунта -func (s *accountRepo) Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error { - s.log.DebugContext(ctx, "добавление суммы на баланс", "account_id", receiver_id, "amount", amount) - query := `UPDATE accounts SET balance = balance + $1 WHERE id = $2` - if _, err := s.tx.ExecContext(ctx, query, amount, receiver_id); err != nil { - s.log.ErrorContext(ctx, "ошибка добавления суммы на баланс", "error", err, "account_id", receiver_id, "amount", amount) - return fmt.Errorf("добавление суммы на баланс: %w", err) - } - s.log.InfoContext(ctx, "сумма успешно добавлена на баланс", "account_id", receiver_id, "amount", amount) - return nil -} - -// Transaction создает транзакцию в бд -func (s *txRepo) Transaction(ctx context.Context, tx *domain.Transaction) error { - s.log.DebugContext(ctx, "создание транзакции", "transaction_id", tx.ID, "amount", tx.Amount, "sender_id", tx.Sender_id, "receiver_id", tx.Receiver_id) - query := ` - INSERT INTO transactions(id, amount, sender_id, receiver_id) VALUES($1, $2, $3, $4) - RETURNING status, created_at - ` - if err := s.tx.QueryRowContext(ctx, query, tx.ID, tx.Amount, tx.Sender_id, tx.Receiver_id). - Scan(&tx.Status, &tx.Created_at); err != nil { - s.log.ErrorContext(ctx, "ошибка создания транзакции", "error", err, "transaction_id", tx.ID) - return fmt.Errorf("создание транзакции: %w", err) - } - s.log.InfoContext(ctx, "транзакция успешно создана", "transaction_id", tx.ID, "status", tx.Status) - return nil -} - -// UpdateStatus обновляет статус транзакции в бд -func (s *txRepo) UpdateStatus(ctx context.Context, tx *domain.Transaction, status domain.TransactionStatus) error { - s.log.DebugContext(ctx, "обновление статуса транзакции", "transaction_id", tx.ID, "new_status", status) - query := ` - UPDATE transactions SET status = $1 WHERE id = $2 - RETURNING status, created_at - ` - if err := s.tx.QueryRowContext(ctx, query, status, tx.ID).Scan(&tx.Status, &tx.Created_at); err != nil { - s.log.ErrorContext(ctx, "ошибка обновления статуса транзакции", "error", err, "transaction_id", tx.ID, "status", status) - return fmt.Errorf("обновление статуса транзакции: %w", err) - } - s.log.InfoContext(ctx, "статус транзакции успешно обновлен", "transaction_id", tx.ID, "status", tx.Status) - return nil -} - -func (s *txRepo) GetByID(ctx context.Context, transactionID uuid.UUID) (domain.Transaction, error) { - s.log.DebugContext(ctx, "получение транзакции по id", "transaction_id", transactionID) - transaction := domain.Transaction{} - query := `SELECT amount, sender_id, receiver_id, status, created_at FROM transactions WHERE id = $1` - err := s.tx. - QueryRowContext(ctx, query, transactionID). - Scan( - &transaction.Amount, - &transaction.Sender_id, - &transaction.Receiver_id, - &transaction.Status, - &transaction.Created_at, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - s.log.WarnContext(ctx, "транзакция не найдена", "transaction_id", transactionID) - } else { - s.log.ErrorContext(ctx, "ошибка получения транзакции", "error", err, "transaction_id", transactionID) - } - return domain.Transaction{}, fmt.Errorf("получение транзакции по айди: %w", err) - } - s.log.DebugContext(ctx, "транзакция успешно получена", "transaction_id", transactionID, "status", transaction.Status) - return transaction, nil -} - -// GetTransactions получает транзакции по фильтрам -func (s *txRepo) GetTransactions(ctx context.Context, filter domain.TransactionFilter) ([]domain.Transaction, error) { - s.log.DebugContext(ctx, "получение транзакций по фильтрам", "filter", filter) - - query, args := sqlrequest(ctx, filter, s.log) - if query == "" { - s.log.Error("не получилось построить запрос") - return nil, errors.New("не получилось построить запрос") - } - s.log.DebugContext(ctx, "выполнение SQL запроса", "query", query, "args", args) - - rows, err := s.tx.QueryContext(ctx, query, args...) - if err != nil { - s.log.ErrorContext(ctx, "ошибка выполнения запроса", "error", err) - return nil, fmt.Errorf("получение транзакций по фильтрам: %w", err) - } - defer rows.Close() - - transactions := []domain.Transaction{} - for rows.Next() { - var t domain.Transaction - err := rows.Scan( - &t.ID, - &t.Amount, - &t.Sender_id, - &t.Receiver_id, - &t.Status, - &t.Created_at, - ) - if err != nil { - s.log.ErrorContext(ctx, "ошибка сканирования строки", "error", err) - return nil, fmt.Errorf("сканирование транзакции: %w", err) - } - transactions = append(transactions, t) - } - - if err = rows.Err(); err != nil { - s.log.ErrorContext(ctx, "ошибка при обработке строк", "error", err) - return nil, fmt.Errorf("обработка строк результата: %w", err) - } - - s.log.InfoContext(ctx, "транзакции успешно получены", "count", len(transactions)) - return transactions, nil -} +package storage + +import ( + "context" + "database/sql" + "errors" + "fmt" + "processing/internal/decimal" + "processing/internal/domain" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +type accountRepo struct { + tx *sql.Tx +} + +type txRepo struct { + tx *sql.Tx +} + +type txToken struct { + tx *sql.Tx +} + +// транзакция которую мы будем раздавать +type sqlTx struct { + tx *sql.Tx + accounts *accountRepo + token *txToken + txs *txRepo +} + +func (u *sqlTx) Accounts() domain.AccountsStorage { return u.accounts } +func (u *sqlTx) Transactions() domain.TransactionStorage { return u.txs } +func (u *sqlTx) Tokens() domain.TokenStorage { return u.token } + +func (u *sqlTx) Commit() error { + return u.tx.Commit() +} + +func (u *sqlTx) Rollback() error { + return u.tx.Rollback() +} + +type uowFactory struct { + db *sql.DB +} + +func NewUoWFactory(db *sql.DB) domain.TxUOW { + return &uowFactory{db: db} +} + +// NewTX создает новую транзакцию базы данных +func (u *uowFactory) NewTX(ctx context.Context) (domain.UnitOfWork, error) { + tx, err := u.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("tx begin: %w", err) + } + return &sqlTx{ + tx: tx, + accounts: &accountRepo{tx: tx}, + txs: &txRepo{tx: tx}, + token: &txToken{tx: tx}, + }, nil +} + +// Create - создаёт аккаунт и возвращает ID +func (s *accountRepo) Create(ctx context.Context, ac *domain.Account) error { + query := `INSERT INTO accounts(id, name, email, balance, password_hash, role) VALUES($1, $2, $3, $4, $5, $6)` + + if _, err := s.tx.ExecContext(ctx, query, ac.ID, ac.Name, ac.Email, ac.Balance, ac.PasswordHash, ac.Role); err != nil { + var pgerr *pgconn.PgError + if errors.As(err, &pgerr) && pgerr.Code == "23505" { + return domain.ErrAccountAlreadyExist + } + return fmt.Errorf("создание аккакунта: %w", err) + } + return nil +} + +// GetById - возвращает аккаунт по id +func (s *accountRepo) GetById(ctx context.Context, id uuid.UUID) (*domain.Account, error) { + ac := &domain.Account{} + query := `SELECT id, name, email, balance, password_hash, role FROM accounts WHERE id = $1` + err := s.tx.QueryRowContext(ctx, query, id).Scan(&ac.ID, &ac.Name, &ac.Email, &ac.Balance, &ac.PasswordHash, &ac.Role) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("аккаунт не найден или не создан: %w", err) + } + return nil, fmt.Errorf("получение данных аккаунта по id: %w", err) + } + return ac, nil +} + +// GetByEmail - возвращает аккаунт по email +func (s *accountRepo) GetByEmail(ctx context.Context, email string) (*domain.Account, error) { + ac := &domain.Account{} + query := `SELECT id, name, email, balance, password_hash, role FROM accounts WHERE email = $1` + err := s.tx.QueryRowContext(ctx, query, email).Scan(&ac.ID, &ac.Name, &ac.Email, &ac.Balance, &ac.PasswordHash, &ac.Role) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("аккаунт не найден: %w", err) + } + return nil, fmt.Errorf("получение данных аккаунта по email: %w", err) + } + return ac, nil +} + +// Sub - вычетает сумму с баланса аккаунта +func (s *accountRepo) Sub(ctx context.Context, sender_id uuid.UUID, amount decimal.Decimal) error { + query := ` + UPDATE accounts + SET balance = balance - $1 + WHERE id = $2 AND balance >= $1 + ` + res, err := s.tx.ExecContext(ctx, query, amount, sender_id) + if err != nil { + return fmt.Errorf("вычет суммы с баланса: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return domain.ErrInsufficientFunds + } + + return nil +} + +// Add - добавляет сумму на баланс аккаунта +func (s *accountRepo) Add(ctx context.Context, receiver_id uuid.UUID, amount decimal.Decimal) error { + query := `UPDATE accounts SET balance = balance + $1 WHERE id = $2` + res, err := s.tx.ExecContext(ctx, query, amount, receiver_id) + if err != nil { + return fmt.Errorf("добавление суммы на баланс: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrReceiverAccountNotFound + } + + return nil +} + +// Transaction создает транзакцию в бд +func (s *txRepo) Transaction(ctx context.Context, tx *domain.Transaction) error { + query := ` + INSERT INTO transactions(id, amount, sender_id, receiver_id) VALUES($1, $2, $3, $4) + RETURNING status, created_at + ` + if err := s.tx.QueryRowContext(ctx, query, tx.ID, tx.Amount, tx.Sender_id, tx.Receiver_id). + Scan(&tx.Status, &tx.Created_at); err != nil { + return fmt.Errorf("создание транзакции: %w", err) + } + return nil +} + +// UpdateStatus обновляет статус транзакции в бд +func (s *txRepo) UpdateStatus(ctx context.Context, tx *domain.Transaction, status domain.TransactionStatus) error { + query := ` + UPDATE transactions SET status = $1 WHERE id = $2 + RETURNING status, created_at + ` + if err := s.tx.QueryRowContext(ctx, query, status, tx.ID).Scan(&tx.Status, &tx.Created_at); err != nil { + return fmt.Errorf("обновление статуса транзакции: %w", err) + } + return nil +} + +func (s *txRepo) GetByID(ctx context.Context, transactionID uuid.UUID) (domain.Transaction, error) { + transaction := domain.Transaction{} + query := `SELECT id, amount, sender_id, receiver_id, status, created_at FROM transactions WHERE id = $1` + err := s.tx. + QueryRowContext(ctx, query, transactionID). + Scan( + &transaction.ID, + &transaction.Amount, + &transaction.Sender_id, + &transaction.Receiver_id, + &transaction.Status, + &transaction.Created_at, + ) + if err != nil { + return domain.Transaction{}, fmt.Errorf("получение транзакции по айди: %w", err) + } + return transaction, nil +} + +// GetTransactions получает транзакции по фильтрам +func (s *txRepo) GetTransactions(ctx context.Context, filter domain.TransactionFilter) ([]domain.Transaction, error) { + query, args := sqlrequest(ctx, filter) + if query == "" { + return nil, errors.New("не получилось построить запрос") + } + + rows, err := s.tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("получение транзакций по фильтрам: %w", err) + } + defer rows.Close() + + transactions := []domain.Transaction{} + for rows.Next() { + var t domain.Transaction + err := rows.Scan( + &t.ID, + &t.Amount, + &t.Sender_id, + &t.Receiver_id, + &t.Status, + &t.Created_at, + ) + if err != nil { + return nil, fmt.Errorf("сканирование транзакции: %w", err) + } + transactions = append(transactions, t) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("обработка строк результата: %w", err) + } + + return transactions, nil +} + +func (s *txRepo) TotalTransactions(ctx context.Context, userID uuid.UUID) (int, error) { + query := `SELECT COUNT(*) FROM transactions WHERE receiver_id=$1 OR sender_id=$1` + var count int + + if err := s.tx.QueryRowContext(ctx, query, userID).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} + +func (s *txToken) SaveRefreshToken(ctx context.Context, jti string, user_id string, expires_at time.Time) error { + query := `INSERT INTO refresh_token(jti, user_id, expires_at, revoked) VALUES($1, $2, $3, false)` + + res, err := s.tx.ExecContext(ctx, query, jti, user_id, expires_at) + if err != nil { + return domain.ErrSaveRefreshToken + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrSaveRefreshToken + } + + return nil +} + +func (s *txToken) GetRefreshToken(ctx context.Context, jti string) (*domain.RefreshSession, error) { + query := `SELECT user_id, revoked, expires_at FROM refresh_token WHERE jti = $1` + + session := &domain.RefreshSession{} + err := s.tx.QueryRowContext(ctx, query, jti).Scan(&session.UserID, &session.Revoked, &session.ExpiresAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrRefreshTokenNotFound + } + return nil, fmt.Errorf("получение refresh токена: %w", err) + } + + return session, nil +} + +func (s *txToken) RevokeRefreshToken(ctx context.Context, jti string) error { + query := `UPDATE refresh_token SET revoked = true WHERE jti = $1 AND revoked = false` + + res, err := s.tx.ExecContext(ctx, query, jti) + if err != nil { + return fmt.Errorf("отзыв refresh токена: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrRefreshTokenNotFound + } + + return nil +} + +func (s *txToken) RevokeAllUserTokens(ctx context.Context, userID uuid.UUID) error { + query := `UPDATE refresh_token SET revoked = true WHERE user_id = $1 AND revoked = false` + + res, err := s.tx.ExecContext(ctx, query, userID) + if err != nil { + return fmt.Errorf("отзыв всех токенов пользователя: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return domain.ErrUserNotFound + } + + return nil +} diff --git a/internal/infrastructure/storage/storage_test.go b/internal/infrastructure/storage/storage_test.go index 835eead..cf687e4 100644 --- a/internal/infrastructure/storage/storage_test.go +++ b/internal/infrastructure/storage/storage_test.go @@ -1,5 +1,5 @@ -package storage - -import ( - _ "github.com/jackc/pgx/v5/stdlib" -) +package storage + +import ( + _ "github.com/jackc/pgx/v5/stdlib" +) diff --git a/internal/usecase/accounts.go b/internal/usecase/accounts.go new file mode 100644 index 0000000..950037b --- /dev/null +++ b/internal/usecase/accounts.go @@ -0,0 +1,138 @@ +package usecase + +import ( + "context" + "errors" + "log/slog" + "processing/internal/domain" + "processing/internal/infrastructure/logger" + + "time" + + "github.com/google/uuid" +) + +var ( + ErrInvalidEmail = errors.New("invalid email") +) + +type AccountsService struct { + tx domain.TxUOW + cache domain.Cache + log *slog.Logger +} + +func NewAccountService(tx domain.TxUOW, cache domain.Cache, log *slog.Logger) *AccountsService { + log = logger.WithService(log, "Accounts") + return &AccountsService{ + tx: tx, + cache: cache, + log: log, + } +} + +// Create создает аккаунт +func (as *AccountsService) Create(ctx context.Context, acc *domain.Account, ip string) error { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при создании аккаунта", "ip", ip) + return err + } + + if err := as.cache.IdempotencyCheck(ctx, ip, time.Minute); err != nil { + as.log.WarnContext(ctx, "повторный запрос на создание аккаунта", "ip", ip) + return err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return err + } + defer uow.Rollback() + + if err := uow.Accounts().Create(ctx, acc); err != nil { + as.log.ErrorContext(ctx, "ошибка создания аккаунта в БД", "err", err, "email", acc.Email) + return err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return err + } + + as.log.InfoContext(ctx, "аккаунт успешно создан", "account_id", acc.ID, "email", acc.Email) + return nil +} + +// GetAccount получает аккаунт по id +func (as *AccountsService) GetAccount(ctx context.Context, id uuid.UUID) (*domain.Account, error) { + if err := as.cache.CheckRateLimit(ctx, id.String()); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов получения аккаунта", "account_id", id) + return nil, err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return nil, err + } + defer uow.Rollback() + + acc, err := uow.Accounts().GetById(ctx, id) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения аккаунта из БД", "err", err, "account_id", id) + return nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return nil, err + } + + as.log.InfoContext(ctx, "аккаунт успешно получен", "account_id", id) + return acc, nil +} + +// TransactionHistory выводит все транзакции пользователя +func (as *AccountsService) TransactionHistory(ctx context.Context, accountID uuid.UUID, limit, offset int) (int, []domain.Transaction, error) { + if err := as.cache.CheckRateLimit(ctx, accountID.String()); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов истории транзакций", "account_id", accountID) + return 0, nil, err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return 0, nil, err + } + defer uow.Rollback() + + var total int + + total, err = uow.Transactions().TotalTransactions(ctx, accountID) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения количества транзакций", "err", err, "account_id", accountID) + return 0, nil, err + } + + var transactions []domain.Transaction + filter := domain.TransactionFilter{ + AccountID: accountID, + Limit: limit, + Offset: offset, + } + + transactions, err = uow.Transactions().GetTransactions(ctx, filter) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения транзакций из БД", "err", err, "account_id", accountID, "limit", limit, "offset", offset) + return 0, nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return 0, nil, err + } + + as.log.InfoContext(ctx, "история транзакций получена", "account_id", accountID, "total", total, "returned", len(transactions)) + return total, transactions, nil +} diff --git a/internal/usecase/auth.go b/internal/usecase/auth.go new file mode 100644 index 0000000..bb1fa53 --- /dev/null +++ b/internal/usecase/auth.go @@ -0,0 +1,265 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + "log/slog" + "processing/internal/decimal" + jwtLayer "processing/internal/delivery/http/jwt" + "processing/internal/domain" + "processing/internal/infrastructure/logger" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type AuthService struct { + tx domain.TxUOW + cache domain.Cache + log *slog.Logger +} + +func NewAuthService(tx domain.TxUOW, cache domain.Cache, log *slog.Logger) *AuthService { + log = logger.WithService(log, "Auth") + return &AuthService{ + tx: tx, + cache: cache, + log: log, + } +} + +// Register регистрирует нового пользователя +func (as *AuthService) Register(ctx context.Context, email, password, name string, ip string) (*domain.Account, error) { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при регистрации", "ip", ip) + return nil, err + } + + idempotencyKey := ip + ":" + email + if err := as.cache.IdempotencyCheck(ctx, idempotencyKey, time.Minute); err != nil { + as.log.WarnContext(ctx, "повторный запрос на регистрацию", "ip", ip, "email", email) + return nil, err + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + as.log.ErrorContext(ctx, "ошибка хэширования пароля", "err", err) + return nil, fmt.Errorf("хэширование пароля: %w", err) + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return nil, err + } + defer uow.Rollback() + + account := &domain.Account{ + ID: uuid.New(), + Email: email, + Name: name, + PasswordHash: string(passwordHash), + Balance: decimal.Zero(), + Role: "user", + } + + if err := uow.Accounts().Create(ctx, account); err != nil { + as.log.ErrorContext(ctx, "ошибка создания аккаунта", "err", err, "email", email) + return nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка коммита транзакции", "err", err) + return nil, err + } + + as.log.InfoContext(ctx, "пользователь успешно зарегистрирован", "user_id", account.ID, "email", email) + return account, nil +} + +// Login аутентифицирует пользователя и возвращает пару токенов +func (as *AuthService) Login(ctx context.Context, email, password string, ip string) (*domain.TokenPair, error) { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при логине", "ip", ip) + return nil, err + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return nil, err + } + defer uow.Rollback() + + account, err := uow.Accounts().GetByEmail(ctx, email) + if err != nil { + as.log.WarnContext(ctx, "попытка входа с несуществующим email", "email", email) + return nil, domain.ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(password)); err != nil { + as.log.WarnContext(ctx, "неверный пароль при попытке входа", "user_id", account.ID, "email", email) + return nil, domain.ErrInvalidCredentials + } + + tokenPair, err := jwtLayer.GenerateTokenPair(account.ID.String(), account.Role) + if err != nil { + as.log.ErrorContext(ctx, "ошибка генерации токенов", "err", err, "user_id", account.ID) + return nil, fmt.Errorf("генерация токенов: %w", err) + } + + if err := uow.Tokens().SaveRefreshToken(ctx, tokenPair.JTI, account.ID.String(), tokenPair.ExpiresAt); err != nil { + as.log.ErrorContext(ctx, "ошибка сохранения refresh токена", "err", err, "user_id", account.ID) + return nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка транзакции", "err", err) + return nil, err + } + + as.log.InfoContext(ctx, "успешный вход пользователя", "user_id", account.ID, "email", email) + + return &domain.TokenPair{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + ExpiresIn: tokenPair.ExpiresIn, + }, nil +} + +// Refresh обновляет пару токенов используя refresh токен +func (as *AuthService) Refresh(ctx context.Context, refreshToken string, ip string) (*domain.TokenPair, error) { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при refresh", "ip", ip) + return nil, err + } + + claims, err := jwtLayer.ValidateRefreshToken(refreshToken) + if err != nil { + as.log.WarnContext(ctx, "невалидный refresh токен", "err", err) + return nil, domain.ErrInvalidRefreshToken + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return nil, err + } + defer uow.Rollback() + + session, err := uow.Tokens().GetRefreshToken(ctx, claims.ID) + if err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + as.log.WarnContext(ctx, "refresh токен не найден в БД", "jti", claims.ID) + return nil, domain.ErrInvalidRefreshToken + } + return nil, err + } + + if session.Revoked { + as.log.WarnContext(ctx, "попытка использования отозванного токена", "jti", claims.ID, "user_id", session.UserID) + return nil, domain.ErrRefreshTokenRevoked + } + + if time.Now().After(session.ExpiresAt) { + as.log.WarnContext(ctx, "истекший refresh токен", "jti", claims.ID, "user_id", session.UserID) + return nil, domain.ErrRefreshTokenExpired + } + + if err := uow.Tokens().RevokeRefreshToken(ctx, claims.ID); err != nil { + as.log.ErrorContext(ctx, "ошибка отзыва старого токена", "err", err, "jti", claims.ID) + return nil, err + } + + account, err := uow.Accounts().GetById(ctx, session.UserID) + if err != nil { + as.log.ErrorContext(ctx, "ошибка получения аккаунта", "err", err, "user_id", session.UserID) + return nil, err + } + + newTokenPair, err := jwtLayer.GenerateTokenPair(account.ID.String(), account.Role) + if err != nil { + as.log.ErrorContext(ctx, "ошибка генерации новых токенов", "err", err, "user_id", account.ID) + return nil, fmt.Errorf("генерация токенов: %w", err) + } + + if err := uow.Tokens().SaveRefreshToken(ctx, newTokenPair.JTI, account.ID.String(), newTokenPair.ExpiresAt); err != nil { + as.log.ErrorContext(ctx, "ошибка сохранения нового refresh токена", "err", err, "user_id", account.ID) + return nil, err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка транзакции", "err", err) + return nil, err + } + + as.log.InfoContext(ctx, "токены успешно обновлены", "user_id", account.ID, "old_jti", claims.ID, "new_jti", newTokenPair.JTI) + + return &domain.TokenPair{ + AccessToken: newTokenPair.AccessToken, + RefreshToken: newTokenPair.RefreshToken, + ExpiresIn: newTokenPair.ExpiresIn, + }, nil +} + +// Logout выходит из системы и отзывает refresh токен +func (as *AuthService) Logout(ctx context.Context, refreshToken string, ip string) error { + if err := as.cache.CheckRateLimit(ctx, ip); err != nil { + as.log.WarnContext(ctx, "превышен лимит запросов при logout", "ip", ip) + } + + claims, err := jwtLayer.ValidateRefreshToken(refreshToken) + if err != nil { + as.log.WarnContext(ctx, "невалидный токен при logout", "err", err) + return nil + } + + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return err + } + defer uow.Rollback() + + if err := uow.Tokens().RevokeRefreshToken(ctx, claims.ID); err != nil { + if errors.Is(err, domain.ErrRefreshTokenNotFound) { + as.log.WarnContext(ctx, "токен уже удален или не существует", "jti", claims.ID) + return nil + } + as.log.ErrorContext(ctx, "ошибка отзыва токена при logout", "err", err, "jti", claims.ID) + return err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка транзакции", "err", err) + return err + } + + as.log.InfoContext(ctx, "пользователь вышел из системы", "jti", claims.ID, "user_id", claims.UserID) + return nil +} + +// LogoutAll отзывает все токены +func (as *AuthService) LogoutAll(ctx context.Context, userID uuid.UUID) error { + uow, err := as.tx.NewTX(ctx) + if err != nil { + as.log.ErrorContext(ctx, "ошибка создания транзакции", "err", err) + return err + } + defer uow.Rollback() + + if err := uow.Tokens().RevokeAllUserTokens(ctx, userID); err != nil { + as.log.ErrorContext(ctx, "ошибка отзыва всех токенов пользователя", "err", err, "user_id", userID) + return err + } + + if err := uow.Commit(); err != nil { + as.log.ErrorContext(ctx, "ошибка транзакции", "err", err) + return err + } + + as.log.InfoContext(ctx, "все токены пользователя отозваны", "user_id", userID) + return nil +} diff --git a/internal/usecase/transactions.go b/internal/usecase/transactions.go index 56bed9a..3bd6738 100644 --- a/internal/usecase/transactions.go +++ b/internal/usecase/transactions.go @@ -1,177 +1,181 @@ -package usecase - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "processing/internal/decimal" - "processing/internal/domain" - "time" - - "github.com/google/uuid" -) - -type TransferService struct { - tx domain.TxUOW - cache domain.Cache - log *slog.Logger -} - -func NewService(tx domain.TxUOW, cache domain.Cache, loggerPath string) *TransferService { - file, err := os.OpenFile(loggerPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) - if err != nil { - panic(err) - } - logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, file), nil)) - slog.SetDefault(logger) - slog.Info("создан логгер") - return &TransferService{ - tx: tx, - cache: cache, - log: logger, - } -} - -// Transfer - главная функция процессинга. Создает транзакцию. -// Как работает: вычет с балансов аккаунтов -> создание транзакции -// принимает контекст, ключ для redis, sender_id, receiver_id, amount -func (ts *TransferService) Transfer( - ctx context.Context, - sender_id, receiver_id uuid.UUID, - key string, - amount decimal.Decimal, -) error { - if err := ts.cache.CheckRateLimit(ctx, sender_id); err != nil { - ts.log.Error("CheckRateLimit", "err", err) - return err - } - //проверка идемпотентности запроса - if err := ts.cache.IdempotencyCheck(ctx, key, 1, 24*time.Hour); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) - return err - } - //новая транзакция - uow, err := ts.tx.NewTX(ctx) - if err != nil { - ts.log.Error("NewTX", "err", err) - return err - } - - defer uow.Rollback() - - //получаем пользователей по айди валидации - sender, err := uow.Accounts().GetById(ctx, sender_id) - if err != nil { - ts.log.Error("Accounts.GetById", "err", err) - return err - } - receiver, err := uow.Accounts().GetById(ctx, receiver_id) - if err != nil { - ts.log.Error("Account.GetById", "err", err) - return err - } - //валидация - if err := domain.ValidateTransferRequest(sender.ID, receiver.ID, sender.Balance, amount); err != nil { - return err - } - //сначала вычитаем сумму с баланса отправителя - if err := uow.Accounts().Sub(ctx, sender_id, amount); err != nil { - ts.log.Error("DB substituion", "err", err) - return err - } - //затем прибавляем сумму на баланс получателя - if err := uow.Accounts().Add(ctx, receiver_id, amount); err != nil { - ts.log.Error("DB Amount add", "err", err) - return err - } - - //создание транзакции - tx, err := domain.NewTransaction(amount, sender_id, receiver_id) - if err != nil { - ts.log.Error("creating domain.Transaction", "err", err) - return err - } - - if err := uow.Transactions().Transaction(ctx, tx); err != nil { - ts.log.Error("DB transaction creating", "err", err) - return err - } - - if err := uow.Transactions().UpdateStatus(ctx, tx, domain.StatusCompleted); err != nil { - ts.log.Error("update status", "err", err) - return err - } - - return uow.Commit() -} - -func (ts *TransferService) GetTransaction( - ctx context.Context, - transactionID, - userID uuid.UUID, - key string, -) (domain.Transaction, error) { - if err := ts.cache.CheckRateLimit(ctx, userID); err != nil { - ts.log.Error("CheckRateLimit", "err", err) - return domain.Transaction{}, err - } - - if err := ts.cache.IdempotencyCheck(ctx, key, 10, time.Minute); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) - return domain.Transaction{}, err - } - uow, err := ts.tx.NewTX(ctx) - if err != nil { - ts.log.Error("NewTX", "err", err) - return domain.Transaction{}, fmt.Errorf("ошибка начала транзакции бд: %w", err) - } - defer uow.Rollback() - - transaction, err := uow.Transactions().GetByID(ctx, transactionID) - if err != nil { - ts.log.Error("Transactions.GetByID", "err", err) - return domain.Transaction{}, fmt.Errorf("ошибка получения транзакции из бд: %w", err) - } - - uow.Commit() - return transaction, nil -} - -func (ts *TransferService) GetTransactionFilter( - ctx context.Context, - t *domain.TransactionFilter, - userID uuid.UUID, - key string, -) ([]domain.Transaction, error) { - if err := ts.cache.CheckRateLimit(ctx, userID); err != nil { - ts.log.Error("CheckRateLimit", "err", err) - return nil, err - } - - if err := ts.cache.IdempotencyCheck(ctx, key, 10, time.Minute); err != nil { - ts.log.Error("IdempotencyCheck", "err", err) - return nil, err - } - - uow, err := ts.tx.NewTX(ctx) - if err != nil { - ts.log.Error("NewTX", "err", err) - return nil, fmt.Errorf("ошибка начала транзакции бд: %w", err) - } - defer uow.Rollback() - - transactions, err := uow.Transactions().GetTransactions(ctx, *t) - if err != nil { - ts.log.Error("Transactions.GetTransactions", "err", err) - return nil, fmt.Errorf("ошибка получения транзакций из бд: %w", err) - } - - if err := uow.Commit(); err != nil { - ts.log.Error("Commit", "err", err) - return nil, fmt.Errorf("ошибка коммита транзакции: %w", err) - } - - return transactions, nil -} +package usecase + +import ( + "context" + "log/slog" + "processing/internal/decimal" + "processing/internal/domain" + "processing/internal/infrastructure/logger" + "time" + + "github.com/google/uuid" +) + +type TransactionsService struct { + tx domain.TxUOW + cache domain.Cache + log *slog.Logger +} + +func NewTransactionsService(txUOW domain.TxUOW, cache domain.Cache, log *slog.Logger) *TransactionsService { + log = logger.WithService(log, "Transactions") + return &TransactionsService{ + tx: txUOW, + cache: cache, + log: log, + } +} + +// Transfer - главная функция процессинга. Создает транзакцию. +// Как работает: вычет с балансов аккаунтов -> создание транзакции +// принимает контекст, sender_id, receiver_id, ключ для redis, amount +func (ts *TransactionsService) Transfer( + ctx context.Context, + sender_id, receiver_id uuid.UUID, + key string, + amount decimal.Decimal, +) (string, error) { + if err := ts.cache.CheckRateLimit(ctx, sender_id.String()); err != nil { + ts.log.WarnContext(ctx, "превышен лимит запросов при переводе", "sender_id", sender_id) + return "", err + } + + if err := ts.cache.IdempotencyCheck(ctx, key, 24*time.Hour); err != nil { + ts.log.WarnContext(ctx, "повторный запрос на перевод", "sender_id", sender_id, "receiver_id", receiver_id) + return "", err + } + + uow, err := ts.tx.NewTX(ctx) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return "", err + } + + defer uow.Rollback() + + sender, err := uow.Accounts().GetById(ctx, sender_id) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка получения аккаунта отправителя", "err", err, "sender_id", sender_id) + return "", err + } + receiver, err := uow.Accounts().GetById(ctx, receiver_id) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка получения аккаунта получателя", "err", err, "receiver_id", receiver_id) + return "", err + } + + if sender.ID == receiver.ID { + ts.log.WarnContext(ctx, "попытка перевода на собственный счет", "sender_id", sender_id) + return "", domain.ErrSameAccount + } + + if !amount.IsPositive() { + ts.log.WarnContext(ctx, "попытка перевода отрицательной суммы", "sender_id", sender_id, "amount", amount) + return "", domain.ErrInvalidAmount + } + + if err := uow.Accounts().Sub(ctx, sender_id, amount); err != nil { + ts.log.ErrorContext(ctx, "ошибка вычисления суммы со счета отправителя", "err", err, "sender_id", sender_id, "amount", amount) + return "", err + } + + if err := uow.Accounts().Add(ctx, receiver_id, amount); err != nil { + ts.log.ErrorContext(ctx, "ошибка добавления суммы на счет получателя", "err", err, "receiver_id", receiver_id, "amount", amount) + return "", err + } + + tx, err := domain.NewTransaction(amount, sender_id, receiver_id) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка создания объекта транзакции", "err", err, "sender_id", sender_id, "receiver_id", receiver_id) + return "", err + } + + if err := uow.Transactions().Transaction(ctx, tx); err != nil { + ts.log.ErrorContext(ctx, "ошибка сохранения транзакции в БД", "err", err, "transaction_id", tx.ID) + return "", err + } + + if err := uow.Transactions().UpdateStatus(ctx, tx, domain.StatusCompleted); err != nil { + ts.log.ErrorContext(ctx, "ошибка обновления статуса транзакции", "err", err, "transaction_id", tx.ID) + return "", err + } + + if err := uow.Commit(); err != nil { + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err, "transaction_id", tx.ID) + return "", err + } + + ts.log.InfoContext(ctx, "транзакция успешно завершена", "transaction_id", tx.ID, "sender_id", sender_id, "receiver_id", receiver_id, "amount", amount) + return tx.ID.String(), nil +} + +// GetTransaction +func (ts *TransactionsService) GetTransaction( + ctx context.Context, + transactionID, + userID uuid.UUID, + key string, +) (domain.Transaction, error) { + if err := ts.cache.CheckRateLimit(ctx, userID.String()); err != nil { + ts.log.WarnContext(ctx, "превышен лимит запросов при получении транзакции", "user_id", userID) + return domain.Transaction{}, err + } + + uow, err := ts.tx.NewTX(ctx) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return domain.Transaction{}, err + } + defer uow.Rollback() + + transaction, err := uow.Transactions().GetByID(ctx, transactionID) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка получения транзакции из БД", "err", err, "transaction_id", transactionID) + return domain.Transaction{}, err + } + + if transaction.Sender_id != userID && transaction.Receiver_id != userID { + ts.log.WarnContext(ctx, "попытка доступа к чужой транзакции", "user_id", userID, "transaction_id", transactionID) + return domain.Transaction{}, domain.ErrAccessDenied + } + + if err := uow.Commit(); err != nil { + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err) + return domain.Transaction{}, err + } + + return transaction, nil +} + +func (ts *TransactionsService) GetTransactionFilter( + ctx context.Context, + t *domain.TransactionFilter, + userID uuid.UUID, + key string, +) ([]domain.Transaction, error) { + if err := ts.cache.CheckRateLimit(ctx, userID.String()); err != nil { + ts.log.WarnContext(ctx, "превышен лимит запросов при фильтрации транзакций", "user_id", userID) + return nil, err + } + + uow, err := ts.tx.NewTX(ctx) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка создания транзакции БД", "err", err) + return nil, err + } + defer uow.Rollback() + + transactions, err := uow.Transactions().GetTransactions(ctx, *t) + if err != nil { + ts.log.ErrorContext(ctx, "ошибка получения транзакций из БД", "err", err, "account_id", t.AccountID) + return nil, err + } + + if err := uow.Commit(); err != nil { + ts.log.ErrorContext(ctx, "ошибка коммита транзакции БД", "err", err) + return nil, err + } + + ts.log.InfoContext(ctx, "транзакции по фильтру получены", "user_id", userID, "count", len(transactions)) + return transactions, nil +} diff --git a/makefile b/makefile index b2fe163..1621e72 100644 --- a/makefile +++ b/makefile @@ -1,16 +1,16 @@ -DB_URL = postgres://admin:secret@localhost:5432/postgres_bd - -docker-up: - docker compose up -d - -docker-down: - docker compose down - -migrate-up: - goose -dir migrations postgres $(DB_URL) up - -migrate-down: - goose -dir migrations postgres $(DB_URL) down - -run: +DB_URL = postgres://admin:secret@localhost:5432/postgres_bd + +docker-up: + docker compose up -d + +docker-down: + docker compose down + +migrate-up: + goose -dir migrations postgres $(DB_URL) up + +migrate-down: + goose -dir migrations postgres $(DB_URL) down + +run: go run cmd/main.go \ No newline at end of file diff --git a/migrations/00001_processing.sql b/migrations/00001_processing.sql new file mode 100644 index 0000000..42eb988 --- /dev/null +++ b/migrations/00001_processing.sql @@ -0,0 +1,32 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + balance NUMERIC(36, 18) NOT NULL DEFAULT 0, + CONSTRAINT balance_is_positive CHECK (balance >= 0) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY, + amount NUMERIC(36, 18) NOT NULL, + sender_id UUID NOT NULL REFERENCES accounts(id), + receiver_id UUID NOT NULL REFERENCES accounts(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'completed', 'failed')), + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS refresh_token ( + jti TEXT PRIMARY KEY NOT NULL, + user_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + revoked BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE IF EXISTS refresh_token; +DROP TABLE IF EXISTS transactions; +DROP TABLE IF EXISTS accounts; diff --git a/migrations/00001_transaction.sql b/migrations/00001_transaction.sql deleted file mode 100644 index c1f4826..0000000 --- a/migrations/00001_transaction.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name TEXT NOT NULL, - balance NUMERIC(36, 18) NOT NULL -); - -CREATE TABLE IF NOT EXISTS transactions ( - id UUID PRIMARY KEY, - amount NUMERIC(36, 18) NOT NULL, - sender_id UUID NOT NULL REFERENCES accounts(id), - receiver_id UUID NOT NULL REFERENCES accounts(id), - status VARCHAR(20) NOT NULL DEFAULT 'pending', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - - --- +goose Down -DROP TABLE IF EXISTS transactions; -DROP TABLE IF EXISTS accounts; diff --git a/photo_2026-06-06_10-22-39.jpg b/photo_2026-06-06_10-22-39.jpg deleted file mode 100644 index 8c32825..0000000 Binary files a/photo_2026-06-06_10-22-39.jpg and /dev/null differ