feat: tests

This commit is contained in:
juancwu 2026-02-14 10:53:57 -05:00
commit 1346abf733
No known key found for this signature in database
32 changed files with 3772 additions and 11 deletions

View file

@ -26,6 +26,20 @@ tasks:
- echo "Starting app..."
- task --parallel tailwind-watch templ
# Testing
test:
desc: Run tests (SQLite only)
cmds:
- go test ./internal/... -v -count=1
test:integration:
desc: Run tests against both SQLite and PostgreSQL
cmds:
- docker run --name budgit-test-pg -d -p 5433:5432 -e POSTGRES_USER=budgit_test -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=budgit_test postgres:16-alpine
- defer: docker rm -f budgit-test-pg
- cmd: sleep 3
- cmd: BUDGIT_TEST_POSTGRES_URL="postgres://budgit_test:testpass@localhost:5433/budgit_test?sslmode=disable" go test ./internal/... -v -count=1
# Production build
build:
desc: Build production binary

6
go.mod
View file

@ -4,6 +4,7 @@ go 1.25.1
require (
github.com/Oudwins/tailwind-merge-go v0.2.1
github.com/a-h/templ v0.3.977
github.com/alexedwards/argon2id v1.0.0
github.com/emersion/go-imap v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
@ -12,6 +13,7 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.26.0
github.com/stretchr/testify v1.11.1
github.com/wneessen/go-mail v0.7.2
modernc.org/sqlite v1.40.1
)
@ -21,12 +23,12 @@ require (
github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/a-h/templ v0.3.960 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
@ -54,11 +56,13 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/templui/templui v1.5.0 // indirect
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect
github.com/vertica/vertica-sql-go v1.3.3 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect

16
go.sum
View file

@ -23,8 +23,6 @@ github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuG
github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
@ -50,7 +48,6 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -151,8 +148,8 @@ github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47e
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -187,7 +184,6 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -217,10 +213,10 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/templui/templui v0.101.0 h1:Nv2WiyevFZ+6jtELRYxmVwHlu9WXXIyi6etvgP+tkbI=
github.com/templui/templui v0.101.0/go.mod h1:SnKmOIs7t/ngsdWUws97CVodbz89ne9kQv3ivgdhiHo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/templui/templui v1.5.0 h1:nLWZVCEH/Mh86ZSzqMMa3Blpq+oXQKZWIM2rJ33yHQI=
github.com/templui/templui v1.5.0/go.mod h1:9CP7NRm+tXEA6K3/KRny+yANApKCjwXvdR8ahyfglgM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU=
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s=

View file

@ -0,0 +1,136 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func newTestAuthHandler(dbi testutil.DBInfo) *authHandler {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, profileRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
}
func guestContext() context.Context {
ctx := context.Background()
ctx = ctxkeys.WithConfig(ctx, testutil.TestConfig().Sanitized())
ctx = ctxkeys.WithCSRFToken(ctx, "test")
ctx = ctxkeys.WithAppVersion(ctx, "test")
return ctx
}
func TestAuthHandler_AuthPage(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
req := httptest.NewRequest(http.MethodGet, "/auth", nil)
req = req.WithContext(guestContext())
w := httptest.NewRecorder()
h.AuthPage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAuthHandler_SendMagicLink(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
form := url.Values{"email": {"newuser@example.com"}}
req := httptest.NewRequest(http.MethodPost, "/auth/magic-link", nil)
req = req.WithContext(guestContext())
req.PostForm = form
w := httptest.NewRecorder()
h.SendMagicLink(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAuthHandler_SendMagicLink_EmptyEmail(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
form := url.Values{"email": {""}}
req := httptest.NewRequest(http.MethodPost, "/auth/magic-link", nil)
req = req.WithContext(guestContext())
req.PostForm = form
w := httptest.NewRecorder()
h.SendMagicLink(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAuthHandler_Logout(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/logout", user, profile, nil)
w := httptest.NewRecorder()
h.Logout(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/", w.Header().Get("Location"))
})
}
func TestAuthHandler_CompleteOnboarding_Step2(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, profile, url.Values{
"step": {"2"},
"name": {"John"},
})
w := httptest.NewRecorder()
h.CompleteOnboarding(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAuthHandler_CompleteOnboarding_Step3(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, profile, url.Values{
"step": {"3"},
"name": {"John"},
"space_name": {"My Space"},
})
w := httptest.NewRecorder()
h.CompleteOnboarding(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/app/dashboard", w.Header().Get("Location"))
})
}

View file

@ -0,0 +1,70 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func TestDashboardHandler_DashboardPage(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
testutil.CreateTestSpace(t, dbi.DB, user.ID, "My Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/app/dashboard", user, profile, nil)
w := httptest.NewRecorder()
h.DashboardPage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestDashboardHandler_CreateSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/dashboard/spaces", user, profile, url.Values{"name": {"New Space"}})
w := httptest.NewRecorder()
h.CreateSpace(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.True(t, strings.HasPrefix(w.Header().Get("HX-Redirect"), "/app/spaces/"))
})
}
func TestDashboardHandler_CreateSpace_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
expenseSvc := service.NewExpenseService(expenseRepo)
h := NewDashboardHandler(spaceSvc, expenseSvc)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/dashboard/spaces", user, profile, url.Values{"name": {""}})
w := httptest.NewRecorder()
h.CreateSpace(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}

View file

@ -0,0 +1,43 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func TestHomeHandler_HomePage_Guest(t *testing.T) {
h := NewHomeHandler()
req := httptest.NewRequest(http.MethodGet, "/", nil)
ctx := context.Background()
ctx = ctxkeys.WithConfig(ctx, testutil.TestConfig().Sanitized())
ctx = ctxkeys.WithCSRFToken(ctx, "test")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
h.HomePage(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/auth", w.Header().Get("Location"))
}
func TestHomeHandler_HomePage_Authenticated(t *testing.T) {
h := NewHomeHandler()
user := &model.User{ID: "user-1", Email: "test@example.com"}
profile := &model.Profile{ID: "prof-1", UserID: "user-1", Name: "Test"}
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/", user, profile, nil)
w := httptest.NewRecorder()
h.HomePage(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/app/dashboard", w.Header().Get("Location"))
}

View file

@ -0,0 +1,74 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.AuthService) {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, profileRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo)
return NewSettingsHandler(authSvc, userSvc), authSvc
}
func TestSettingsHandler_SettingsPage(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodGet, "/app/settings", user, profile, nil)
w := httptest.NewRecorder()
h.SettingsPage(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSettingsHandler_SetPassword(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, profile, url.Values{
"new_password": {"testpassword1"},
"confirm_password": {"testpassword1"},
})
w := httptest.NewRecorder()
h.SetPassword(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSettingsHandler_SetPassword_Mismatch(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h, _ := newTestSettingsHandler(dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test User")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/settings/password", user, profile, url.Values{
"new_password": {"testpassword1"},
"confirm_password": {"differentpassword"},
})
w := httptest.NewRecorder()
h.SetPassword(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}

View file

@ -0,0 +1,174 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
)
func newTestSpaceHandler(t *testing.T, dbi testutil.DBInfo) *SpaceHandler {
t.Helper()
spaceRepo := repository.NewSpaceRepository(dbi.DB)
tagRepo := repository.NewTagRepository(dbi.DB)
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
expenseRepo := repository.NewExpenseRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB)
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
return NewSpaceHandler(
service.NewSpaceService(spaceRepo),
service.NewTagService(tagRepo),
service.NewShoppingListService(listRepo, itemRepo),
service.NewExpenseService(expenseRepo),
service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc),
service.NewMoneyAccountService(accountRepo),
service.NewPaymentMethodService(methodRepo),
)
}
func TestSpaceHandler_CreateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists", user, profile, url.Values{"name": {"Groceries"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_CreateList_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists", user, profile, url.Values{"name": {""}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateList(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
}
func TestSpaceHandler_DeleteList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Groceries")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists/"+list.ID+"?from=card", user, profile, nil)
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("listID", list.ID)
w := httptest.NewRecorder()
h.DeleteList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_AddItemToList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Groceries")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/lists/"+list.ID+"/items", user, profile, url.Values{"name": {"Milk"}})
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("listID", list.ID)
w := httptest.NewRecorder()
h.AddItemToList(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_CreateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/tags", user, profile, url.Values{"name": {"food"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateTag(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_DeleteTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "food", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/tags/"+tag.ID, user, profile, nil)
req.SetPathValue("spaceID", space.ID)
req.SetPathValue("tagID", tag.ID)
w := httptest.NewRecorder()
h.DeleteTag(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_CreateAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/accounts", user, profile, url.Values{"name": {"Savings"}})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreateAccount(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestSpaceHandler_CreatePaymentMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestSpaceHandler(t, dbi)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "test@example.com", "Test")
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/app/spaces/"+space.ID+"/payment-methods", user, profile, url.Values{
"name": {"Visa"},
"type": {"credit"},
"last_four": {"4242"},
})
req.SetPathValue("spaceID", space.ID)
w := httptest.NewRecorder()
h.CreatePaymentMethod(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}

View file

@ -0,0 +1,245 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
AmountCents: 1500,
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, expense.ID, fetched.ID)
assert.Equal(t, "Lunch", fetched.Description)
assert.Equal(t, 1500, fetched.AmountCents)
assert.Equal(t, model.ExpenseTypeExpense, fetched.Type)
})
}
func TestExpenseRepository_GetBySpaceIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", 1000, model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", 2000, model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 3", 3000, model.ExpenseTypeExpense)
expenses, err := repo.GetBySpaceIDPaginated(space.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, expenses, 2)
})
}
func TestExpenseRepository_CountBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 1", 1000, model.ExpenseTypeExpense)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Expense 2", 2000, model.ExpenseTypeExpense)
count, err := repo.CountBySpaceID(space.ID)
require.NoError(t, err)
assert.Equal(t, 2, count)
})
}
func TestExpenseRepository_GetTagsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Groceries", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Weekly groceries",
AmountCents: 5000,
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag.ID}, nil)
require.NoError(t, err)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, tagMap, expense.ID)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag.ID, tagMap[expense.ID][0].ID)
assert.Equal(t, "Groceries", tagMap[expense.ID][0].Name)
})
}
func TestExpenseRepository_GetPaymentMethodsByExpenseIDs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Online purchase",
AmountCents: 3000,
Type: model.ExpenseTypeExpense,
Date: now,
PaymentMethodID: &method.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, nil, nil)
require.NoError(t, err)
methodMap, err := repo.GetPaymentMethodsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Contains(t, methodMap, expense.ID)
assert.Equal(t, method.ID, methodMap[expense.ID].ID)
assert.Equal(t, "Visa", methodMap[expense.ID].Name)
assert.Equal(t, model.PaymentMethodTypeCredit, methodMap[expense.ID].Type)
})
}
func TestExpenseRepository_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
color := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", &color)
now := time.Now()
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
expense1 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Lunch",
AmountCents: 1500,
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense1, []string{tag.ID}, nil)
require.NoError(t, err)
expense2 := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Dinner",
AmountCents: 2500,
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err = repo.Create(expense2, []string{tag.ID}, nil)
require.NoError(t, err)
summaries, err := repo.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.Equal(t, "Food", summaries[0].TagName)
assert.Equal(t, 4000, summaries[0].TotalAmount)
})
}
func TestExpenseRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
tag1 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag A", nil)
tag2 := testutil.CreateTestTag(t, dbi.DB, space.ID, "Tag B", nil)
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: space.ID,
CreatedBy: user.ID,
Description: "Original",
AmountCents: 1000,
Type: model.ExpenseTypeExpense,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(expense, []string{tag1.ID}, nil)
require.NoError(t, err)
expense.Description = "Updated"
expense.UpdatedAt = time.Now()
err = repo.Update(expense, []string{tag2.ID})
require.NoError(t, err)
fetched, err := repo.GetByID(expense.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Description)
tagMap, err := repo.GetTagsByExpenseIDs([]string{expense.ID})
require.NoError(t, err)
require.Len(t, tagMap[expense.ID], 1)
assert.Equal(t, tag2.ID, tagMap[expense.ID][0].ID)
})
}
func TestExpenseRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewExpenseRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
expense := testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "To Delete", 500, model.ExpenseTypeExpense)
err := repo.Delete(expense.ID)
require.NoError(t, err)
_, err = repo.GetByID(expense.ID)
assert.ErrorIs(t, err, ErrExpenseNotFound)
})
}

