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") + } +}