From b91e67ac1dd54946f20f8c6962431cf65f2b8d73 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:05:08 +0000 Subject: [PATCH 01/11] ci: add Gitea Actions workflows and placeholder tests --- .gitea/workflows/ci.yml | 24 ++++++ .gitea/workflows/deploy-prod.yml | 21 ++++++ cmd/api/main.go | 4 + internal/handler/interest.go | 58 +++++++++++++++ internal/health/health_test.go | 10 +++ internal/service/interest.go | 123 +++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/deploy-prod.yml create mode 100644 internal/handler/interest.go create mode 100644 internal/health/health_test.go create mode 100644 internal/service/interest.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..73a0fe5 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [dev] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -v + + - name: Build + run: CGO_ENABLED=0 go build -o main ./cmd/api diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..6327d52 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,21 @@ +name: Deploy Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build + run: CGO_ENABLED=0 go build -o main ./cmd/api + + - name: Deploy + run: echo "Production deploy via docker" diff --git a/cmd/api/main.go b/cmd/api/main.go index dcbd487..b8d73ea 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -71,6 +71,7 @@ func main() { profileHandler := handler.NewProfileHandler(userRepo) habitFreezeHandler := handler.NewHabitFreezeHandler(habitFreezeRepo, habitRepo) savingsHandler := handler.NewSavingsHandler(savingsRepo) + interestHandler := handler.NewInterestHandler(db) // Initialize middleware authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) @@ -103,6 +104,9 @@ func main() { r.Post("/auth/forgot-password", authHandler.ForgotPassword) r.Post("/auth/reset-password", authHandler.ResetPassword) + // Internal routes (API key protected) + interestHandler.RegisterRoutes(r) + // Protected routes r.Group(func(r chi.Router) { r.Use(authMiddleware.Authenticate) diff --git a/internal/handler/interest.go b/internal/handler/interest.go new file mode 100644 index 0000000..6a06ef5 --- /dev/null +++ b/internal/handler/interest.go @@ -0,0 +1,58 @@ +package handler + +import ( + "encoding/json" + "net/http" + "os" + + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" + + "github.com/daniil/homelab-api/internal/service" +) + +type InterestHandler struct { + service *service.InterestService + secretKey string +} + +func NewInterestHandler(db *sqlx.DB) *InterestHandler { + secretKey := os.Getenv("INTERNAL_API_KEY") + if secretKey == "" { + secretKey = "pulse-internal-2026" + } + return &InterestHandler{ + service: service.NewInterestService(db), + secretKey: secretKey, + } +} + +// RegisterRoutes регистрирует internal routes +func (h *InterestHandler) RegisterRoutes(r chi.Router) { + r.Post("/internal/calculate-interest", h.CalculateInterest) +} + +// CalculateInterest запускает расчёт процентов для всех вкладов +// POST /internal/calculate-interest +// Header: X-Internal-Key: +func (h *InterestHandler) CalculateInterest(w http.ResponseWriter, r *http.Request) { + // Verify internal API key + key := r.Header.Get("X-Internal-Key") + if key != h.secretKey { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + results, err := h.service.CalculateAllDepositsInterest() + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Interest calculation completed", + "results": results, + }) +} diff --git a/internal/health/health_test.go b/internal/health/health_test.go new file mode 100644 index 0000000..7aecb47 --- /dev/null +++ b/internal/health/health_test.go @@ -0,0 +1,10 @@ +package health + +import "testing" + +func TestHealthCheck(t *testing.T) { + status := "ok" + if status != "ok" { + t.Errorf("expected ok, got %s", status) + } +} diff --git a/internal/service/interest.go b/internal/service/interest.go new file mode 100644 index 0000000..89bb385 --- /dev/null +++ b/internal/service/interest.go @@ -0,0 +1,123 @@ +package service + +import ( + "fmt" + "log" + "math" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" +) + +type InterestService struct { + db *sqlx.DB +} + +func NewInterestService(db *sqlx.DB) *InterestService { + return &InterestService{db: db} +} + +// CalculateAllDepositsInterest проверяет все вклады и начисляет проценты где нужно +func (s *InterestService) CalculateAllDepositsInterest() ([]string, error) { + var results []string + + // Получаем все активные вклады + var deposits []model.SavingsCategory + err := s.db.Select(&deposits, ` + SELECT * FROM savings_categories + WHERE is_deposit = true AND interest_rate > 0 + `) + if err != nil { + return nil, fmt.Errorf("failed to fetch deposits: %w", err) + } + + log.Printf("Found %d deposits to check", len(deposits)) + + for _, deposit := range deposits { + result, err := s.CalculateInterestForDeposit(&deposit) + if err != nil { + log.Printf("Error calculating interest for %s: %v", deposit.Name, err) + results = append(results, fmt.Sprintf("❌ %s: %v", deposit.Name, err)) + continue + } + if result != "" { + results = append(results, result) + } + } + + return results, nil +} + +// CalculateInterestForDeposit рассчитывает проценты для одного вклада +func (s *InterestService) CalculateInterestForDeposit(deposit *model.SavingsCategory) (string, error) { + if !deposit.IsDeposit || deposit.InterestRate <= 0 { + return "", nil + } + + if !deposit.DepositStartDate.Valid { + return "", fmt.Errorf("no start date") + } + + now := time.Now() + startDate := deposit.DepositStartDate.Time + + // Проверяем не истёк ли срок вклада + if deposit.DepositTerm > 0 { + endDate := startDate.AddDate(0, deposit.DepositTerm, 0) + if now.After(endDate) { + log.Printf("Deposit %s expired on %v", deposit.Name, endDate) + return "", nil + } + } + + // День начисления = день открытия вклада + interestDay := startDate.Day() + + // Сегодня день начисления? + if now.Day() != interestDay { + return "", nil + } + + // Проверяем не начислены ли уже проценты за этот месяц + currentMonth := fmt.Sprintf("%02d.%d", now.Month(), now.Year()) + searchPattern := "%" + currentMonth + "%" + var count int + err := s.db.Get(&count, `SELECT COUNT(*) FROM savings_transactions WHERE category_id = $1 AND description LIKE $2`, deposit.ID, searchPattern) + if err != nil { + return "", err + } + if count > 0 { + log.Printf("Interest for %s already calculated for %s", deposit.Name, currentMonth) + return "", nil + } + + // Получаем текущий баланс + var balance float64 + err = s.db.Get(&balance, `SELECT COALESCE(SUM(CASE WHEN type = 'deposit' THEN amount ELSE -amount END), 0) FROM savings_transactions WHERE category_id = $1`, deposit.ID) + if err != nil { + return "", err + } + + // Рассчитываем проценты (годовая ставка / 12) + monthlyRate := deposit.InterestRate / 12 / 100 + interest := balance * monthlyRate + interest = math.Round(interest*100) / 100 + + if interest <= 0 { + return "", nil + } + + // Добавляем транзакцию + description := fmt.Sprintf("Проценты за %s (%.2f%% годовых)", currentMonth, deposit.InterestRate) + + _, err = s.db.Exec(`INSERT INTO savings_transactions (user_id, category_id, type, amount, date, description) VALUES ($1, $2, 'deposit', $3, $4, $5)`, deposit.UserID, deposit.ID, interest, now.Format("2006-01-02"), description) + if err != nil { + return "", err + } + + result := fmt.Sprintf("✅ %s: +%.2f₽ (баланс: %.2f₽)", deposit.Name, interest, balance+interest) + log.Printf(result) + + return result, nil +} From b544a8c9a3c2bfb3ed3e21b99367d09c45c2ac4f Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:12:00 +0000 Subject: [PATCH 02/11] ci: fix network --- .gitea/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 73a0fe5..a53578e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -22,3 +22,4 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api +# CI From 8811a9078bd45b2f36548bc1ca0a0e0baa47d620 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:14:23 +0000 Subject: [PATCH 03/11] ci: add go mod tidy step --- .gitea/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a53578e..26cf36e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: with: go-version: '1.22' + - name: Tidy + run: go mod tidy + - name: Vet run: go vet ./... @@ -22,4 +25,3 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api -# CI From 76d12f362a6051778e8c770ab4aa0a7efa99dcc7 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:21:35 +0000 Subject: [PATCH 04/11] docs: add readme --- README.md | 1 + docker-compose.dev.yml | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 README.md create mode 100644 docker-compose.dev.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..1523fcd --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Pulse API diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..00f36cc --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +networks: + proxy: + external: true + name: services_proxy + internal: + driver: bridge + +services: + api-dev: + build: . + container_name: pulse-api-dev + restart: always + ports: + - "8081:8080" + environment: + - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab?sslmode=disable + - JWT_SECRET=${JWT_SECRET} + - PORT=8080 + - RESEND_API_KEY=${RESEND_API_KEY} + - FROM_EMAIL=${FROM_EMAIL:-noreply@digital-home.site} + - FROM_NAME=${FROM_NAME:-Homelab} + - APP_URL=${APP_URL:-https://api.digital-home.site} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + networks: + - proxy + - internal From d4bb0bdfb92c9bf4f07e15571fa5d6f186d5bd78 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:23:04 +0000 Subject: [PATCH 05/11] test: webhook trigger --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1523fcd..b63c2e0 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ # Pulse API + From edbd565ad3c41fcd7aabaa55158122212ffc53f6 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:23:40 +0000 Subject: [PATCH 06/11] test: webhook 2 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b63c2e0..dfd6739 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # Pulse API +# test From 13b4435c457e464256eedfc6f8824461e5a7e116 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:25:53 +0000 Subject: [PATCH 07/11] ci: add deploy trigger via curl --- .gitea/workflows/ci.yml | 6 ++++++ .gitea/workflows/deploy-prod.yml | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 26cf36e..d64a1d5 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,3 +25,9 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api + + - name: Trigger Deploy + run: | + curl -s -X POST http://172.18.0.1:9000/deploy \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/dev","repository":{"name":"pulse-api"}}' diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 6327d52..8c3b5e4 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -17,5 +17,8 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api - - name: Deploy - run: echo "Production deploy via docker" + - name: Trigger Deploy + run: | + curl -s -X POST http://172.18.0.1:9000/deploy \ + -H 'Content-Type: application/json' \ + -d '{"ref":"refs/heads/main","repository":{"name":"pulse-api"}}' From 2b4a6ce4c85e16886dcbf8fe1837fec261889cb7 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 00:30:40 +0000 Subject: [PATCH 08/11] ci: clean workflows (deploy via cron) --- .gitea/workflows/ci.yml | 6 ------ .gitea/workflows/deploy-prod.yml | 6 ------ 2 files changed, 12 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d64a1d5..26cf36e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -25,9 +25,3 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api - - - name: Trigger Deploy - run: | - curl -s -X POST http://172.18.0.1:9000/deploy \ - -H 'Content-Type: application/json' \ - -d '{"ref":"refs/heads/dev","repository":{"name":"pulse-api"}}' diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index 8c3b5e4..8c17f1d 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -16,9 +16,3 @@ jobs: - name: Build run: CGO_ENABLED=0 go build -o main ./cmd/api - - - name: Trigger Deploy - run: | - curl -s -X POST http://172.18.0.1:9000/deploy \ - -H 'Content-Type: application/json' \ - -d '{"ref":"refs/heads/main","repository":{"name":"pulse-api"}}' From 8d9fe818f456f786f91f5f178e5d73b4e30ea0bb Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 02:32:59 +0000 Subject: [PATCH 09/11] Add unit tests for middleware, models, services, handlers, and repository helpers --- go.sum | 2 + internal/handler/handler_test.go | 114 +++++++++++++++++++ internal/middleware/auth_test.go | 165 ++++++++++++++++++++++++++++ internal/model/habit_test.go | 52 +++++++++ internal/model/savings_test.go | 61 ++++++++++ internal/model/task_test.go | 66 +++++++++++ internal/model/user_test.go | 46 ++++++++ internal/repository/helpers_test.go | 96 ++++++++++++++++ internal/service/auth_test.go | 97 ++++++++++++++++ internal/service/helpers_test.go | 35 ++++++ internal/service/interest_test.go | 66 +++++++++++ 11 files changed, 800 insertions(+) create mode 100644 internal/handler/handler_test.go create mode 100644 internal/middleware/auth_test.go create mode 100644 internal/model/habit_test.go create mode 100644 internal/model/savings_test.go create mode 100644 internal/model/task_test.go create mode 100644 internal/model/user_test.go create mode 100644 internal/repository/helpers_test.go create mode 100644 internal/service/auth_test.go create mode 100644 internal/service/helpers_test.go create mode 100644 internal/service/interest_test.go diff --git a/go.sum b/go.sum index 86f3590..a160070 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= @@ -13,5 +14,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 0000000..f20486a --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,114 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthHandler(t *testing.T) { + h := NewHealthHandler() + req := httptest.NewRequest("GET", "/health", nil) + rr := httptest.NewRecorder() + h.Health(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + + var resp map[string]string + json.NewDecoder(rr.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Errorf("expected status ok, got %s", resp["status"]) + } + if resp["service"] != "homelab-api" { + t.Errorf("expected service homelab-api, got %s", resp["service"]) + } +} + +func TestWriteJSON(t *testing.T) { + rr := httptest.NewRecorder() + data := map[string]string{"hello": "world"} + writeJSON(rr, data, http.StatusCreated) + + if rr.Code != http.StatusCreated { + t.Errorf("expected 201, got %d", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + + var resp map[string]string + json.NewDecoder(rr.Body).Decode(&resp) + if resp["hello"] != "world" { + t.Errorf("expected world, got %s", resp["hello"]) + } +} + +func TestWriteError(t *testing.T) { + rr := httptest.NewRecorder() + writeError(rr, "something went wrong", http.StatusBadRequest) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } + + var resp map[string]string + json.NewDecoder(rr.Body).Decode(&resp) + if resp["error"] != "something went wrong" { + t.Errorf("expected 'something went wrong', got %s", resp["error"]) + } +} + +func TestInterestHandler_Unauthorized(t *testing.T) { + h := &InterestHandler{secretKey: "my-secret"} + + t.Run("missing key", func(t *testing.T) { + req := httptest.NewRequest("POST", "/internal/calculate-interest", nil) + rr := httptest.NewRecorder() + h.CalculateInterest(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } + }) + + t.Run("wrong key", func(t *testing.T) { + req := httptest.NewRequest("POST", "/internal/calculate-interest", nil) + req.Header.Set("X-Internal-Key", "wrong-key") + rr := httptest.NewRecorder() + h.CalculateInterest(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } + }) +} + +// Test request validation (without real service, just checking decoding) +func TestDecodeInvalidJSON(t *testing.T) { + tests := []struct { + name string + body string + handler http.HandlerFunc + }{ + {"invalid json", "{bad", func(w http.ResponseWriter, r *http.Request) { + var req struct{ Email string } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/test", bytes.NewBufferString(tt.body)) + rr := httptest.NewRecorder() + tt.handler(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } + }) + } +} diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..fb8e681 --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,165 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "test-secret-key" + +func generateTestToken(userID int64, tokenType string, secret string, expiry time.Duration) string { + claims := jwt.MapClaims{ + "user_id": userID, + "type": tokenType, + "exp": time.Now().Add(expiry).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, _ := token.SignedString([]byte(secret)) + return s +} + +func TestAuthMiddleware_ValidToken(t *testing.T) { + m := NewAuthMiddleware(testSecret) + token := generateTestToken(42, "access", testSecret, 15*time.Minute) + + var capturedUserID int64 + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUserID = GetUserID(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + if capturedUserID != 42 { + t.Errorf("expected userID 42, got %d", capturedUserID) + } +} + +func TestAuthMiddleware_MissingHeader(t *testing.T) { + m := NewAuthMiddleware(testSecret) + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuthMiddleware_InvalidFormat(t *testing.T) { + m := NewAuthMiddleware(testSecret) + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + tests := []struct { + name string + header string + }{ + {"no bearer prefix", "Token abc123"}, + {"only bearer", "Bearer"}, + {"three parts", "Bearer token extra"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", tt.header) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } + }) + } +} + +func TestAuthMiddleware_ExpiredToken(t *testing.T) { + m := NewAuthMiddleware(testSecret) + token := generateTestToken(1, "access", testSecret, -1*time.Hour) + + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuthMiddleware_WrongSecret(t *testing.T) { + m := NewAuthMiddleware(testSecret) + token := generateTestToken(1, "access", "wrong-secret", 15*time.Minute) + + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuthMiddleware_RefreshTokenRejected(t *testing.T) { + m := NewAuthMiddleware(testSecret) + token := generateTestToken(1, "refresh", testSecret, 15*time.Minute) + + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for refresh token, got %d", rr.Code) + } +} + +func TestGetUserID_NoContext(t *testing.T) { + req := httptest.NewRequest("GET", "/test", nil) + userID := GetUserID(req.Context()) + if userID != 0 { + t.Errorf("expected 0 for missing context, got %d", userID) + } +} + +func TestAuthMiddleware_InvalidSigningMethod(t *testing.T) { + m := NewAuthMiddleware(testSecret) + // Create a token with none algorithm (should be rejected) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": float64(1), + "type": "access", + "exp": time.Now().Add(15 * time.Minute).Unix(), + }) + // Tamper with the token + s, _ := token.SignedString([]byte(testSecret)) + tampered := s + "tampered" + + handler := m.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tampered)) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} diff --git a/internal/model/habit_test.go b/internal/model/habit_test.go new file mode 100644 index 0000000..3abf8b3 --- /dev/null +++ b/internal/model/habit_test.go @@ -0,0 +1,52 @@ +package model + +import ( + "database/sql" + "testing" + "time" +) + +func TestHabit_ProcessForJSON(t *testing.T) { + t.Run("with reminder time RFC3339 format", func(t *testing.T) { + h := &Habit{ + ReminderTime: sql.NullString{String: "0000-01-01T19:00:00Z", Valid: true}, + StartDate: sql.NullTime{Time: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), Valid: true}, + } + h.ProcessForJSON() + + // Note: ProcessForJSON returns early after parsing RFC3339, so StartDate is NOT processed + if h.ReminderTimeStr == nil || *h.ReminderTimeStr != "19:00" { + t.Errorf("expected 19:00, got %v", h.ReminderTimeStr) + } + }) + + t.Run("with reminder time HH:MM:SS format and start date", func(t *testing.T) { + h := &Habit{ + ReminderTime: sql.NullString{String: "08:30:00", Valid: true}, + StartDate: sql.NullTime{Time: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), Valid: true}, + } + h.ProcessForJSON() + + if h.ReminderTimeStr == nil || *h.ReminderTimeStr != "08:30" { + t.Errorf("expected 08:30, got %v", h.ReminderTimeStr) + } + if h.StartDateStr == nil || *h.StartDateStr != "2025-01-15" { + t.Errorf("expected 2025-01-15, got %v", h.StartDateStr) + } + }) + + t.Run("without reminder time", func(t *testing.T) { + h := &Habit{ + ReminderTime: sql.NullString{Valid: false}, + StartDate: sql.NullTime{Valid: false}, + } + h.ProcessForJSON() + + if h.ReminderTimeStr != nil { + t.Error("reminder_time should be nil") + } + if h.StartDateStr != nil { + t.Error("start_date should be nil") + } + }) +} diff --git a/internal/model/savings_test.go b/internal/model/savings_test.go new file mode 100644 index 0000000..abe400f --- /dev/null +++ b/internal/model/savings_test.go @@ -0,0 +1,61 @@ +package model + +import ( + "database/sql" + "testing" + "time" +) + +func TestSavingsCategory_ProcessForJSON(t *testing.T) { + t.Run("with deposit dates", func(t *testing.T) { + c := &SavingsCategory{ + DepositStartDate: sql.NullTime{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}, + DepositEndDate: sql.NullTime{Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}, + CreditStartDate: sql.NullTime{Time: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), Valid: true}, + } + c.ProcessForJSON() + + if c.DepositStartStr == nil || *c.DepositStartStr != "2025-01-01" { + t.Errorf("expected 2025-01-01, got %v", c.DepositStartStr) + } + if c.DepositEndStr == nil || *c.DepositEndStr != "2026-01-01" { + t.Errorf("expected 2026-01-01, got %v", c.DepositEndStr) + } + if c.CreditStartStr == nil || *c.CreditStartStr != "2025-06-01" { + t.Errorf("expected 2025-06-01, got %v", c.CreditStartStr) + } + }) + + t.Run("without dates", func(t *testing.T) { + c := &SavingsCategory{} + c.ProcessForJSON() + + if c.DepositStartStr != nil { + t.Error("expected nil deposit_start_date") + } + }) +} + +func TestSavingsRecurringPlan_ProcessForJSON(t *testing.T) { + t.Run("with user_id", func(t *testing.T) { + p := &SavingsRecurringPlan{ + UserID: sql.NullInt64{Int64: 42, Valid: true}, + } + p.ProcessForJSON() + + if p.UserIDPtr == nil || *p.UserIDPtr != 42 { + t.Errorf("expected 42, got %v", p.UserIDPtr) + } + }) + + t.Run("without user_id", func(t *testing.T) { + p := &SavingsRecurringPlan{ + UserID: sql.NullInt64{Valid: false}, + } + p.ProcessForJSON() + + if p.UserIDPtr != nil { + t.Error("expected nil user_id") + } + }) +} diff --git a/internal/model/task_test.go b/internal/model/task_test.go new file mode 100644 index 0000000..b2aa01d --- /dev/null +++ b/internal/model/task_test.go @@ -0,0 +1,66 @@ +package model + +import ( + "database/sql" + "testing" + "time" +) + +func TestTask_ProcessForJSON(t *testing.T) { + t.Run("task with HH:MM:SS reminder and all fields", func(t *testing.T) { + task := &Task{ + DueDate: sql.NullTime{Time: time.Date(2025, 3, 15, 0, 0, 0, 0, time.UTC), Valid: true}, + ReminderTime: sql.NullString{String: "14:30:00", Valid: true}, + CompletedAt: sql.NullTime{Time: time.Now(), Valid: true}, + RecurrenceType: sql.NullString{String: "weekly", Valid: true}, + RecurrenceEndDate: sql.NullTime{Time: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), Valid: true}, + ParentTaskID: sql.NullInt64{Int64: 5, Valid: true}, + } + task.ProcessForJSON() + + if task.DueDateStr == nil || *task.DueDateStr != "2025-03-15" { + t.Errorf("expected due_date 2025-03-15, got %v", task.DueDateStr) + } + if task.ReminderTimeStr == nil || *task.ReminderTimeStr != "14:30" { + t.Errorf("expected reminder 14:30, got %v", task.ReminderTimeStr) + } + if !task.Completed { + t.Error("expected completed to be true") + } + if task.RecurrenceTypeStr == nil || *task.RecurrenceTypeStr != "weekly" { + t.Errorf("expected recurrence_type weekly, got %v", task.RecurrenceTypeStr) + } + if task.RecurrenceEndStr == nil || *task.RecurrenceEndStr != "2025-12-31" { + t.Errorf("expected recurrence_end 2025-12-31, got %v", task.RecurrenceEndStr) + } + if task.ParentTaskIDPtr == nil || *task.ParentTaskIDPtr != 5 { + t.Errorf("expected parent_task_id 5, got %v", task.ParentTaskIDPtr) + } + }) + + t.Run("task with RFC3339 reminder", func(t *testing.T) { + task := &Task{ + ReminderTime: sql.NullString{String: "0000-01-01T09:00:00Z", Valid: true}, + } + task.ProcessForJSON() + + if task.ReminderTimeStr == nil || *task.ReminderTimeStr != "09:00" { + t.Errorf("expected 09:00, got %v", task.ReminderTimeStr) + } + }) + + t.Run("incomplete task with null fields", func(t *testing.T) { + task := &Task{ + DueDate: sql.NullTime{Valid: false}, + CompletedAt: sql.NullTime{Valid: false}, + } + task.ProcessForJSON() + + if task.DueDateStr != nil { + t.Error("expected due_date nil") + } + if task.Completed { + t.Error("expected completed to be false") + } + }) +} diff --git a/internal/model/user_test.go b/internal/model/user_test.go new file mode 100644 index 0000000..730e3d5 --- /dev/null +++ b/internal/model/user_test.go @@ -0,0 +1,46 @@ +package model + +import ( + "database/sql" + "testing" +) + +func TestUser_ProcessForJSON(t *testing.T) { + t.Run("with telegram chat id", func(t *testing.T) { + u := &User{ + TelegramChatID: sql.NullInt64{Int64: 123456, Valid: true}, + MorningReminderTime: sql.NullString{String: "09:00:00", Valid: true}, + EveningReminderTime: sql.NullString{String: "21:30:00", Valid: true}, + } + u.ProcessForJSON() + + if u.TelegramChatIDValue == nil || *u.TelegramChatIDValue != 123456 { + t.Error("telegram_chat_id not set correctly") + } + if u.MorningTime != "09:00" { + t.Errorf("expected 09:00, got %s", u.MorningTime) + } + if u.EveningTime != "21:30" { + t.Errorf("expected 21:30, got %s", u.EveningTime) + } + }) + + t.Run("without telegram chat id", func(t *testing.T) { + u := &User{ + TelegramChatID: sql.NullInt64{Valid: false}, + MorningReminderTime: sql.NullString{Valid: false}, + EveningReminderTime: sql.NullString{Valid: false}, + } + u.ProcessForJSON() + + if u.TelegramChatIDValue != nil { + t.Error("telegram_chat_id should be nil") + } + if u.MorningTime != "09:00" { + t.Errorf("expected default 09:00, got %s", u.MorningTime) + } + if u.EveningTime != "21:00" { + t.Errorf("expected default 21:00, got %s", u.EveningTime) + } + }) +} diff --git a/internal/repository/helpers_test.go b/internal/repository/helpers_test.go new file mode 100644 index 0000000..f239b0f --- /dev/null +++ b/internal/repository/helpers_test.go @@ -0,0 +1,96 @@ +package repository + +import ( + "testing" + "time" + + "github.com/daniil/homelab-api/internal/model" +) + +func TestHabitFreezeRepository_CountFrozenDaysLogic(t *testing.T) { + // Test the overlap calculation logic that CountFrozenDaysInRange uses + tests := []struct { + name string + freezeStart, freezeEnd time.Time + queryStart, queryEnd time.Time + wantDays int + }{ + { + name: "full overlap", + freezeStart: time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), + freezeEnd: time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC), + queryStart: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + queryEnd: time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC), + wantDays: 6, + }, + { + name: "partial overlap start", + freezeStart: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + freezeEnd: time.Date(2025, 1, 10, 0, 0, 0, 0, time.UTC), + queryStart: time.Date(2025, 1, 5, 0, 0, 0, 0, time.UTC), + queryEnd: time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC), + wantDays: 6, + }, + { + name: "partial overlap end", + freezeStart: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC), + freezeEnd: time.Date(2025, 2, 5, 0, 0, 0, 0, time.UTC), + queryStart: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + queryEnd: time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC), + wantDays: 12, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + freeze := model.HabitFreeze{ + StartDate: tt.freezeStart, + EndDate: tt.freezeEnd, + } + + overlapStart := freeze.StartDate + if tt.queryStart.After(freeze.StartDate) { + overlapStart = tt.queryStart + } + overlapEnd := freeze.EndDate + if tt.queryEnd.Before(freeze.EndDate) { + overlapEnd = tt.queryEnd + } + + days := 0 + if !overlapEnd.Before(overlapStart) { + days = int(overlapEnd.Sub(overlapStart).Hours()/24) + 1 + } + + if days != tt.wantDays { + t.Errorf("got %d frozen days, want %d", days, tt.wantDays) + } + }) + } +} + +func TestJoinStrings(t *testing.T) { + tests := []struct { + input []string + sep string + want string + }{ + {nil, ", ", ""}, + {[]string{"a"}, ", ", "a"}, + {[]string{"a", "b", "c"}, ", ", "a, b, c"}, + {[]string{"x", "y"}, " AND ", "x AND y"}, + } + + for _, tt := range tests { + got := joinStrings(tt.input, tt.sep) + if got != tt.want { + t.Errorf("joinStrings(%v, %q) = %q, want %q", tt.input, tt.sep, got, tt.want) + } + } +} + +func TestIsUniqueViolation(t *testing.T) { + if isUniqueViolation(nil) { + t.Error("nil error should not be unique violation") + } +} diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go new file mode 100644 index 0000000..ed4770f --- /dev/null +++ b/internal/service/auth_test.go @@ -0,0 +1,97 @@ +package service + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestAuthService_GenerateAndValidateToken(t *testing.T) { + s := &AuthService{jwtSecret: "test-secret"} + + t.Run("valid access token", func(t *testing.T) { + tokenStr, err := s.generateToken(1, "access", 15*time.Minute) + if err != nil { + t.Fatalf("generateToken error: %v", err) + } + + claims, err := s.validateToken(tokenStr, "access") + if err != nil { + t.Fatalf("validateToken error: %v", err) + } + + userID, ok := claims["user_id"].(float64) + if !ok || int64(userID) != 1 { + t.Errorf("expected user_id 1, got %v", claims["user_id"]) + } + }) + + t.Run("wrong token type rejected", func(t *testing.T) { + tokenStr, _ := s.generateToken(1, "refresh", time.Hour) + _, err := s.validateToken(tokenStr, "access") + if err != ErrInvalidToken { + t.Errorf("expected ErrInvalidToken, got %v", err) + } + }) + + t.Run("expired token rejected", func(t *testing.T) { + tokenStr, _ := s.generateToken(1, "access", -time.Hour) + _, err := s.validateToken(tokenStr, "access") + if err != ErrInvalidToken { + t.Errorf("expected ErrInvalidToken, got %v", err) + } + }) + + t.Run("wrong secret rejected", func(t *testing.T) { + otherService := &AuthService{jwtSecret: "other-secret"} + tokenStr, _ := otherService.generateToken(1, "access", time.Hour) + _, err := s.validateToken(tokenStr, "access") + if err != ErrInvalidToken { + t.Errorf("expected ErrInvalidToken, got %v", err) + } + }) + + t.Run("tampered token rejected", func(t *testing.T) { + tokenStr, _ := s.generateToken(1, "access", time.Hour) + _, err := s.validateToken(tokenStr+"x", "access") + if err != ErrInvalidToken { + t.Errorf("expected ErrInvalidToken, got %v", err) + } + }) + + t.Run("HMAC signing method accepted", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": float64(1), + "type": "access", + "exp": time.Now().Add(time.Hour).Unix(), + }) + tokenStr, _ := token.SignedString([]byte("test-secret")) + + claims, err := s.validateToken(tokenStr, "access") + if err != nil { + t.Fatalf("should accept HS256: %v", err) + } + if claims["type"] != "access" { + t.Error("claims type mismatch") + } + }) +} + +func TestErrWeakPassword(t *testing.T) { + if ErrWeakPassword.Error() != "password must be at least 8 characters" { + t.Errorf("unexpected error message: %s", ErrWeakPassword.Error()) + } +} + +func TestErrInvalidCredentials(t *testing.T) { + if ErrInvalidCredentials.Error() != "invalid credentials" { + t.Errorf("unexpected error message: %s", ErrInvalidCredentials.Error()) + } +} + +func TestErrEmailNotVerified(t *testing.T) { + if ErrEmailNotVerified.Error() != "email not verified" { + t.Errorf("unexpected error message: %s", ErrEmailNotVerified.Error()) + } +} diff --git a/internal/service/helpers_test.go b/internal/service/helpers_test.go new file mode 100644 index 0000000..9c8d2c3 --- /dev/null +++ b/internal/service/helpers_test.go @@ -0,0 +1,35 @@ +package service + +import "testing" + +func TestDefaultString(t *testing.T) { + tests := []struct { + val, def, want string + }{ + {"hello", "default", "hello"}, + {"", "default", "default"}, + {"", "", ""}, + } + for _, tt := range tests { + got := defaultString(tt.val, tt.def) + if got != tt.want { + t.Errorf("defaultString(%q, %q) = %q, want %q", tt.val, tt.def, got, tt.want) + } + } +} + +func TestDefaultInt(t *testing.T) { + tests := []struct { + val, def, want int + }{ + {5, 10, 5}, + {0, 10, 10}, + {0, 0, 0}, + } + for _, tt := range tests { + got := defaultInt(tt.val, tt.def) + if got != tt.want { + t.Errorf("defaultInt(%d, %d) = %d, want %d", tt.val, tt.def, got, tt.want) + } + } +} diff --git a/internal/service/interest_test.go b/internal/service/interest_test.go new file mode 100644 index 0000000..2708bef --- /dev/null +++ b/internal/service/interest_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "database/sql" + "testing" + "time" + + "github.com/daniil/homelab-api/internal/model" +) + +func TestCalculateInterestForDeposit_NotDeposit(t *testing.T) { + s := &InterestService{} + deposit := &model.SavingsCategory{IsDeposit: false} + result, err := s.CalculateInterestForDeposit(deposit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected empty result for non-deposit, got %q", result) + } +} + +func TestCalculateInterestForDeposit_ZeroRate(t *testing.T) { + s := &InterestService{} + deposit := &model.SavingsCategory{IsDeposit: true, InterestRate: 0} + result, err := s.CalculateInterestForDeposit(deposit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected empty result for zero rate, got %q", result) + } +} + +func TestCalculateInterestForDeposit_NoStartDate(t *testing.T) { + s := &InterestService{} + deposit := &model.SavingsCategory{ + IsDeposit: true, + InterestRate: 10, + DepositStartDate: sql.NullTime{Valid: false}, + } + _, err := s.CalculateInterestForDeposit(deposit) + if err == nil { + t.Error("expected error for missing start date") + } +} + +func TestCalculateInterestForDeposit_ExpiredDeposit(t *testing.T) { + s := &InterestService{} + deposit := &model.SavingsCategory{ + IsDeposit: true, + InterestRate: 10, + DepositTerm: 3, // 3 months + DepositStartDate: sql.NullTime{ + Time: time.Now().AddDate(0, -6, 0), // 6 months ago + Valid: true, + }, + } + result, err := s.CalculateInterestForDeposit(deposit) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "" { + t.Errorf("expected empty result for expired deposit, got %q", result) + } +} From 23939ccc92f9d192ad2dd30baf7ed6c031389ba1 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 04:22:10 +0000 Subject: [PATCH 10/11] feat: add finance module (categories, transactions, summary, analytics) - model/finance.go: FinanceCategory, FinanceTransaction, Summary, Analytics - repository/finance.go: CRUD + summary/analytics queries - service/finance.go: business logic with auto-seed default categories - handler/finance.go: REST endpoints with owner-only check (user_id=1) - db.go: finance_categories + finance_transactions migrations - main.go: register /finance/* routes Endpoints: GET/POST/PUT/DELETE /finance/categories, /finance/transactions GET /finance/summary, /finance/analytics --- cmd/api/main.go | 22 ++ docker-compose.dev.yml | 2 +- internal/handler/finance.go | 266 ++++++++++++++++++++++++ internal/model/finance.go | 114 +++++++++++ internal/repository/db.go | 37 ++++ internal/repository/finance.go | 363 +++++++++++++++++++++++++++++++++ internal/service/finance.go | 167 +++++++++++++++ 7 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 internal/handler/finance.go create mode 100644 internal/model/finance.go create mode 100644 internal/repository/finance.go create mode 100644 internal/service/finance.go diff --git a/cmd/api/main.go b/cmd/api/main.go index b8d73ea..e8ee11f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -33,6 +33,11 @@ func main() { log.Fatalf("Failed to run migrations: %v", err) } + // Run finance migrations + if err := repository.RunFinanceMigrations(db); err != nil { + log.Fatalf("Failed to run finance migrations: %v", err) + } + // Initialize repositories userRepo := repository.NewUserRepository(db) habitRepo := repository.NewHabitRepository(db) @@ -40,12 +45,14 @@ func main() { emailTokenRepo := repository.NewEmailTokenRepository(db) habitFreezeRepo := repository.NewHabitFreezeRepository(db) savingsRepo := repository.NewSavingsRepository(db) + financeRepo := repository.NewFinanceRepository(db) // Initialize services emailService := service.NewEmailService(cfg.ResendAPIKey, cfg.FromEmail, cfg.FromName, cfg.AppURL) authService := service.NewAuthService(userRepo, emailTokenRepo, emailService, cfg.JWTSecret) habitService := service.NewHabitService(habitRepo, habitFreezeRepo) taskService := service.NewTaskService(taskRepo) + financeService := service.NewFinanceService(financeRepo) // Initialize Telegram bot telegramBot, err := bot.New(cfg.TelegramBotToken, userRepo, taskRepo, habitRepo) @@ -72,6 +79,7 @@ func main() { habitFreezeHandler := handler.NewHabitFreezeHandler(habitFreezeRepo, habitRepo) savingsHandler := handler.NewSavingsHandler(savingsRepo) interestHandler := handler.NewInterestHandler(db) + financeHandler := handler.NewFinanceHandler(financeService) // Initialize middleware authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) @@ -178,6 +186,20 @@ func main() { // Savings stats r.Get("/savings/stats", savingsHandler.Stats) + + // Finance routes (owner-only, checked in handler) + r.Get("/finance/categories", financeHandler.ListCategories) + r.Post("/finance/categories", financeHandler.CreateCategory) + r.Put("/finance/categories/{id}", financeHandler.UpdateCategory) + r.Delete("/finance/categories/{id}", financeHandler.DeleteCategory) + + r.Get("/finance/transactions", financeHandler.ListTransactions) + r.Post("/finance/transactions", financeHandler.CreateTransaction) + r.Put("/finance/transactions/{id}", financeHandler.UpdateTransaction) + r.Delete("/finance/transactions/{id}", financeHandler.DeleteTransaction) + + r.Get("/finance/summary", financeHandler.Summary) + r.Get("/finance/analytics", financeHandler.Analytics) }) port := os.Getenv("PORT") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00f36cc..0d94744 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,7 +13,7 @@ services: ports: - "8081:8080" environment: - - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab?sslmode=disable + - DATABASE_URL=postgres://homelab:${DB_PASSWORD}@db:5432/homelab_dev?sslmode=disable - JWT_SECRET=${JWT_SECRET} - PORT=8080 - RESEND_API_KEY=${RESEND_API_KEY} diff --git a/internal/handler/finance.go b/internal/handler/finance.go new file mode 100644 index 0000000..32ca323 --- /dev/null +++ b/internal/handler/finance.go @@ -0,0 +1,266 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/service" +) + +const ownerUserID int64 = 1 + +type FinanceHandler struct { + svc *service.FinanceService +} + +func NewFinanceHandler(svc *service.FinanceService) *FinanceHandler { + return &FinanceHandler{svc: svc} +} + +func (h *FinanceHandler) checkOwner(w http.ResponseWriter, r *http.Request) (int64, bool) { + userID := middleware.GetUserID(r.Context()) + if userID != ownerUserID { + writeError(w, "forbidden", http.StatusForbidden) + return 0, false + } + return userID, true +} + +// Categories + +func (h *FinanceHandler) ListCategories(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + cats, err := h.svc.ListCategories(userID) + if err != nil { + writeError(w, "failed to fetch categories", http.StatusInternalServerError) + return + } + writeJSON(w, cats, http.StatusOK) +} + +func (h *FinanceHandler) CreateCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + var req model.CreateFinanceCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Name == "" || req.Type == "" { + writeError(w, "name and type are required", http.StatusBadRequest) + return + } + if req.Type != "expense" && req.Type != "income" { + writeError(w, "type must be expense or income", http.StatusBadRequest) + return + } + cat, err := h.svc.CreateCategory(userID, &req) + if err != nil { + writeError(w, "failed to create category", http.StatusInternalServerError) + return + } + writeJSON(w, cat, http.StatusCreated) +} + +func (h *FinanceHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var req model.UpdateFinanceCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + cat, err := h.svc.UpdateCategory(id, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrFinanceCategoryNotFound) { + writeError(w, "category not found", http.StatusNotFound) + return + } + writeError(w, "failed to update category", http.StatusInternalServerError) + return + } + writeJSON(w, cat, http.StatusOK) +} + +func (h *FinanceHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := h.svc.DeleteCategory(id, userID); err != nil { + if errors.Is(err, repository.ErrFinanceCategoryNotFound) { + writeError(w, "category not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete category", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Transactions + +func (h *FinanceHandler) ListTransactions(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + q := r.URL.Query() + month, _ := strconv.Atoi(q.Get("month")) + year, _ := strconv.Atoi(q.Get("year")) + var catID *int64 + if c := q.Get("category_id"); c != "" { + v, _ := strconv.ParseInt(c, 10, 64) + catID = &v + } + txType := q.Get("type") + search := q.Get("search") + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + + txs, err := h.svc.ListTransactions(userID, month, year, catID, txType, search, limit, offset) + if err != nil { + writeError(w, "failed to fetch transactions", http.StatusInternalServerError) + return + } + writeJSON(w, txs, http.StatusOK) +} + +func (h *FinanceHandler) CreateTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + var req model.CreateFinanceTransactionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + if req.Amount <= 0 { + writeError(w, "amount must be positive", http.StatusBadRequest) + return + } + if req.Type != "expense" && req.Type != "income" { + writeError(w, "type must be expense or income", http.StatusBadRequest) + return + } + tx, err := h.svc.CreateTransaction(userID, &req) + if err != nil { + writeError(w, "failed to create transaction", http.StatusInternalServerError) + return + } + writeJSON(w, tx, http.StatusCreated) +} + +func (h *FinanceHandler) UpdateTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + var req model.UpdateFinanceTransactionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + tx, err := h.svc.UpdateTransaction(id, userID, &req) + if err != nil { + if errors.Is(err, repository.ErrFinanceTransactionNotFound) { + writeError(w, "transaction not found", http.StatusNotFound) + return + } + writeError(w, "failed to update transaction", http.StatusInternalServerError) + return + } + writeJSON(w, tx, http.StatusOK) +} + +func (h *FinanceHandler) DeleteTransaction(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + writeError(w, "invalid id", http.StatusBadRequest) + return + } + if err := h.svc.DeleteTransaction(id, userID); err != nil { + if errors.Is(err, repository.ErrFinanceTransactionNotFound) { + writeError(w, "transaction not found", http.StatusNotFound) + return + } + writeError(w, "failed to delete transaction", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Summary & Analytics + +func (h *FinanceHandler) Summary(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + q := r.URL.Query() + month, _ := strconv.Atoi(q.Get("month")) + year, _ := strconv.Atoi(q.Get("year")) + if month == 0 || year == 0 { + now := time.Now() + month = int(now.Month()) + year = now.Year() + } + summary, err := h.svc.GetSummary(userID, month, year) + if err != nil { + writeError(w, "failed to get summary", http.StatusInternalServerError) + return + } + writeJSON(w, summary, http.StatusOK) +} + +func (h *FinanceHandler) Analytics(w http.ResponseWriter, r *http.Request) { + userID, ok := h.checkOwner(w, r) + if !ok { + return + } + months, _ := strconv.Atoi(r.URL.Query().Get("months")) + if months <= 0 { + months = 6 + } + analytics, err := h.svc.GetAnalytics(userID, months) + if err != nil { + writeError(w, "failed to get analytics", http.StatusInternalServerError) + return + } + writeJSON(w, analytics, http.StatusOK) +} diff --git a/internal/model/finance.go b/internal/model/finance.go new file mode 100644 index 0000000..4a6210e --- /dev/null +++ b/internal/model/finance.go @@ -0,0 +1,114 @@ +package model + +import ( + "database/sql" + "time" +) + +type FinanceCategory struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Emoji string `db:"emoji" json:"emoji"` + Type string `db:"type" json:"type"` + Budget sql.NullFloat64 `db:"budget" json:"-"` + BudgetVal *float64 `db:"-" json:"budget"` + Color string `db:"color" json:"color"` + SortOrder int `db:"sort_order" json:"sort_order"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +func (c *FinanceCategory) ProcessForJSON() { + if c.Budget.Valid { + c.BudgetVal = &c.Budget.Float64 + } +} + +type FinanceTransaction struct { + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + CategoryID int64 `db:"category_id" json:"category_id"` + Type string `db:"type" json:"type"` + Amount float64 `db:"amount" json:"amount"` + Description string `db:"description" json:"description"` + Date time.Time `db:"date" json:"date"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + // Joined fields + CategoryName string `db:"category_name" json:"category_name,omitempty"` + CategoryEmoji string `db:"category_emoji" json:"category_emoji,omitempty"` +} + +type CreateFinanceCategoryRequest struct { + Name string `json:"name"` + Emoji string `json:"emoji,omitempty"` + Type string `json:"type"` + Budget *float64 `json:"budget,omitempty"` + Color string `json:"color,omitempty"` + SortOrder int `json:"sort_order,omitempty"` +} + +type UpdateFinanceCategoryRequest struct { + Name *string `json:"name,omitempty"` + Emoji *string `json:"emoji,omitempty"` + Type *string `json:"type,omitempty"` + Budget *float64 `json:"budget,omitempty"` + Color *string `json:"color,omitempty"` + SortOrder *int `json:"sort_order,omitempty"` +} + +type CreateFinanceTransactionRequest struct { + CategoryID int64 `json:"category_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description,omitempty"` + Date string `json:"date"` +} + +type UpdateFinanceTransactionRequest struct { + CategoryID *int64 `json:"category_id,omitempty"` + Type *string `json:"type,omitempty"` + Amount *float64 `json:"amount,omitempty"` + Description *string `json:"description,omitempty"` + Date *string `json:"date,omitempty"` +} + +type FinanceSummary struct { + Balance float64 `json:"balance"` + TotalIncome float64 `json:"total_income"` + TotalExpense float64 `json:"total_expense"` + ByCategory []CategorySummary `json:"by_category"` + Daily []DailySummary `json:"daily"` +} + +type CategorySummary struct { + CategoryID int64 `json:"category_id" db:"category_id"` + CategoryName string `json:"category_name" db:"category_name"` + CategoryEmoji string `json:"category_emoji" db:"category_emoji"` + Type string `json:"type" db:"type"` + Amount float64 `json:"amount" db:"amount"` + Percentage float64 `json:"percentage"` + Budget *float64 `json:"budget,omitempty"` +} + +type DailySummary struct { + Date string `json:"date" db:"date"` + Amount float64 `json:"amount" db:"amount"` +} + +type FinanceAnalytics struct { + MonthlyTrend []MonthlyTrend `json:"monthly_trend"` + AvgDailyExpense float64 `json:"avg_daily_expense"` + ComparisonPrevMonth Comparison `json:"comparison_prev_month"` +} + +type MonthlyTrend struct { + Month string `json:"month" db:"month"` + Income float64 `json:"income" db:"income"` + Expense float64 `json:"expense" db:"expense"` +} + +type Comparison struct { + Current float64 `json:"current"` + Previous float64 `json:"previous"` + DiffPercent float64 `json:"diff_percent"` +} diff --git a/internal/repository/db.go b/internal/repository/db.go index 311baa0..92a87c8 100644 --- a/internal/repository/db.go +++ b/internal/repository/db.go @@ -127,3 +127,40 @@ func RunMigrations(db *sqlx.DB) error { return nil } + +func RunFinanceMigrations(db *sqlx.DB) error { + migrations := []string{ + `CREATE TABLE IF NOT EXISTS finance_categories ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + emoji VARCHAR(10) DEFAULT '', + type VARCHAR(10) NOT NULL, + budget DECIMAL(12,2), + color VARCHAR(7) DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS finance_transactions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES finance_categories(id) ON DELETE SET NULL, + type VARCHAR(10) NOT NULL, + amount DECIMAL(12,2) NOT NULL, + description TEXT DEFAULT '', + date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_finance_categories_user ON finance_categories(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_user ON finance_transactions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_date ON finance_transactions(date)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_category ON finance_transactions(category_id)`, + `CREATE INDEX IF NOT EXISTS idx_finance_transactions_user_date ON finance_transactions(user_id, date)`, + } + for _, m := range migrations { + if _, err := db.Exec(m); err != nil { + return err + } + } + return nil +} diff --git a/internal/repository/finance.go b/internal/repository/finance.go new file mode 100644 index 0000000..17bcc7a --- /dev/null +++ b/internal/repository/finance.go @@ -0,0 +1,363 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrFinanceCategoryNotFound = errors.New("finance category not found") +var ErrFinanceTransactionNotFound = errors.New("finance transaction not found") + +type FinanceRepository struct { + db *sqlx.DB +} + +func NewFinanceRepository(db *sqlx.DB) *FinanceRepository { + return &FinanceRepository{db: db} +} + +// --- Categories --- + +func (r *FinanceRepository) CreateCategory(cat *model.FinanceCategory) error { + query := `INSERT INTO finance_categories (user_id, name, emoji, type, budget, color, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at` + return r.db.QueryRow(query, cat.UserID, cat.Name, cat.Emoji, cat.Type, cat.Budget, cat.Color, cat.SortOrder). + Scan(&cat.ID, &cat.CreatedAt) +} + +func (r *FinanceRepository) ListCategories(userID int64) ([]model.FinanceCategory, error) { + query := `SELECT id, user_id, name, emoji, type, budget, color, sort_order, created_at + FROM finance_categories WHERE user_id = $1 ORDER BY sort_order, id` + rows, err := r.db.Query(query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var cats []model.FinanceCategory + for rows.Next() { + var c model.FinanceCategory + if err := rows.Scan(&c.ID, &c.UserID, &c.Name, &c.Emoji, &c.Type, &c.Budget, &c.Color, &c.SortOrder, &c.CreatedAt); err != nil { + return nil, err + } + c.ProcessForJSON() + cats = append(cats, c) + } + return cats, nil +} + +func (r *FinanceRepository) GetCategory(id, userID int64) (*model.FinanceCategory, error) { + var c model.FinanceCategory + query := `SELECT id, user_id, name, emoji, type, budget, color, sort_order, created_at + FROM finance_categories WHERE id = $1 AND user_id = $2` + err := r.db.QueryRow(query, id, userID).Scan(&c.ID, &c.UserID, &c.Name, &c.Emoji, &c.Type, &c.Budget, &c.Color, &c.SortOrder, &c.CreatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrFinanceCategoryNotFound + } + return nil, err + } + c.ProcessForJSON() + return &c, nil +} + +func (r *FinanceRepository) UpdateCategory(cat *model.FinanceCategory) error { + query := `UPDATE finance_categories SET name=$2, emoji=$3, type=$4, budget=$5, color=$6, sort_order=$7 + WHERE id=$1 AND user_id=$8` + result, err := r.db.Exec(query, cat.ID, cat.Name, cat.Emoji, cat.Type, cat.Budget, cat.Color, cat.SortOrder, cat.UserID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrFinanceCategoryNotFound + } + return nil +} + +func (r *FinanceRepository) DeleteCategory(id, userID int64) error { + result, err := r.db.Exec(`DELETE FROM finance_categories WHERE id=$1 AND user_id=$2`, id, userID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrFinanceCategoryNotFound + } + return nil +} + +func (r *FinanceRepository) SeedDefaultCategories(userID int64) error { + type cat struct { + emoji, name, typ, color string + } + defaults := []cat{ + {"🏠", "Жильё", "expense", "#ef4444"}, + {"🍔", "Еда", "expense", "#f97316"}, + {"🚗", "Транспорт", "expense", "#6366f1"}, + {"👕", "Одежда", "expense", "#8b5cf6"}, + {"🏥", "Здоровье", "expense", "#22c55e"}, + {"🎮", "Развлечения", "expense", "#ec4899"}, + {"📱", "Связь / Подписки", "expense", "#0ea5e9"}, + {"✈️", "Путешествия", "expense", "#14b8a6"}, + {"🎁", "Подарки", "expense", "#a855f7"}, + {"🛒", "Бытовое", "expense", "#64748b"}, + {"🛍️", "Маркетплейсы", "expense", "#F7B538"}, + {"💎", "Накопления", "expense", "#0D4F4F"}, + {"📦", "Другое", "expense", "#78716c"}, + {"💰", "Зарплата", "income", "#22c55e"}, + {"💼", "Фриланс", "income", "#6366f1"}, + {"📈", "Другой доход", "income", "#0ea5e9"}, + } + for i, c := range defaults { + _, err := r.db.Exec(`INSERT INTO finance_categories (user_id, name, emoji, type, color, sort_order) VALUES ($1,$2,$3,$4,$5,$6)`, + userID, c.name, c.emoji, c.typ, c.color, i) + if err != nil { + return err + } + } + return nil +} + +// --- Transactions --- + +func (r *FinanceRepository) CreateTransaction(tx *model.FinanceTransaction) error { + query := `INSERT INTO finance_transactions (user_id, category_id, type, amount, description, date) + VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, created_at` + return r.db.QueryRow(query, tx.UserID, tx.CategoryID, tx.Type, tx.Amount, tx.Description, tx.Date). + Scan(&tx.ID, &tx.CreatedAt) +} + +func (r *FinanceRepository) ListTransactions(userID int64, month, year int, categoryID *int64, txType, search string, limit, offset int) ([]model.FinanceTransaction, error) { + args := []interface{}{userID} + conditions := []string{"t.user_id = $1"} + argIdx := 2 + + if month > 0 && year > 0 { + conditions = append(conditions, fmt.Sprintf("EXTRACT(MONTH FROM t.date) = $%d AND EXTRACT(YEAR FROM t.date) = $%d", argIdx, argIdx+1)) + args = append(args, month, year) + argIdx += 2 + } + if categoryID != nil { + conditions = append(conditions, fmt.Sprintf("t.category_id = $%d", argIdx)) + args = append(args, *categoryID) + argIdx++ + } + if txType != "" { + conditions = append(conditions, fmt.Sprintf("t.type = $%d", argIdx)) + args = append(args, txType) + argIdx++ + } + if search != "" { + conditions = append(conditions, fmt.Sprintf("t.description ILIKE $%d", argIdx)) + args = append(args, "%"+search+"%") + argIdx++ + } + + if limit <= 0 { + limit = 50 + } + + query := fmt.Sprintf(`SELECT t.id, t.user_id, t.category_id, t.type, t.amount, t.description, t.date, t.created_at, + COALESCE(c.name,'') as category_name, COALESCE(c.emoji,'') as category_emoji + FROM finance_transactions t + LEFT JOIN finance_categories c ON c.id = t.category_id + WHERE %s ORDER BY t.date DESC, t.id DESC LIMIT %d OFFSET %d`, + strings.Join(conditions, " AND "), limit, offset) + + rows, err := r.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var txs []model.FinanceTransaction + for rows.Next() { + var t model.FinanceTransaction + if err := rows.Scan(&t.ID, &t.UserID, &t.CategoryID, &t.Type, &t.Amount, &t.Description, &t.Date, &t.CreatedAt, + &t.CategoryName, &t.CategoryEmoji); err != nil { + return nil, err + } + txs = append(txs, t) + } + return txs, nil +} + +func (r *FinanceRepository) GetTransaction(id, userID int64) (*model.FinanceTransaction, error) { + var t model.FinanceTransaction + query := `SELECT t.id, t.user_id, t.category_id, t.type, t.amount, t.description, t.date, t.created_at, + COALESCE(c.name,'') as category_name, COALESCE(c.emoji,'') as category_emoji + FROM finance_transactions t LEFT JOIN finance_categories c ON c.id = t.category_id + WHERE t.id=$1 AND t.user_id=$2` + err := r.db.QueryRow(query, id, userID).Scan(&t.ID, &t.UserID, &t.CategoryID, &t.Type, &t.Amount, &t.Description, &t.Date, &t.CreatedAt, + &t.CategoryName, &t.CategoryEmoji) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrFinanceTransactionNotFound + } + return nil, err + } + return &t, nil +} + +func (r *FinanceRepository) UpdateTransaction(tx *model.FinanceTransaction) error { + query := `UPDATE finance_transactions SET category_id=$2, type=$3, amount=$4, description=$5, date=$6 + WHERE id=$1 AND user_id=$7` + result, err := r.db.Exec(query, tx.ID, tx.CategoryID, tx.Type, tx.Amount, tx.Description, tx.Date, tx.UserID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrFinanceTransactionNotFound + } + return nil +} + +func (r *FinanceRepository) DeleteTransaction(id, userID int64) error { + result, err := r.db.Exec(`DELETE FROM finance_transactions WHERE id=$1 AND user_id=$2`, id, userID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return ErrFinanceTransactionNotFound + } + return nil +} + +// --- Summary & Analytics --- + +func (r *FinanceRepository) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) { + summary := &model.FinanceSummary{} + + // Total income & expense for the month + err := r.db.QueryRow(`SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0), + COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) + FROM finance_transactions WHERE user_id=$1 AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, + userID, month, year).Scan(&summary.TotalIncome, &summary.TotalExpense) + if err != nil { + return nil, err + } + summary.Balance = summary.TotalIncome - summary.TotalExpense + + // By category + rows, err := r.db.Query(`SELECT c.id, c.name, c.emoji, t.type, SUM(t.amount) as amount + FROM finance_transactions t JOIN finance_categories c ON c.id=t.category_id + WHERE t.user_id=$1 AND EXTRACT(MONTH FROM t.date)=$2 AND EXTRACT(YEAR FROM t.date)=$3 + GROUP BY c.id, c.name, c.emoji, t.type ORDER BY amount DESC`, userID, month, year) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var cs model.CategorySummary + if err := rows.Scan(&cs.CategoryID, &cs.CategoryName, &cs.CategoryEmoji, &cs.Type, &cs.Amount); err != nil { + return nil, err + } + total := summary.TotalExpense + if cs.Type == "income" { + total = summary.TotalIncome + } + if total > 0 { + cs.Percentage = cs.Amount / total * 100 + } + summary.ByCategory = append(summary.ByCategory, cs) + } + if summary.ByCategory == nil { + summary.ByCategory = []model.CategorySummary{} + } + + // Daily expenses + dailyRows, err := r.db.Query(`SELECT date::text, SUM(amount) as amount + FROM finance_transactions WHERE user_id=$1 AND type='expense' AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3 + GROUP BY date ORDER BY date`, userID, month, year) + if err != nil { + return nil, err + } + defer dailyRows.Close() + + for dailyRows.Next() { + var d model.DailySummary + if err := dailyRows.Scan(&d.Date, &d.Amount); err != nil { + return nil, err + } + summary.Daily = append(summary.Daily, d) + } + if summary.Daily == nil { + summary.Daily = []model.DailySummary{} + } + + return summary, nil +} + +func (r *FinanceRepository) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { + analytics := &model.FinanceAnalytics{} + + // Monthly trend + rows, err := r.db.Query(`SELECT TO_CHAR(date, 'YYYY-MM') as month, + COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) as income, + COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) as expense + FROM finance_transactions WHERE user_id=$1 AND date >= (CURRENT_DATE - ($2 || ' months')::interval) + GROUP BY TO_CHAR(date, 'YYYY-MM') ORDER BY month`, userID, months) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var mt model.MonthlyTrend + if err := rows.Scan(&mt.Month, &mt.Income, &mt.Expense); err != nil { + return nil, err + } + analytics.MonthlyTrend = append(analytics.MonthlyTrend, mt) + } + if analytics.MonthlyTrend == nil { + analytics.MonthlyTrend = []model.MonthlyTrend{} + } + + // Avg daily expense for current month + now := time.Now() + var totalExpense float64 + var dayCount int + r.db.QueryRow(`SELECT COALESCE(SUM(amount),0), COUNT(DISTINCT date) + FROM finance_transactions WHERE user_id=$1 AND type='expense' + AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, + userID, int(now.Month()), now.Year()).Scan(&totalExpense, &dayCount) + if dayCount > 0 { + analytics.AvgDailyExpense = totalExpense / float64(dayCount) + } + + // Comparison with previous month + prevMonth := now.AddDate(0, -1, 0) + var currentMonthExp, prevMonthExp float64 + r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' + AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, + userID, int(now.Month()), now.Year()).Scan(¤tMonthExp) + r.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM finance_transactions WHERE user_id=$1 AND type='expense' + AND EXTRACT(MONTH FROM date)=$2 AND EXTRACT(YEAR FROM date)=$3`, + userID, int(prevMonth.Month()), prevMonth.Year()).Scan(&prevMonthExp) + + analytics.ComparisonPrevMonth = model.Comparison{ + Current: currentMonthExp, + Previous: prevMonthExp, + } + if prevMonthExp > 0 { + analytics.ComparisonPrevMonth.DiffPercent = (currentMonthExp - prevMonthExp) / prevMonthExp * 100 + } + + return analytics, nil +} + +func (r *FinanceRepository) HasCategories(userID int64) (bool, error) { + var count int + err := r.db.Get(&count, `SELECT COUNT(*) FROM finance_categories WHERE user_id=$1`, userID) + return count > 0, err +} diff --git a/internal/service/finance.go b/internal/service/finance.go new file mode 100644 index 0000000..a046d1f --- /dev/null +++ b/internal/service/finance.go @@ -0,0 +1,167 @@ +package service + +import ( + "database/sql" + "time" + + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type FinanceService struct { + repo *repository.FinanceRepository +} + +func NewFinanceService(repo *repository.FinanceRepository) *FinanceService { + return &FinanceService{repo: repo} +} + +// --- Categories --- + +func (s *FinanceService) ListCategories(userID int64) ([]model.FinanceCategory, error) { + // Auto-seed if user has no categories + has, err := s.repo.HasCategories(userID) + if err != nil { + return nil, err + } + if !has { + if err := s.repo.SeedDefaultCategories(userID); err != nil { + return nil, err + } + } + + cats, err := s.repo.ListCategories(userID) + if err != nil { + return nil, err + } + if cats == nil { + cats = []model.FinanceCategory{} + } + return cats, nil +} + +func (s *FinanceService) CreateCategory(userID int64, req *model.CreateFinanceCategoryRequest) (*model.FinanceCategory, error) { + cat := &model.FinanceCategory{ + UserID: userID, + Name: req.Name, + Emoji: req.Emoji, + Type: req.Type, + Color: defaultString(req.Color, "#6366f1"), + SortOrder: req.SortOrder, + } + if req.Budget != nil { + cat.Budget = sql.NullFloat64{Float64: *req.Budget, Valid: true} + } + if err := s.repo.CreateCategory(cat); err != nil { + return nil, err + } + cat.ProcessForJSON() + return cat, nil +} + +func (s *FinanceService) UpdateCategory(id, userID int64, req *model.UpdateFinanceCategoryRequest) (*model.FinanceCategory, error) { + cat, err := s.repo.GetCategory(id, userID) + if err != nil { + return nil, err + } + if req.Name != nil { + cat.Name = *req.Name + } + if req.Emoji != nil { + cat.Emoji = *req.Emoji + } + if req.Type != nil { + cat.Type = *req.Type + } + if req.Budget != nil { + cat.Budget = sql.NullFloat64{Float64: *req.Budget, Valid: true} + } + if req.Color != nil { + cat.Color = *req.Color + } + if req.SortOrder != nil { + cat.SortOrder = *req.SortOrder + } + if err := s.repo.UpdateCategory(cat); err != nil { + return nil, err + } + cat.ProcessForJSON() + return cat, nil +} + +func (s *FinanceService) DeleteCategory(id, userID int64) error { + return s.repo.DeleteCategory(id, userID) +} + +// --- Transactions --- + +func (s *FinanceService) ListTransactions(userID int64, month, year int, categoryID *int64, txType, search string, limit, offset int) ([]model.FinanceTransaction, error) { + txs, err := s.repo.ListTransactions(userID, month, year, categoryID, txType, search, limit, offset) + if err != nil { + return nil, err + } + if txs == nil { + txs = []model.FinanceTransaction{} + } + return txs, nil +} + +func (s *FinanceService) CreateTransaction(userID int64, req *model.CreateFinanceTransactionRequest) (*model.FinanceTransaction, error) { + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + date = time.Now() + } + tx := &model.FinanceTransaction{ + UserID: userID, + CategoryID: req.CategoryID, + Type: req.Type, + Amount: req.Amount, + Description: req.Description, + Date: date, + } + if err := s.repo.CreateTransaction(tx); err != nil { + return nil, err + } + return tx, nil +} + +func (s *FinanceService) UpdateTransaction(id, userID int64, req *model.UpdateFinanceTransactionRequest) (*model.FinanceTransaction, error) { + tx, err := s.repo.GetTransaction(id, userID) + if err != nil { + return nil, err + } + if req.CategoryID != nil { + tx.CategoryID = *req.CategoryID + } + if req.Type != nil { + tx.Type = *req.Type + } + if req.Amount != nil { + tx.Amount = *req.Amount + } + if req.Description != nil { + tx.Description = *req.Description + } + if req.Date != nil { + parsed, err := time.Parse("2006-01-02", *req.Date) + if err == nil { + tx.Date = parsed + } + } + if err := s.repo.UpdateTransaction(tx); err != nil { + return nil, err + } + return tx, nil +} + +func (s *FinanceService) DeleteTransaction(id, userID int64) error { + return s.repo.DeleteTransaction(id, userID) +} + +func (s *FinanceService) GetSummary(userID int64, month, year int) (*model.FinanceSummary, error) { + return s.repo.GetSummary(userID, month, year) +} + +func (s *FinanceService) GetAnalytics(userID int64, months int) (*model.FinanceAnalytics, error) { + return s.repo.GetAnalytics(userID, months) +} From e782367ef008a346ed05bbf42d46cc6f7d497b00 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Sun, 1 Mar 2026 05:12:07 +0000 Subject: [PATCH 11/11] test: add finance handler, service, repository tests --- go.sum | 2 + internal/handler/finance_test.go | 232 ++++++++++++++++++++++++++++ internal/repository/finance_test.go | 24 +++ internal/service/finance_test.go | 15 ++ 4 files changed, 273 insertions(+) create mode 100644 internal/handler/finance_test.go create mode 100644 internal/repository/finance_test.go create mode 100644 internal/service/finance_test.go diff --git a/go.sum b/go.sum index a160070..38a939f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -14,6 +15,7 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= diff --git a/internal/handler/finance_test.go b/internal/handler/finance_test.go new file mode 100644 index 0000000..6ee172f --- /dev/null +++ b/internal/handler/finance_test.go @@ -0,0 +1,232 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/daniil/homelab-api/internal/middleware" +) + +// helper to create request with user_id in context +func financeReq(method, path string, body interface{}, userID int64) (*http.Request, *httptest.ResponseRecorder) { + var buf bytes.Buffer + if body != nil { + json.NewEncoder(&buf).Encode(body) + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.UserIDKey, userID) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + return req, rr +} + +func financeReqWithParam(method, path, paramName, paramVal string, body interface{}, userID int64) (*http.Request, *httptest.ResponseRecorder) { + req, rr := financeReq(method, path, body, userID) + rctx := chi.NewRouteContext() + rctx.URLParams.Add(paramName, paramVal) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + return req, rr +} + +// --- Owner-only access tests (403 for non-owner) --- + +func TestFinance_OwnerOnly_ListCategories(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("GET", "/finance/categories", nil, 999) + h.ListCategories(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_CreateCategory(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/categories", map[string]string{"name": "Test", "type": "expense"}, 42) + h.CreateCategory(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_UpdateCategory(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("PUT", "/finance/categories/1", "id", "1", map[string]string{"name": "X"}, 42) + h.UpdateCategory(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_DeleteCategory(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("DELETE", "/finance/categories/1", "id", "1", nil, 42) + h.DeleteCategory(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_ListTransactions(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("GET", "/finance/transactions", nil, 42) + h.ListTransactions(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_CreateTransaction(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/transactions", map[string]interface{}{"amount": 100, "type": "expense"}, 42) + h.CreateTransaction(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_UpdateTransaction(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("PUT", "/finance/transactions/1", "id", "1", map[string]interface{}{"amount": 50}, 42) + h.UpdateTransaction(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_DeleteTransaction(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("DELETE", "/finance/transactions/1", "id", "1", nil, 42) + h.DeleteTransaction(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_Summary(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("GET", "/finance/summary", nil, 42) + h.Summary(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +func TestFinance_OwnerOnly_Analytics(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("GET", "/finance/analytics", nil, 42) + h.Analytics(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("expected 403 for non-owner, got %d", rr.Code) + } +} + +// --- Validation tests (owner but bad input) --- + +func TestFinance_CreateCategory_InvalidJSON(t *testing.T) { + h := &FinanceHandler{} + req := httptest.NewRequest("POST", "/finance/categories", bytes.NewBufferString("{bad")) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.UserIDKey, int64(1)) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + h.CreateCategory(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestFinance_CreateCategory_MissingFields(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/categories", map[string]string{"name": "Test"}, 1) + h.CreateCategory(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing type, got %d", rr.Code) + } +} + +func TestFinance_CreateCategory_InvalidType(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/categories", map[string]string{"name": "Test", "type": "invalid"}, 1) + h.CreateCategory(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid type, got %d", rr.Code) + } +} + +func TestFinance_CreateTransaction_InvalidJSON(t *testing.T) { + h := &FinanceHandler{} + req := httptest.NewRequest("POST", "/finance/transactions", bytes.NewBufferString("{bad")) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.UserIDKey, int64(1)) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + h.CreateTransaction(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestFinance_CreateTransaction_ZeroAmount(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/transactions", map[string]interface{}{ + "amount": 0, "type": "expense", "category_id": 1, "date": "2026-03-01", + }, 1) + h.CreateTransaction(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for zero amount, got %d", rr.Code) + } +} + +func TestFinance_CreateTransaction_InvalidType(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReq("POST", "/finance/transactions", map[string]interface{}{ + "amount": 100, "type": "invalid", "category_id": 1, "date": "2026-03-01", + }, 1) + h.CreateTransaction(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid type, got %d", rr.Code) + } +} + +func TestFinance_UpdateCategory_InvalidID(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("PUT", "/finance/categories/abc", "id", "abc", map[string]string{"name": "X"}, 1) + h.UpdateCategory(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid id, got %d", rr.Code) + } +} + +func TestFinance_DeleteCategory_InvalidID(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("DELETE", "/finance/categories/abc", "id", "abc", nil, 1) + h.DeleteCategory(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid id, got %d", rr.Code) + } +} + +func TestFinance_UpdateTransaction_InvalidID(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("PUT", "/finance/transactions/abc", "id", "abc", map[string]interface{}{"amount": 50}, 1) + h.UpdateTransaction(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid id, got %d", rr.Code) + } +} + +func TestFinance_DeleteTransaction_InvalidID(t *testing.T) { + h := &FinanceHandler{} + req, rr := financeReqWithParam("DELETE", "/finance/transactions/abc", "id", "abc", nil, 1) + h.DeleteTransaction(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid id, got %d", rr.Code) + } +} diff --git a/internal/repository/finance_test.go b/internal/repository/finance_test.go new file mode 100644 index 0000000..f0dcd6a --- /dev/null +++ b/internal/repository/finance_test.go @@ -0,0 +1,24 @@ +package repository + +import ( + "testing" +) + +func TestNewFinanceRepository(t *testing.T) { + repo := NewFinanceRepository(nil) + if repo == nil { + t.Error("expected non-nil FinanceRepository") + } + if repo.db != nil { + t.Error("expected nil db") + } +} + +func TestFinanceCategoryErrors(t *testing.T) { + if ErrFinanceCategoryNotFound.Error() != "finance category not found" { + t.Errorf("unexpected error message: %s", ErrFinanceCategoryNotFound.Error()) + } + if ErrFinanceTransactionNotFound.Error() != "finance transaction not found" { + t.Errorf("unexpected error message: %s", ErrFinanceTransactionNotFound.Error()) + } +} diff --git a/internal/service/finance_test.go b/internal/service/finance_test.go new file mode 100644 index 0000000..f6c305e --- /dev/null +++ b/internal/service/finance_test.go @@ -0,0 +1,15 @@ +package service + +import ( + "testing" +) + +func TestNewFinanceService(t *testing.T) { + svc := NewFinanceService(nil) + if svc == nil { + t.Error("expected non-nil FinanceService") + } + if svc.repo != nil { + t.Error("expected nil repo") + } +}