View file

@ -0,0 +1,100 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInvitationRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewInvitationRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "inv-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Invite Space")
now := time.Now()
invitation := &model.SpaceInvitation{
Token: uuid.NewString(),
SpaceID: space.ID,
InviterID: owner.ID,
Email: "invitee@example.com",
Status: model.InvitationStatusPending,
ExpiresAt: now.Add(48 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(invitation)
require.NoError(t, err)
fetched, err := repo.GetByToken(invitation.Token)
require.NoError(t, err)
assert.Equal(t, invitation.Token, fetched.Token)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "invitee@example.com", fetched.Email)
assert.Equal(t, model.InvitationStatusPending, fetched.Status)
})
}
func TestInvitationRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewInvitationRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "inv-space-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Space Invites")
testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "one@example.com")
invitations, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, invitations, 1)
assert.Equal(t, "one@example.com", invitations[0].Email)
})
}
func TestInvitationRepository_UpdateStatus(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewInvitationRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "inv-status-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Status Space")
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "status@example.com")
err := repo.UpdateStatus(invitation.Token, model.InvitationStatusAccepted)
require.NoError(t, err)
fetched, err := repo.GetByToken(invitation.Token)
require.NoError(t, err)
assert.Equal(t, model.InvitationStatusAccepted, fetched.Status)
})
}
func TestInvitationRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewInvitationRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "inv-delete-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Delete Space")
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "delete@example.com")
err := repo.Delete(invitation.Token)
require.NoError(t, err)
_, err = repo.GetByToken(invitation.Token)
assert.ErrorIs(t, err, ErrInvitationNotFound)
})
}
func TestInvitationRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewInvitationRepository(dbi.DB)
_, err := repo.GetByToken("nonexistent-token")
assert.ErrorIs(t, err, ErrInvitationNotFound)
})
}

View file

@ -0,0 +1,161 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListItemRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: list.ID,
Name: "Apples",
IsChecked: false,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, item.ID, fetched.ID)
assert.Equal(t, list.ID, fetched.ListID)
assert.Equal(t, "Apples", fetched.Name)
assert.False(t, fetched.IsChecked)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestListItemRepository_GetByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item1 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
item2 := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
require.Len(t, items, 2)
// Ordered by created_at ASC, so item1 should be first.
assert.Equal(t, item1.ID, items[0].ID)
assert.Equal(t, item2.ID, items[1].ID)
})
}
func TestListItemRepository_GetByListIDPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
time.Sleep(10 * time.Millisecond)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
items, err := repo.GetByListIDPaginated(list.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, items, 2)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_CountByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item C", user.ID)
count, err := repo.CountByListID(list.ID)
require.NoError(t, err)
assert.Equal(t, 3, count)
})
}
func TestListItemRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "Original", user.ID)
item.Name = "Updated"
item.IsChecked = true
err := repo.Update(item)
require.NoError(t, err)
fetched, err := repo.GetByID(item.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", fetched.Name)
assert.True(t, fetched.IsChecked)
})
}
func TestListItemRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
item := testutil.CreateTestListItem(t, dbi.DB, list.ID, "To Delete", user.ID)
err := repo.Delete(item.ID)
require.NoError(t, err)
_, err = repo.GetByID(item.ID)
assert.ErrorIs(t, err, ErrListItemNotFound)
})
}
func TestListItemRepository_DeleteByListID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewListItemRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Test List")
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item A", user.ID)
testutil.CreateTestListItem(t, dbi.DB, list.ID, "Item B", user.ID)
err := repo.DeleteByListID(list.ID)
require.NoError(t, err)
items, err := repo.GetByListID(list.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}

View file

@ -0,0 +1,169 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, account.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Savings", fetched.Name)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestMoneyAccountRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
accounts, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, accounts, 2)
})
}
func TestMoneyAccountRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Original", user.ID)
account.Name = "Renamed"
err := repo.Update(account)
require.NoError(t, err)
fetched, err := repo.GetByID(account.ID)
require.NoError(t, err)
assert.Equal(t, "Renamed", fetched.Name)
})
}
func TestMoneyAccountRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "To Delete", user.ID)
err := repo.Delete(account.ID)
require.NoError(t, err)
_, err = repo.GetByID(account.ID)
assert.ErrorIs(t, err, ErrMoneyAccountNotFound)
})
}
func TestMoneyAccountRepository_CreateTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: account.ID,
AmountCents: 5000,
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
CreatedAt: time.Now(),
}
err := repo.CreateTransfer(transfer)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
require.Len(t, transfers, 1)
assert.Equal(t, transfer.ID, transfers[0].ID)
assert.Equal(t, 5000, transfers[0].AmountCents)
assert.Equal(t, model.TransferDirectionDeposit, transfers[0].Direction)
})
}
func TestMoneyAccountRepository_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID)
err := repo.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := repo.GetTransfersByAccountID(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}
func TestMoneyAccountRepository_GetAccountBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 300, model.TransferDirectionWithdrawal, user.ID)
balance, err := repo.GetAccountBalance(account.ID)
require.NoError(t, err)
assert.Equal(t, 700, balance)
})
}
func TestMoneyAccountRepository_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewMoneyAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account A", user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account B", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, 2000, model.TransferDirectionDeposit, user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 3000, model.TransferDirectionDeposit, user.ID)
total, err := repo.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.Equal(t, 5000, total)
})
}

