feat: tests
This commit is contained in:
parent
3de76916c9
commit
1346abf733
32 changed files with 3772 additions and 11 deletions
75
internal/testutil/http.go
Normal file
75
internal/testutil/http.go
Normal 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
293
internal/testutil/seed.go
Normal 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
|
||||
}
|
||||
121
internal/testutil/testutil.go
Normal file
121
internal/testutil/testutil.go
Normal 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"}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue