test: add finance handler, service, repository tests
All checks were successful
CI / ci (push) Successful in 36s

This commit is contained in:
Cosmo
2026-03-01 05:12:07 +00:00
parent 23939ccc92
commit e782367ef0
4 changed files with 273 additions and 0 deletions

2
go.sum
View File

@@ -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-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 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 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/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 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/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 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 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= 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 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

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