View file

@ -0,0 +1,97 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
lastFour := "4242"
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Visa Gold",
Type: model.PaymentMethodTypeCredit,
LastFour: &lastFour,
CreatedBy: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, method.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Visa Gold", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, fetched.Type)
require.NotNil(t, fetched.LastFour)
assert.Equal(t, "4242", *fetched.LastFour)
assert.Equal(t, user.ID, fetched.CreatedBy)
})
}
func TestPaymentMethodRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit Card", model.PaymentMethodTypeDebit, user.ID)
methods, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
method.Name = "New Card"
method.Type = model.PaymentMethodTypeDebit
err := repo.Update(method)
require.NoError(t, err)
fetched, err := repo.GetByID(method.ID)
require.NoError(t, err)
assert.Equal(t, "New Card", fetched.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, fetched.Type)
})
}
func TestPaymentMethodRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewPaymentMethodRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "To Delete", model.PaymentMethodTypeCredit, user.ID)
err := repo.Delete(method.ID)
require.NoError(t, err)
_, err = repo.GetByID(method.ID)
assert.ErrorIs(t, err, ErrPaymentMethodNotFound)
})
}

View file

@ -0,0 +1,63 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-create@example.com", nil)
now := time.Now()
profile := &model.Profile{
ID: uuid.NewString(),
UserID: user.ID,
Name: "Test User",
CreatedAt: now,
UpdatedAt: now,
}
id, err := repo.Create(profile)
require.NoError(t, err)
assert.Equal(t, profile.ID, id)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "Test User", fetched.Name)
assert.Equal(t, user.ID, fetched.UserID)
})
}
func TestProfileRepository_UpdateName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "profile-update@example.com", nil)
testutil.CreateTestProfile(t, dbi.DB, user.ID, "Old Name")
err := repo.UpdateName(user.ID, "New Name")
require.NoError(t, err)
fetched, err := repo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", fetched.Name)
})
}
func TestProfileRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewProfileRepository(dbi.DB)
_, err := repo.ByUserID("nonexistent-id")
assert.ErrorIs(t, err, ErrProfileNotFound)
})
}

View file

@ -0,0 +1,93 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, list.ID, fetched.ID)
assert.Equal(t, space.ID, fetched.SpaceID)
assert.Equal(t, "Groceries", fetched.Name)
})
}
func TestShoppingListRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list1 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List A")
// Small delay to ensure distinct created_at timestamps for ordering.
time.Sleep(10 * time.Millisecond)
list2 := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "List B")
lists, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, lists, 2)
// Ordered by created_at DESC, so list2 should be first.
assert.Equal(t, list2.ID, lists[0].ID)
assert.Equal(t, list1.ID, lists[1].ID)
})
}
func TestShoppingListRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Original Name")
list.Name = "Updated Name"
err := repo.Update(list)
require.NoError(t, err)
fetched, err := repo.GetByID(list.ID)
require.NoError(t, err)
assert.Equal(t, "Updated Name", fetched.Name)
})
}
func TestShoppingListRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewShoppingListRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
list := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "To Delete")
err := repo.Delete(list.ID)
require.NoError(t, err)
_, err = repo.GetByID(list.ID)
assert.ErrorIs(t, err, ErrShoppingListNotFound)
})
}

View file

@ -0,0 +1,140 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpaceRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "space-create@example.com", nil)
now := time.Now()
space := &model.Space{
ID: uuid.NewString(),
Name: "My Space",
OwnerID: user.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(space)
require.NoError(t, err)
fetched, err := repo.ByID(space.ID)
require.NoError(t, err)
assert.Equal(t, "My Space", fetched.Name)
assert.Equal(t, user.ID, fetched.OwnerID)
isMember, err := repo.IsMember(space.ID, user.ID)
require.NoError(t, err)
assert.True(t, isMember, "owner should be a member after Create")
})
}
func TestSpaceRepository_ByUserID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "space-byuser@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "User Space")
spaces, err := repo.ByUserID(user.ID)
require.NoError(t, err)
require.Len(t, spaces, 1)
assert.Equal(t, space.ID, spaces[0].ID)
})
}
func TestSpaceRepository_AddMember(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "space-owner@example.com", nil)
member := testutil.CreateTestUser(t, dbi.DB, "space-member@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Shared Space")
err := repo.AddMember(space.ID, member.ID, model.RoleMember)
require.NoError(t, err)
isMember, err := repo.IsMember(space.ID, member.ID)
require.NoError(t, err)
assert.True(t, isMember)
})
}
func TestSpaceRepository_RemoveMember(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
owner := testutil.CreateTestUser(t, dbi.DB, "remove-owner@example.com", nil)
member := testutil.CreateTestUser(t, dbi.DB, "remove-member@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Remove Test")
err := repo.AddMember(space.ID, member.ID, model.RoleMember)
require.NoError(t, err)
err = repo.RemoveMember(space.ID, member.ID)
require.NoError(t, err)
isMember, err := repo.IsMember(space.ID, member.ID)
require.NoError(t, err)
assert.False(t, isMember)
})
}
func TestSpaceRepository_GetMembers(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-owner@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "members-member@example.com", "Member")
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
err := repo.AddMember(space.ID, member.ID, model.RoleMember)
require.NoError(t, err)
members, err := repo.GetMembers(space.ID)
require.NoError(t, err)
require.Len(t, members, 2)
// The query orders by role DESC (owner first), then joined_at ASC.
assert.Equal(t, model.RoleOwner, members[0].Role)
assert.Equal(t, "Owner", members[0].Name)
assert.Equal(t, model.RoleMember, members[1].Role)
assert.Equal(t, "Member", members[1].Name)
})
}
func TestSpaceRepository_UpdateName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "space-rename@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Old Name")
err := repo.UpdateName(space.ID, "New Name")
require.NoError(t, err)
fetched, err := repo.ByID(space.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", fetched.Name)
})
}
func TestSpaceRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewSpaceRepository(dbi.DB)
_, err := repo.ByID("nonexistent-id")
assert.ErrorIs(t, err, ErrSpaceNotFound)
})
}

View file

@ -0,0 +1,120 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Space")
color := "#ff0000"
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Groceries",
Color: &color,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "Groceries", fetched.Name)
assert.Equal(t, &color, fetched.Color)
assert.Equal(t, space.ID, fetched.SpaceID)
})
}
func TestTagRepository_GetBySpaceID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag List Space")
// Create tags with names that sort alphabetically: "Alpha" < "Beta".
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
tags, err := repo.GetBySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
assert.Equal(t, "Alpha", tags[0].Name)
assert.Equal(t, "Beta", tags[1].Name)
})
}
func TestTagRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Tag", nil)
newColor := "#00ff00"
tag.Name = "New Tag"
tag.Color = &newColor
err := repo.Update(tag)
require.NoError(t, err)
fetched, err := repo.GetByID(tag.ID)
require.NoError(t, err)
assert.Equal(t, "New Tag", fetched.Name)
assert.Equal(t, &newColor, fetched.Color)
})
}
func TestTagRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := repo.Delete(tag.ID)
require.NoError(t, err)
_, err = repo.GetByID(tag.ID)
assert.ErrorIs(t, err, ErrTagNotFound)
})
}
func TestTagRepository_DuplicateTagName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTagRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "tag-dup@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Dup Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Duplicate", nil)
now := time.Now()
duplicate := &model.Tag{
ID: uuid.NewString(),
SpaceID: space.ID,
Name: "Duplicate",
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(duplicate)
assert.ErrorIs(t, err, ErrDuplicateTagName)
})
}

View file

@ -0,0 +1,78 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTokenRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTokenRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "token-create@example.com", nil)
token := &model.Token{
ID: uuid.NewString(),
UserID: user.ID,
Type: model.TokenTypeEmailVerify,
Token: uuid.NewString(),
ExpiresAt: time.Now().Add(1 * time.Hour),
CreatedAt: time.Now(),
}
id, err := repo.Create(token)
require.NoError(t, err)
assert.Equal(t, token.ID, id)
})
}
func TestTokenRepository_ConsumeToken(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTokenRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "token-consume@example.com", nil)
tokenString := uuid.NewString()
testutil.CreateTestToken(t, dbi.DB, user.ID, model.TokenTypeEmailVerify, tokenString, time.Now().Add(1*time.Hour))
consumed, err := repo.ConsumeToken(tokenString)
require.NoError(t, err)
assert.NotNil(t, consumed.UsedAt)
assert.Equal(t, user.ID, consumed.UserID)
})
}
func TestTokenRepository_ConsumeExpiredToken(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTokenRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "token-expired@example.com", nil)
tokenString := uuid.NewString()
testutil.CreateTestToken(t, dbi.DB, user.ID, model.TokenTypeEmailVerify, tokenString, time.Now().Add(-1*time.Hour))
_, err := repo.ConsumeToken(tokenString)
assert.ErrorIs(t, err, ErrTokenNotFound)
})
}
func TestTokenRepository_DeleteByUserAndType(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewTokenRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "token-delete@example.com", nil)
tokenString := uuid.NewString()
testutil.CreateTestToken(t, dbi.DB, user.ID, model.TokenTypeEmailVerify, tokenString, time.Now().Add(1*time.Hour))
err := repo.DeleteByUserAndType(user.ID, model.TokenTypeEmailVerify)
require.NoError(t, err)
// The token should no longer be consumable since it was deleted.
_, err = repo.ConsumeToken(tokenString)
assert.ErrorIs(t, err, ErrTokenNotFound)
})
}

View file

@ -0,0 +1,101 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserRepository_Create(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
user := &model.User{
ID: uuid.NewString(),
Email: "create@example.com",
CreatedAt: time.Now(),
}
id, err := repo.Create(user)
require.NoError(t, err)
assert.Equal(t, user.ID, id)
fetched, err := repo.ByID(id)
require.NoError(t, err)
assert.Equal(t, user.Email, fetched.Email)
})
}
func TestUserRepository_ByEmail(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "byemail@example.com", nil)
fetched, err := repo.ByEmail("byemail@example.com")
require.NoError(t, err)
assert.Equal(t, user.ID, fetched.ID)
assert.Equal(t, "byemail@example.com", fetched.Email)
})
}
func TestUserRepository_Update(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "before@example.com", nil)
user.Email = "after@example.com"
err := repo.Update(user)
require.NoError(t, err)
fetched, err := repo.ByID(user.ID)
require.NoError(t, err)
assert.Equal(t, "after@example.com", fetched.Email)
})
}
func TestUserRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "delete@example.com", nil)
err := repo.Delete(user.ID)
require.NoError(t, err)
_, err = repo.ByID(user.ID)
assert.ErrorIs(t, err, ErrUserNotFound)
})
}
func TestUserRepository_DuplicateEmail(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
testutil.CreateTestUser(t, dbi.DB, "dup@example.com", nil)
duplicate := &model.User{
ID: uuid.NewString(),
Email: "dup@example.com",
CreatedAt: time.Now(),
}
_, err := repo.Create(duplicate)
assert.ErrorIs(t, err, ErrDuplicateEmail)
})
}
func TestUserRepository_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewUserRepository(dbi.DB)
_, err := repo.ByID("nonexistent-id")
assert.ErrorIs(t, err, ErrUserNotFound)
})
}

View file

@ -0,0 +1,195 @@
package service
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestAuthService(dbi testutil.DBInfo) *AuthService {
cfg := testutil.TestConfig()
userRepo := repository.NewUserRepository(dbi.DB)
profileRepo := repository.NewProfileRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaceSvc := NewSpaceService(spaceRepo)
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
return NewAuthService(
emailSvc,
userRepo,
profileRepo,
tokenRepo,
spaceSvc,
cfg.JWTSecret,
cfg.JWTExpiry,
cfg.TokenMagicLinkExpiry,
false,
)
}
func TestAuthService_SendMagicLink(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
err := svc.SendMagicLink("newuser@example.com")
require.NoError(t, err)
// Verify user was created in DB
userRepo := repository.NewUserRepository(dbi.DB)
user, err := userRepo.ByEmail("newuser@example.com")
require.NoError(t, err)
assert.Equal(t, "newuser@example.com", user.Email)
// Verify profile was created in DB
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "", profile.Name)
// Verify token was created in DB
var tokenCount int
err = dbi.DB.Get(&tokenCount, `SELECT COUNT(*) FROM tokens WHERE user_id = $1 AND type = $2`, user.ID, model.TokenTypeMagicLink)
require.NoError(t, err)
assert.Equal(t, 1, tokenCount)
})
}
func TestAuthService_VerifyMagicLink(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "verify@example.com", nil)
testutil.CreateTestToken(t, dbi.DB, user.ID, model.TokenTypeMagicLink, "test-token-123", time.Now().Add(10*time.Minute))
got, err := svc.VerifyMagicLink("test-token-123")
require.NoError(t, err)
assert.Equal(t, user.ID, got.ID)
assert.Equal(t, user.Email, got.Email)
assert.NotNil(t, got.EmailVerifiedAt, "email should be marked as verified")
})
}
func TestAuthService_LoginWithPassword(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
hash, err := svc.HashPassword("testpassword1")
require.NoError(t, err)
user := testutil.CreateTestUser(t, dbi.DB, "login@example.com", &hash)
got, err := svc.LoginWithPassword("login@example.com", "testpassword1")
require.NoError(t, err)
assert.Equal(t, user.ID, got.ID)
assert.Equal(t, user.Email, got.Email)
})
}
func TestAuthService_LoginWithPassword_Wrong(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
hash, err := svc.HashPassword("testpassword1")
require.NoError(t, err)
testutil.CreateTestUser(t, dbi.DB, "wrongpw@example.com", &hash)
_, err = svc.LoginWithPassword("wrongpw@example.com", "wrongpassword!")
assert.Error(t, err)
})
}
func TestAuthService_HashAndComparePassword(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
hash, err := svc.HashPassword("testpassword1")
require.NoError(t, err)
assert.NotEmpty(t, hash)
// Correct password should succeed
err = svc.ComparePassword("testpassword1", hash)
assert.NoError(t, err)
// Wrong password should fail
err = svc.ComparePassword("wrongpassword!", hash)
assert.Error(t, err)
})
}
func TestAuthService_GenerateAndVerifyJWT(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "jwt@example.com", nil)
tokenString, err := svc.GenerateJWT(user)
require.NoError(t, err)
assert.NotEmpty(t, tokenString)
claims, err := svc.VerifyJWT(tokenString)
require.NoError(t, err)
assert.Equal(t, user.ID, claims["user_id"])
assert.Equal(t, user.Email, claims["email"])
})
}
func TestAuthService_SetPassword(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "setpw@example.com", nil)
assert.False(t, user.HasPassword())
err := svc.SetPassword(user.ID, "", "newpassword12", "newpassword12")
require.NoError(t, err)
// Verify user now has a password
userRepo := repository.NewUserRepository(dbi.DB)
updated, err := userRepo.ByID(user.ID)
require.NoError(t, err)
assert.True(t, updated.HasPassword())
})
}
func TestAuthService_NeedsOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
// User with empty name needs onboarding
userEmpty, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "empty@example.com", "")
needs, err := svc.NeedsOnboarding(userEmpty.ID)
require.NoError(t, err)
assert.True(t, needs)
// User with a name does not need onboarding
userNamed, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "named@example.com", "Jane Doe")
needs, err = svc.NeedsOnboarding(userNamed.ID)
require.NoError(t, err)
assert.False(t, needs)
})
}
func TestAuthService_CompleteOnboarding(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestAuthService(dbi)
user, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "onboard@example.com", "")
err := svc.CompleteOnboarding(user.ID, "New Name")
require.NoError(t, err)
// Verify profile name was updated
profileRepo := repository.NewProfileRepository(dbi.DB)
profile, err := profileRepo.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", profile.Name)
})
}

View file

@ -0,0 +1,232 @@
package service
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpenseService_CreateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Food", nil)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Lunch",
Amount: 1500,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
assert.NotEmpty(t, expense.ID)
assert.Equal(t, "Lunch", expense.Description)
assert.Equal(t, 1500, expense.AmountCents)
assert.Equal(t, model.ExpenseTypeExpense, expense.Type)
})
}
func TestExpenseService_CreateExpense_EmptyDescription(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "",
Amount: 1000,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_CreateExpense_ZeroAmount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
expense, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: "some-space",
UserID: "some-user",
Description: "Something",
Amount: 0,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
assert.Error(t, err)
assert.Nil(t, expense)
})
}
func TestExpenseService_GetExpensesWithTagsForSpacePaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Paginate Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Transport", nil)
// Create expense with tag via the service
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Bus fare",
Amount: 250,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
// Create expense without tag
_, err = svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Coffee",
Amount: 500,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
results, totalPages, err := svc.GetExpensesWithTagsForSpacePaginated(space.ID, 1)
require.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, 1, totalPages)
// Verify at least one result has tags and one does not
var withTags, withoutTags int
for _, r := range results {
if len(r.Tags) > 0 {
withTags++
} else {
withoutTags++
}
}
assert.Equal(t, 1, withTags)
assert.Equal(t, 1, withoutTags)
})
}
func TestExpenseService_GetBalanceForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-balance@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Balance Space")
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Topup", 10000, model.ExpenseTypeTopup)
testutil.CreateTestExpense(t, dbi.DB, space.ID, user.ID, "Groceries", 3000, model.ExpenseTypeExpense)
balance, err := svc.GetBalanceForSpace(space.ID)
require.NoError(t, err)
assert.Equal(t, 7000, balance)
})
}
func TestExpenseService_GetExpensesByTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-bytag@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc ByTag Space")
tagColor := "#ff0000"
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Dining", &tagColor)
now := time.Now()
_, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Dinner",
Amount: 2500,
Type: model.ExpenseTypeExpense,
Date: now,
TagIDs: []string{tag.ID},
})
require.NoError(t, err)
fromDate := now.Add(-24 * time.Hour)
toDate := now.Add(24 * time.Hour)
summaries, err := svc.GetExpensesByTag(space.ID, fromDate, toDate)
require.NoError(t, err)
require.Len(t, summaries, 1)
assert.Equal(t, tag.ID, summaries[0].TagID)
assert.Equal(t, 2500, summaries[0].TotalAmount)
})
}
func TestExpenseService_UpdateExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Update Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Old Description",
Amount: 1000,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
updated, err := svc.UpdateExpense(UpdateExpenseDTO{
ID: created.ID,
SpaceID: space.ID,
Description: "New Description",
Amount: 2000,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, "New Description", updated.Description)
assert.Equal(t, 2000, updated.AmountCents)
})
}
func TestExpenseService_DeleteExpense(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
expenseRepo := repository.NewExpenseRepository(dbi.DB)
svc := NewExpenseService(expenseRepo)
user := testutil.CreateTestUser(t, dbi.DB, "exp-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Expense Svc Delete Space")
created, err := svc.CreateExpense(CreateExpenseDTO{
SpaceID: space.ID,
UserID: user.ID,
Description: "Doomed Expense",
Amount: 500,
Type: model.ExpenseTypeExpense,
Date: time.Now(),
})
require.NoError(t, err)
err = svc.DeleteExpense(created.ID, space.ID)
require.NoError(t, err)
_, err = svc.GetExpense(created.ID)
assert.Error(t, err)
})
}

View file

@ -0,0 +1,90 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestInviteService(dbi testutil.DBInfo) *InviteService {
inviteRepo := repository.NewInvitationRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB)
userRepo := repository.NewUserRepository(dbi.DB)
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
return NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
}
func TestInviteService_CreateInvite(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestInviteService(dbi)
owner := testutil.CreateTestUser(t, dbi.DB, "invite-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Invite Space")
invitation, err := svc.CreateInvite(space.ID, owner.ID, "invitee@example.com")
require.NoError(t, err)
assert.Equal(t, space.ID, invitation.SpaceID)
assert.Equal(t, owner.ID, invitation.InviterID)
assert.Equal(t, "invitee@example.com", invitation.Email)
assert.NotEmpty(t, invitation.Token)
})
}
func TestInviteService_AcceptInvite(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestInviteService(dbi)
owner := testutil.CreateTestUser(t, dbi.DB, "accept-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Accept Space")
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "acceptee@example.com")
// Create the user who will accept
accepter := testutil.CreateTestUser(t, dbi.DB, "acceptee@example.com", nil)
spaceID, err := svc.AcceptInvite(invitation.Token, accepter.ID)
require.NoError(t, err)
assert.Equal(t, space.ID, spaceID)
// Verify member was added to space
spaceRepo := repository.NewSpaceRepository(dbi.DB)
isMember, err := spaceRepo.IsMember(space.ID, accepter.ID)
require.NoError(t, err)
assert.True(t, isMember)
})
}
func TestInviteService_CancelInvite(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestInviteService(dbi)
owner := testutil.CreateTestUser(t, dbi.DB, "cancel-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Cancel Space")
invitation := testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "cancelee@example.com")
err := svc.CancelInvite(invitation.Token)
require.NoError(t, err)
// Verify invitation is gone
inviteRepo := repository.NewInvitationRepository(dbi.DB)
_, err = inviteRepo.GetByToken(invitation.Token)
assert.Error(t, err)
})
}
func TestInviteService_GetPendingInvites(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
svc := newTestInviteService(dbi)
owner := testutil.CreateTestUser(t, dbi.DB, "pending-owner@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Pending Space")
testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "pending1@example.com")
testutil.CreateTestInvitation(t, dbi.DB, space.ID, owner.ID, "pending2@example.com")
pending, err := svc.GetPendingInvites(space.ID)
require.NoError(t, err)
assert.Len(t, pending, 2)
})
}

View file

@ -0,0 +1,189 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMoneyAccountService_CreateAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Space")
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: space.ID,
Name: "Savings",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, account.ID)
assert.Equal(t, "Savings", account.Name)
assert.Equal(t, space.ID, account.SpaceID)
})
}
func TestMoneyAccountService_CreateAccount_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
account, err := svc.CreateAccount(CreateMoneyAccountDTO{
SpaceID: "some-space",
Name: "",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, account)
})
}
func TestMoneyAccountService_GetAccountsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc List Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Checking", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, "Checking", accounts[0].Name)
assert.Equal(t, 5000, accounts[0].BalanceCents)
})
}
func TestMoneyAccountService_CreateTransfer_Deposit(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deposit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Deposit Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Deposit Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 3000,
Direction: model.TransferDirectionDeposit,
Note: "Initial deposit",
CreatedBy: user.ID,
}, 10000)
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.Equal(t, 3000, transfer.AmountCents)
assert.Equal(t, model.TransferDirectionDeposit, transfer.Direction)
})
}
func TestMoneyAccountService_CreateTransfer_InsufficientBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-insuf@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Insuf Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Insuf Account", user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 5000,
Direction: model.TransferDirectionDeposit,
Note: "Too much",
CreatedBy: user.ID,
}, 1000)
assert.Error(t, err)
assert.Nil(t, transfer)
})
}
func TestMoneyAccountService_CreateTransfer_Withdrawal(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-withdraw@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Withdraw Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Withdraw Account", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account.ID, 5000, model.TransferDirectionDeposit, user.ID)
transfer, err := svc.CreateTransfer(CreateTransferDTO{
AccountID: account.ID,
Amount: 2000,
Direction: model.TransferDirectionWithdrawal,
Note: "Withdrawal",
CreatedBy: user.ID,
}, 0)
require.NoError(t, err)
assert.NotEmpty(t, transfer.ID)
assert.Equal(t, 2000, transfer.AmountCents)
assert.Equal(t, model.TransferDirectionWithdrawal, transfer.Direction)
})
}
func TestMoneyAccountService_GetTotalAllocatedForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-total@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Total Space")
account1 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 1", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account1.ID, 3000, model.TransferDirectionDeposit, user.ID)
account2 := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Account 2", user.ID)
testutil.CreateTestTransfer(t, dbi.DB, account2.ID, 2000, model.TransferDirectionDeposit, user.ID)
total, err := svc.GetTotalAllocatedForSpace(space.ID)
require.NoError(t, err)
assert.Equal(t, 5000, total)
})
}
func TestMoneyAccountService_DeleteAccount(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc Del Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "Doomed Account", user.ID)
err := svc.DeleteAccount(account.ID)
require.NoError(t, err)
accounts, err := svc.GetAccountsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, accounts)
})
}
func TestMoneyAccountService_DeleteTransfer(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewMoneyAccountRepository(dbi.DB)
svc := NewMoneyAccountService(accountRepo)
user := testutil.CreateTestUser(t, dbi.DB, "acct-svc-deltx@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Account Svc DelTx Space")
account := testutil.CreateTestMoneyAccount(t, dbi.DB, space.ID, "DelTx Account", user.ID)
transfer := testutil.CreateTestTransfer(t, dbi.DB, account.ID, 1000, model.TransferDirectionDeposit, user.ID)
err := svc.DeleteTransfer(transfer.ID)
require.NoError(t, err)
transfers, err := svc.GetTransfersForAccount(account.ID)
require.NoError(t, err)
assert.Empty(t, transfers)
})
}

View file

@ -0,0 +1,144 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPaymentMethodService_CreateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Space")
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: space.ID,
Name: "Visa Card",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: user.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, method.ID)
assert.Equal(t, "Visa Card", method.Name)
assert.Equal(t, model.PaymentMethodTypeCredit, method.Type)
require.NotNil(t, method.LastFour)
assert.Equal(t, "4242", *method.LastFour)
})
}
func TestPaymentMethodService_CreateMethod_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "",
Type: model.PaymentMethodTypeCredit,
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidType(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Bad Type Card",
Type: "invalid",
LastFour: "4242",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_CreateMethod_InvalidLastFour(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
method, err := svc.CreateMethod(CreatePaymentMethodDTO{
SpaceID: "some-space",
Name: "Short Digits Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "12",
CreatedBy: "some-user",
})
assert.Error(t, err)
assert.Nil(t, method)
})
}
func TestPaymentMethodService_GetMethodsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc List Space")
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Visa", model.PaymentMethodTypeCredit, user.ID)
testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Debit", model.PaymentMethodTypeDebit, user.ID)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Len(t, methods, 2)
})
}
func TestPaymentMethodService_UpdateMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Update Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Old Card", model.PaymentMethodTypeCredit, user.ID)
updated, err := svc.UpdateMethod(UpdatePaymentMethodDTO{
ID: method.ID,
Name: "New Card",
Type: model.PaymentMethodTypeDebit,
LastFour: "9999",
})
require.NoError(t, err)
assert.Equal(t, "New Card", updated.Name)
assert.Equal(t, model.PaymentMethodTypeDebit, updated.Type)
require.NotNil(t, updated.LastFour)
assert.Equal(t, "9999", *updated.LastFour)
})
}
func TestPaymentMethodService_DeleteMethod(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
methodRepo := repository.NewPaymentMethodRepository(dbi.DB)
svc := NewPaymentMethodService(methodRepo)
user := testutil.CreateTestUser(t, dbi.DB, "pm-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "PM Svc Delete Space")
method := testutil.CreateTestPaymentMethod(t, dbi.DB, space.ID, "Doomed Card", model.PaymentMethodTypeCredit, user.ID)
err := svc.DeleteMethod(method.ID)
require.NoError(t, err)
methods, err := svc.GetMethodsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, methods)
})
}

View file

@ -0,0 +1,35 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProfileService_ByUserID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
user, profile := testutil.CreateTestUserWithProfile(t, dbi.DB, "profile@example.com", "Test User")
got, err := svc.ByUserID(user.ID)
require.NoError(t, err)
assert.Equal(t, profile.ID, got.ID)
assert.Equal(t, user.ID, got.UserID)
assert.Equal(t, "Test User", got.Name)
})
}
func TestProfileService_ByUserID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
profileRepo := repository.NewProfileRepository(dbi.DB)
svc := NewProfileService(profileRepo)
_, err := svc.ByUserID("nonexistent-id")
assert.Error(t, err)
})
}

View file

@ -0,0 +1,204 @@
package service
import (
"fmt"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShoppingListService_CreateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Space")
list, err := svc.CreateList(space.ID, "Weekly Groceries")
require.NoError(t, err)
assert.NotEmpty(t, list.ID)
assert.Equal(t, "Weekly Groceries", list.Name)
assert.Equal(t, space.ID, list.SpaceID)
})
}
func TestShoppingListService_CreateList_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Empty Space")
list, err := svc.CreateList(space.ID, "")
assert.Error(t, err)
assert.Nil(t, list)
})
}
func TestShoppingListService_GetList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-get@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Get Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Seeded List")
list, err := svc.GetList(seeded.ID)
require.NoError(t, err)
assert.Equal(t, seeded.ID, list.ID)
assert.Equal(t, "Seeded List", list.Name)
})
}
func TestShoppingListService_UpdateList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Update Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Old Name")
updated, err := svc.UpdateList(seeded.ID, "New Name")
require.NoError(t, err)
assert.Equal(t, "New Name", updated.Name)
})
}
func TestShoppingListService_DeleteList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-del@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Del Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Doomed List")
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 1", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Item 2", user.ID)
err := svc.DeleteList(seeded.ID)
require.NoError(t, err)
_, err = svc.GetList(seeded.ID)
assert.Error(t, err)
items, err := itemRepo.GetByListID(seeded.ID)
require.NoError(t, err)
assert.Empty(t, items)
})
}
func TestShoppingListService_AddItemToList(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-additem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc AddItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Add Item List")
item, err := svc.AddItemToList(seeded.ID, "Milk", user.ID)
require.NoError(t, err)
assert.NotEmpty(t, item.ID)
assert.Equal(t, "Milk", item.Name)
assert.Equal(t, seeded.ID, item.ListID)
assert.False(t, item.IsChecked)
})
}
func TestShoppingListService_GetItemsForListPaginated(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-paginate@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Paginate Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Paginate List")
for i := 0; i < 6; i++ {
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, fmt.Sprintf("Item %d", i), user.ID)
}
items, totalPages, err := svc.GetItemsForListPaginated(seeded.ID, 1)
require.NoError(t, err)
assert.Len(t, items, 5)
assert.Equal(t, 2, totalPages)
})
}
func TestShoppingListService_CheckItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-check@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Check Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Check List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Check Me", user.ID)
err := svc.CheckItem(item.ID)
require.NoError(t, err)
fetched, err := svc.GetItem(item.ID)
require.NoError(t, err)
assert.True(t, fetched.IsChecked)
})
}
func TestShoppingListService_GetListsWithUncheckedItems(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-unchecked@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc Unchecked Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "Unchecked List")
checkedItem := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Checked Item", user.ID)
testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Unchecked Item", user.ID)
_, err := dbi.DB.Exec("UPDATE list_items SET is_checked = true WHERE id = $1", checkedItem.ID)
require.NoError(t, err)
result, err := svc.GetListsWithUncheckedItems(space.ID)
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, seeded.ID, result[0].List.ID)
require.Len(t, result[0].Items, 1)
assert.Equal(t, "Unchecked Item", result[0].Items[0].Name)
})
}
func TestShoppingListService_DeleteItem(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
listRepo := repository.NewShoppingListRepository(dbi.DB)
itemRepo := repository.NewListItemRepository(dbi.DB)
svc := NewShoppingListService(listRepo, itemRepo)
user := testutil.CreateTestUser(t, dbi.DB, "list-svc-delitem@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "List Svc DelItem Space")
seeded := testutil.CreateTestShoppingList(t, dbi.DB, space.ID, "DelItem List")
item := testutil.CreateTestListItem(t, dbi.DB, seeded.ID, "Doomed Item", user.ID)
err := svc.DeleteItem(item.ID)
require.NoError(t, err)
_, err = svc.GetItem(item.ID)
assert.Error(t, err)
})
}

View file

@ -0,0 +1,172 @@
package service
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSpaceService_CreateSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "create-space@example.com", nil)
space, err := svc.CreateSpace("My Space", user.ID)
require.NoError(t, err)
assert.Equal(t, "My Space", space.Name)
assert.Equal(t, user.ID, space.OwnerID)
assert.NotEmpty(t, space.ID)
})
}
func TestSpaceService_CreateSpace_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "empty-name@example.com", nil)
_, err := svc.CreateSpace("", user.ID)
assert.Error(t, err)
})
}
func TestSpaceService_EnsurePersonalSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "personal@example.com", nil)
// First call creates the personal space
space1, err := svc.EnsurePersonalSpace(user)
require.NoError(t, err)
assert.Equal(t, PersonalSpaceName, space1.Name)
// Second call returns the same space (idempotent)
space2, err := svc.EnsurePersonalSpace(user)
require.NoError(t, err)
assert.Equal(t, space1.ID, space2.ID)
})
}
func TestSpaceService_GetSpacesForUser(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "getspaces@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Test Space")
spaces, err := svc.GetSpacesForUser(user.ID)
require.NoError(t, err)
require.Len(t, spaces, 1)
assert.Equal(t, space.ID, spaces[0].ID)
assert.Equal(t, "Test Space", spaces[0].Name)
})
}
func TestSpaceService_IsMember(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "ismember@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Member Check Space")
// Owner should be a member
isMember, err := svc.IsMember(user.ID, space.ID)
require.NoError(t, err)
assert.True(t, isMember)
// Random ID should not be a member
isMember, err = svc.IsMember("nonexistent-user-id", space.ID)
require.NoError(t, err)
assert.False(t, isMember)
})
}
func TestSpaceService_GetMembers(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
owner, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "owner-members@example.com", "Owner")
member, _ := testutil.CreateTestUserWithProfile(t, dbi.DB, "member-members@example.com", "Member")
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Members Space")
// Add second user as a member
_, err := dbi.DB.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4)`,
space.ID, member.ID, model.RoleMember, time.Now(),
)
require.NoError(t, err)
members, err := svc.GetMembers(space.ID)
require.NoError(t, err)
require.Len(t, members, 2)
// The query orders by role DESC (owner first), then joined_at ASC
assert.Equal(t, model.RoleOwner, members[0].Role)
assert.Equal(t, "Owner", members[0].Name)
assert.Equal(t, model.RoleMember, members[1].Role)
assert.Equal(t, "Member", members[1].Name)
})
}
func TestSpaceService_RemoveMember(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
owner := testutil.CreateTestUser(t, dbi.DB, "remove-owner@example.com", nil)
member := testutil.CreateTestUser(t, dbi.DB, "remove-member@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, owner.ID, "Remove Space")
// Add member
_, err := dbi.DB.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4)`,
space.ID, member.ID, model.RoleMember, time.Now(),
)
require.NoError(t, err)
// Verify member was added
isMember, err := svc.IsMember(member.ID, space.ID)
require.NoError(t, err)
assert.True(t, isMember)
// Remove member
err = svc.RemoveMember(space.ID, member.ID)
require.NoError(t, err)
// Verify member was removed
isMember, err = svc.IsMember(member.ID, space.ID)
require.NoError(t, err)
assert.False(t, isMember)
})
}
func TestSpaceService_UpdateSpaceName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
spaceRepo := repository.NewSpaceRepository(dbi.DB)
svc := NewSpaceService(spaceRepo)
user := testutil.CreateTestUser(t, dbi.DB, "rename@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Old Name")
err := svc.UpdateSpaceName(space.ID, "New Name")
require.NoError(t, err)
// Verify name was updated by fetching the space
fetched, err := svc.GetSpace(space.ID)
require.NoError(t, err)
assert.Equal(t, "New Name", fetched.Name)
})
}

View file

@ -0,0 +1,99 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagService_CreateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Space")
color := "#ff0000"
tag, err := svc.CreateTag(space.ID, "Groceries", &color)
require.NoError(t, err)
assert.NotEmpty(t, tag.ID)
assert.Equal(t, "groceries", tag.Name)
assert.Equal(t, &color, tag.Color)
assert.Equal(t, space.ID, tag.SpaceID)
})
}
func TestTagService_CreateTag_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-empty@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Empty Space")
tag, err := svc.CreateTag(space.ID, "", nil)
assert.Error(t, err)
assert.Nil(t, tag)
})
}
func TestTagService_GetTagsForSpace(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-list@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc List Space")
testutil.CreateTestTag(t, dbi.DB, space.ID, "Alpha", nil)
testutil.CreateTestTag(t, dbi.DB, space.ID, "Beta", nil)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
require.Len(t, tags, 2)
})
}
func TestTagService_UpdateTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-update@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Update Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Old Name", nil)
newColor := "#00ff00"
updated, err := svc.UpdateTag(tag.ID, "New Name", &newColor)
require.NoError(t, err)
assert.Equal(t, "new name", updated.Name)
assert.Equal(t, &newColor, updated.Color)
})
}
func TestTagService_DeleteTag(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
tagRepo := repository.NewTagRepository(dbi.DB)
svc := NewTagService(tagRepo)
user := testutil.CreateTestUser(t, dbi.DB, "tag-svc-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Tag Svc Delete Space")
tag := testutil.CreateTestTag(t, dbi.DB, space.ID, "Doomed Tag", nil)
err := svc.DeleteTag(tag.ID)
require.NoError(t, err)
tags, err := svc.GetTagsForSpace(space.ID)
require.NoError(t, err)
assert.Empty(t, tags)
})
}
func TestNormalizeTagName(t *testing.T) {
result := NormalizeTagName(" Hello World ")
assert.Equal(t, "hello world", result)
}

View file

@ -0,0 +1,34 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserService_ByID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
userRepo := repository.NewUserRepository(dbi.DB)
svc := NewUserService(userRepo)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
got, err := svc.ByID(user.ID)
require.NoError(t, err)
assert.Equal(t, user.ID, got.ID)
assert.Equal(t, user.Email, got.Email)
})
}
func TestUserService_ByID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
userRepo := repository.NewUserRepository(dbi.DB)
svc := NewUserService(userRepo)
_, err := svc.ByID("nonexistent-id")
assert.Error(t, err)
})
}

75
internal/testutil/http.go Normal file
View file

@ -0,0 +1,75 @@
package testutil
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/config"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
// TestConfig returns a minimal config for tests. No env vars needed.
func TestConfig() *config.Config {
return &config.Config{
AppName: "Budgit Test",
AppTagline: "Test tagline",
AppEnv: "test",
AppURL: "http://localhost:9999",
Host: "127.0.0.1",
Port: "9999",
DBDriver: "sqlite",
DBConnection: ":memory:",
JWTSecret: "test-secret-key-for-testing-only",
JWTExpiry: 24 * time.Hour,
TokenMagicLinkExpiry: 10 * time.Minute,
Version: "test",
}
}
// AuthenticatedContext returns a context with user, profile, config, and CSRF token injected.
func AuthenticatedContext(user *model.User, profile *model.Profile) context.Context {
ctx := context.Background()
ctx = ctxkeys.WithUser(ctx, user)
ctx = ctxkeys.WithProfile(ctx, profile)
ctx = ctxkeys.WithConfig(ctx, TestConfig().Sanitized())
ctx = ctxkeys.WithCSRFToken(ctx, "test-csrf-token")
ctx = ctxkeys.WithAppVersion(ctx, "test")
return ctx
}
// NewAuthenticatedRequest creates an HTTP request with auth context and optional form values.
// CSRF token is automatically added to form values for POST requests.
func NewAuthenticatedRequest(t *testing.T, method, target string, user *model.User, profile *model.Profile, formValues url.Values) *http.Request {
t.Helper()
var req *http.Request
if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete {
if formValues == nil {
formValues = url.Values{}
}
formValues.Set("csrf_token", "test-csrf-token")
body := strings.NewReader(formValues.Encode())
req = httptest.NewRequest(method, target, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := AuthenticatedContext(user, profile)
req = req.WithContext(ctx)
return req
}
// NewHTMXRequest adds HX-Request header to a request.
func NewHTMXRequest(req *http.Request) *http.Request {
req.Header.Set("HX-Request", "true")
return req
}

293
internal/testutil/seed.go Normal file
View file

@ -0,0 +1,293 @@
package testutil
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// CreateTestUser inserts a user directly into the database.
func CreateTestUser(t *testing.T, db *sqlx.DB, email string, passwordHash *string) *model.User {
t.Helper()
user := &model.User{
ID: uuid.NewString(),
Email: email,
PasswordHash: passwordHash,
CreatedAt: time.Now(),
}
_, err := db.Exec(
`INSERT INTO users (id, email, password_hash, email_verified_at, created_at) VALUES ($1, $2, $3, $4, $5)`,
user.ID, user.Email, user.PasswordHash, user.EmailVerifiedAt, user.CreatedAt,
)
if err != nil {
t.Fatalf("CreateTestUser: %v", err)
}
return user
}
// CreateTestProfile inserts a profile directly into the database.
func CreateTestProfile(t *testing.T, db *sqlx.DB, userID, name string) *model.Profile {
t.Helper()
now := time.Now()
profile := &model.Profile{
ID: uuid.NewString(),
UserID: userID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO profiles (id, user_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
profile.ID, profile.UserID, profile.Name, profile.CreatedAt, profile.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestProfile: %v", err)
}
return profile
}
// CreateTestUserWithProfile creates both a user and a profile.
func CreateTestUserWithProfile(t *testing.T, db *sqlx.DB, email, name string) (*model.User, *model.Profile) {
t.Helper()
user := CreateTestUser(t, db, email, nil)
profile := CreateTestProfile(t, db, user.ID, name)
return user, profile
}
// CreateTestSpace inserts a space and adds the owner as a member.
func CreateTestSpace(t *testing.T, db *sqlx.DB, ownerID, name string) *model.Space {
t.Helper()
now := time.Now()
space := &model.Space{
ID: uuid.NewString(),
Name: name,
OwnerID: ownerID,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO spaces (id, name, owner_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
space.ID, space.Name, space.OwnerID, space.CreatedAt, space.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestSpace (space): %v", err)
}
_, err = db.Exec(
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES ($1, $2, $3, $4)`,
space.ID, ownerID, model.RoleOwner, now,
)
if err != nil {
t.Fatalf("CreateTestSpace (member): %v", err)
}
return space
}
// CreateTestTag inserts a tag directly into the database.
func CreateTestTag(t *testing.T, db *sqlx.DB, spaceID, name string, color *string) *model.Tag {
t.Helper()
now := time.Now()
tag := &model.Tag{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Color: color,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO tags (id, space_id, name, color, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
tag.ID, tag.SpaceID, tag.Name, tag.Color, tag.CreatedAt, tag.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestTag: %v", err)
}
return tag
}
// CreateTestShoppingList inserts a shopping list directly into the database.
func CreateTestShoppingList(t *testing.T, db *sqlx.DB, spaceID, name string) *model.ShoppingList {
t.Helper()
now := time.Now()
list := &model.ShoppingList{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO shopping_lists (id, space_id, name, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`,
list.ID, list.SpaceID, list.Name, list.CreatedAt, list.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestShoppingList: %v", err)
}
return list
}
// CreateTestListItem inserts a list item directly into the database.
func CreateTestListItem(t *testing.T, db *sqlx.DB, listID, name, createdBy string) *model.ListItem {
t.Helper()
now := time.Now()
item := &model.ListItem{
ID: uuid.NewString(),
ListID: listID,
Name: name,
IsChecked: false,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO list_items (id, list_id, name, is_checked, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
item.ID, item.ListID, item.Name, item.IsChecked, item.CreatedBy, item.CreatedAt, item.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestListItem: %v", err)
}
return item
}
// CreateTestExpense inserts an expense directly into the database.
func CreateTestExpense(t *testing.T, db *sqlx.DB, spaceID, userID, desc string, amount int, typ model.ExpenseType) *model.Expense {
t.Helper()
now := time.Now()
expense := &model.Expense{
ID: uuid.NewString(),
SpaceID: spaceID,
CreatedBy: userID,
Description: desc,
AmountCents: amount,
Type: typ,
Date: now,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, payment_method_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents,
expense.Type, expense.Date, expense.PaymentMethodID, expense.CreatedAt, expense.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestExpense: %v", err)
}
return expense
}
// CreateTestMoneyAccount inserts a money account directly into the database.
func CreateTestMoneyAccount(t *testing.T, db *sqlx.DB, spaceID, name, createdBy string) *model.MoneyAccount {
t.Helper()
now := time.Now()
account := &model.MoneyAccount{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO money_accounts (id, space_id, name, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)`,
account.ID, account.SpaceID, account.Name, account.CreatedBy, account.CreatedAt, account.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestMoneyAccount: %v", err)
}
return account
}
// CreateTestTransfer inserts an account transfer directly into the database.
func CreateTestTransfer(t *testing.T, db *sqlx.DB, accountID string, amount int, direction model.TransferDirection, createdBy string) *model.AccountTransfer {
t.Helper()
transfer := &model.AccountTransfer{
ID: uuid.NewString(),
AccountID: accountID,
AmountCents: amount,
Direction: direction,
Note: "test transfer",
CreatedBy: createdBy,
CreatedAt: time.Now(),
}
_, err := db.Exec(
`INSERT INTO account_transfers (id, account_id, amount_cents, direction, note, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
transfer.ID, transfer.AccountID, transfer.AmountCents, transfer.Direction, transfer.Note, transfer.CreatedBy, transfer.CreatedAt,
)
if err != nil {
t.Fatalf("CreateTestTransfer: %v", err)
}
return transfer
}
// CreateTestPaymentMethod inserts a payment method directly into the database.
func CreateTestPaymentMethod(t *testing.T, db *sqlx.DB, spaceID, name string, typ model.PaymentMethodType, createdBy string) *model.PaymentMethod {
t.Helper()
lastFour := "1234"
now := time.Now()
method := &model.PaymentMethod{
ID: uuid.NewString(),
SpaceID: spaceID,
Name: name,
Type: typ,
LastFour: &lastFour,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO payment_methods (id, space_id, name, type, last_four, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
method.ID, method.SpaceID, method.Name, method.Type, method.LastFour, method.CreatedBy, method.CreatedAt, method.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestPaymentMethod: %v", err)
}
return method
}
// CreateTestToken inserts a token directly into the database.
func CreateTestToken(t *testing.T, db *sqlx.DB, userID, tokenType, tokenString string, expiresAt time.Time) *model.Token {
t.Helper()
token := &model.Token{
ID: uuid.NewString(),
UserID: userID,
Type: tokenType,
Token: tokenString,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}
_, err := db.Exec(
`INSERT INTO tokens (id, user_id, type, token, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)`,
token.ID, token.UserID, token.Type, token.Token, token.ExpiresAt, token.CreatedAt,
)
if err != nil {
t.Fatalf("CreateTestToken: %v", err)
}
return token
}
// CreateTestInvitation inserts a space invitation directly into the database.
func CreateTestInvitation(t *testing.T, db *sqlx.DB, spaceID, inviterID, email string) *model.SpaceInvitation {
t.Helper()
now := time.Now()
invitation := &model.SpaceInvitation{
Token: uuid.NewString(),
SpaceID: spaceID,
InviterID: inviterID,
Email: email,
Status: model.InvitationStatusPending,
ExpiresAt: now.Add(48 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
}
_, err := db.Exec(
`INSERT INTO space_invitations (token, space_id, inviter_id, email, status, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
invitation.Token, invitation.SpaceID, invitation.InviterID, invitation.Email,
invitation.Status, invitation.ExpiresAt, invitation.CreatedAt, invitation.UpdatedAt,
)
if err != nil {
t.Fatalf("CreateTestInvitation: %v", err)
}
return invitation
}

View file

@ -0,0 +1,121 @@
package testutil
import (
"fmt"
"os"
"strings"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/db"
"github.com/jmoiron/sqlx"
)
// DBInfo holds a test database connection and its driver name.
type DBInfo struct {
DB *sqlx.DB
Driver string
}
// ForEachDB runs the given test function against both SQLite and PostgreSQL.
// PostgreSQL tests are skipped when BUDGIT_TEST_POSTGRES_URL is unset.
func ForEachDB(t *testing.T, fn func(t *testing.T, dbi DBInfo)) {
t.Helper()
t.Run("sqlite", func(t *testing.T) {
t.Parallel()
dbi := newSQLiteDB(t)
fn(t, dbi)
})
pgURL := os.Getenv("BUDGIT_TEST_POSTGRES_URL")
if pgURL == "" {
t.Log("skipping postgres tests: BUDGIT_TEST_POSTGRES_URL not set")
return
}
t.Run("postgres", func(t *testing.T) {
t.Parallel()
dbi := newPostgresDB(t, pgURL)
fn(t, dbi)
})
}
func newSQLiteDB(t *testing.T) DBInfo {
t.Helper()
// Use a unique in-memory database per test via a unique DSN.
// Each file::memory:?cache=shared&name=X uses a separate in-memory DB.
safeName := strings.ReplaceAll(t.Name(), "/", "_")
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_pragma=foreign_keys(1)", safeName)
sqliteDB, err := sqlx.Connect("sqlite", dsn)
if err != nil {
t.Fatalf("failed to connect to sqlite: %v", err)
}
// SQLite in-memory DBs are destroyed when the last connection closes.
// Keep at least one open so it survives the test.
sqliteDB.SetMaxOpenConns(1)
t.Cleanup(func() { sqliteDB.Close() })
err = db.RunMigrations(sqliteDB.DB, "sqlite")
if err != nil {
t.Fatalf("failed to run sqlite migrations: %v", err)
}
return DBInfo{DB: sqliteDB, Driver: "sqlite"}
}
func newPostgresDB(t *testing.T, baseURL string) DBInfo {
t.Helper()
// Create a unique schema per test to ensure isolation.
safeName := strings.ReplaceAll(t.Name(), "/", "_")
safeName = strings.ReplaceAll(safeName, " ", "_")
schema := fmt.Sprintf("test_%s", safeName)
// Connect to the base database to create the schema.
baseDB, err := sqlx.Connect("pgx", baseURL)
if err != nil {
t.Fatalf("failed to connect to postgres: %v", err)
}
_, err = baseDB.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %q", schema))
if err != nil {
baseDB.Close()
t.Fatalf("failed to create schema %s: %v", schema, err)
}
baseDB.Close()
// Connect with a single-connection pool and set search_path to the new schema.
// MaxOpenConns(1) ensures all queries reuse the same connection where
// search_path is set (SET is session-level in PostgreSQL).
pgDB, err := sqlx.Connect("pgx", baseURL)
if err != nil {
t.Fatalf("failed to connect to postgres with schema: %v", err)
}
pgDB.SetMaxOpenConns(1)
_, err = pgDB.Exec(fmt.Sprintf(`SET search_path TO "%s"`, schema))
if err != nil {
pgDB.Close()
t.Fatalf("failed to set search_path to %s: %v", schema, err)
}
t.Cleanup(func() {
pgDB.Close()
// Drop the schema after the test.
cleanDB, err := sqlx.Connect("pgx", baseURL)
if err == nil {
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schema))
cleanDB.Close()
}
})
err = db.RunMigrations(pgDB.DB, "pgx")
if err != nil {
t.Fatalf("failed to run postgres migrations: %v", err)
}
return DBInfo{DB: pgDB, Driver: "pgx"}
}