feat: drop sqlite support
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s

This commit is contained in:
juancwu 2026-05-04 00:29:45 +00:00
commit da718427bd
27 changed files with 1296 additions and 115 deletions

View file

@ -0,0 +1,213 @@
package service
import (
"errors"
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubSpaceAuditRepo serves canned responses for the activity-merger tests so we can
// focus on the merge/sort/pagination logic without a real DB.
type stubSpaceAuditRepo struct {
listAccount []*model.SpaceAuditLogWithActor
countAccount int
listSpace []*model.SpaceAuditLogWithActor
countSpace int
err error
}
func (s *stubSpaceAuditRepo) Create(*model.SpaceAuditLog) error { return nil }
func (s *stubSpaceAuditRepo) ListBySpace(_ string, limit, _ int) ([]*model.SpaceAuditLogWithActor, error) {
if s.err != nil {
return nil, s.err
}
return firstN(s.listSpace, limit), nil
}
func (s *stubSpaceAuditRepo) CountBySpace(string) (int, error) { return s.countSpace, s.err }
func (s *stubSpaceAuditRepo) ListAccountEvents(_ string, limit, _ int) ([]*model.SpaceAuditLogWithActor, error) {
if s.err != nil {
return nil, s.err
}
return firstN(s.listAccount, limit), nil
}
func (s *stubSpaceAuditRepo) CountAccountEvents(string) (int, error) { return s.countAccount, s.err }
type stubTxAuditRepo struct {
listAccount []*model.TransactionAuditLogWithActor
countAccount int
listSpace []*model.TransactionAuditLogWithActor
countSpace int
err error
}
func (s *stubTxAuditRepo) Create(*model.TransactionAuditLog) error { return nil }
func (s *stubTxAuditRepo) ListByTransaction(string, int, int) ([]*model.TransactionAuditLogWithActor, error) {
return nil, nil
}
func (s *stubTxAuditRepo) CountByTransaction(string) (int, error) { return 0, nil }
func (s *stubTxAuditRepo) ListByAccount(_ string, limit, _ int) ([]*model.TransactionAuditLogWithActor, error) {
if s.err != nil {
return nil, s.err
}
return firstNTx(s.listAccount, limit), nil
}
func (s *stubTxAuditRepo) CountByAccount(string) (int, error) { return s.countAccount, s.err }
func (s *stubTxAuditRepo) ListBySpace(_ string, limit, _ int) ([]*model.TransactionAuditLogWithActor, error) {
if s.err != nil {
return nil, s.err
}
return firstNTx(s.listSpace, limit), nil
}
func (s *stubTxAuditRepo) CountBySpace(string) (int, error) { return s.countSpace, s.err }
func firstN(s []*model.SpaceAuditLogWithActor, n int) []*model.SpaceAuditLogWithActor {
if n >= len(s) {
return s
}
return s[:n]
}
func firstNTx(s []*model.TransactionAuditLogWithActor, n int) []*model.TransactionAuditLogWithActor {
if n >= len(s) {
return s
}
return s[:n]
}
func spaceLog(action model.SpaceAuditAction, ts time.Time) *model.SpaceAuditLogWithActor {
return &model.SpaceAuditLogWithActor{
SpaceAuditLog: model.SpaceAuditLog{Action: action, CreatedAt: ts},
}
}
func txLog(action model.TransactionAuditAction, ts time.Time) *model.TransactionAuditLogWithActor {
return &model.TransactionAuditLogWithActor{
TransactionAuditLog: model.TransactionAuditLog{Action: action, CreatedAt: ts},
}
}
func TestAccountActivityService_List_MergesAndSortsByTimestamp(t *testing.T) {
now := time.Now()
spaceRepo := &stubSpaceAuditRepo{
listAccount: []*model.SpaceAuditLogWithActor{
spaceLog(model.SpaceAuditActionAccountRenamed, now.Add(-1*time.Minute)),
spaceLog(model.SpaceAuditActionAccountCreated, now.Add(-10*time.Minute)),
},
countAccount: 2,
}
txRepo := &stubTxAuditRepo{
listAccount: []*model.TransactionAuditLogWithActor{
txLog(model.TransactionAuditActionEdited, now), // newest overall
txLog(model.TransactionAuditActionCreated, now.Add(-5*time.Minute)),
txLog(model.TransactionAuditActionDeleted, now.Add(-15*time.Minute)), // oldest overall
},
countAccount: 3,
}
svc := NewAccountActivityService(NewSpaceAuditLogService(spaceRepo), NewTransactionAuditLogService(txRepo))
rows, err := svc.List("acct-1", 10, 0)
require.NoError(t, err)
require.Len(t, rows, 5)
// Strictly descending by timestamp.
for i := 1; i < len(rows); i++ {
assert.False(t, rows[i].Timestamp().After(rows[i-1].Timestamp()),
"row %d (%v) is newer than row %d (%v)", i, rows[i].Timestamp(), i-1, rows[i-1].Timestamp())
}
// Top row is the transaction edit at `now`.
require.NotNil(t, rows[0].TxLog)
assert.Equal(t, model.TransactionAuditActionEdited, rows[0].TxLog.Action)
}
func TestAccountActivityService_List_Pagination(t *testing.T) {
now := time.Now()
spaceRepo := &stubSpaceAuditRepo{
listAccount: []*model.SpaceAuditLogWithActor{
spaceLog(model.SpaceAuditActionAccountCreated, now.Add(-30*time.Minute)),
},
}
txRepo := &stubTxAuditRepo{
listAccount: []*model.TransactionAuditLogWithActor{
txLog(model.TransactionAuditActionEdited, now.Add(-10*time.Minute)),
txLog(model.TransactionAuditActionEdited, now.Add(-20*time.Minute)),
txLog(model.TransactionAuditActionEdited, now.Add(-40*time.Minute)),
},
}
svc := NewAccountActivityService(NewSpaceAuditLogService(spaceRepo), NewTransactionAuditLogService(txRepo))
page1, err := svc.List("a", 2, 0)
require.NoError(t, err)
require.Len(t, page1, 2)
page2, err := svc.List("a", 2, 2)
require.NoError(t, err)
require.Len(t, page2, 2)
// Total of 4 entries; page2[1] is the oldest.
assert.Equal(t, now.Add(-40*time.Minute).Unix(), page2[1].Timestamp().Unix())
}
func TestAccountActivityService_List_OffsetPastEndReturnsEmpty(t *testing.T) {
svc := NewAccountActivityService(
NewSpaceAuditLogService(&stubSpaceAuditRepo{}),
NewTransactionAuditLogService(&stubTxAuditRepo{}),
)
rows, err := svc.List("a", 10, 100)
require.NoError(t, err)
assert.Nil(t, rows)
}
func TestAccountActivityService_Count_SumsBothSources(t *testing.T) {
svc := NewAccountActivityService(
NewSpaceAuditLogService(&stubSpaceAuditRepo{countAccount: 3}),
NewTransactionAuditLogService(&stubTxAuditRepo{countAccount: 7}),
)
count, err := svc.Count("a")
require.NoError(t, err)
assert.Equal(t, 10, count)
}
func TestAccountActivityService_List_PropagatesError(t *testing.T) {
svc := NewAccountActivityService(
NewSpaceAuditLogService(&stubSpaceAuditRepo{err: errors.New("boom")}),
NewTransactionAuditLogService(&stubTxAuditRepo{}),
)
_, err := svc.List("a", 10, 0)
assert.Error(t, err)
}
func TestAccountActivityService_ListSpace_MergesSpaceAndTxFeeds(t *testing.T) {
now := time.Now()
spaceRepo := &stubSpaceAuditRepo{
listSpace: []*model.SpaceAuditLogWithActor{
spaceLog(model.SpaceAuditActionRenamed, now.Add(-3*time.Minute)),
spaceLog(model.SpaceAuditActionMemberInvited, now.Add(-5*time.Minute)),
},
countSpace: 2,
}
txRepo := &stubTxAuditRepo{
listSpace: []*model.TransactionAuditLogWithActor{
txLog(model.TransactionAuditActionCreated, now),
txLog(model.TransactionAuditActionEdited, now.Add(-4*time.Minute)),
},
countSpace: 2,
}
svc := NewAccountActivityService(NewSpaceAuditLogService(spaceRepo), NewTransactionAuditLogService(txRepo))
rows, err := svc.ListSpace("space", 10, 0)
require.NoError(t, err)
require.Len(t, rows, 4)
require.NotNil(t, rows[0].TxLog, "newest is the tx-created row at `now`")
}
func TestAccountActivityService_CountSpace_SumsBothSources(t *testing.T) {
svc := NewAccountActivityService(
NewSpaceAuditLogService(&stubSpaceAuditRepo{countSpace: 4}),
NewTransactionAuditLogService(&stubTxAuditRepo{countSpace: 6}),
)
count, err := svc.CountSpace("s")
require.NoError(t, err)
assert.Equal(t, 10, count)
}

View file

@ -0,0 +1,112 @@
package service
import (
"encoding/json"
"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 TestAccountService_CreateAccount_RecordsAudit(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewAccountRepository(dbi.DB)
auditRepo := repository.NewSpaceAuditLogRepository(dbi.DB)
auditSvc := NewSpaceAuditLogService(auditRepo)
svc := NewAccountService(accountRepo)
svc.SetAuditLogger(auditSvc)
user := testutil.CreateTestUser(t, dbi.DB, "acct-create-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "Checking", user.ID)
require.NoError(t, err)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 1)
assert.Equal(t, model.SpaceAuditActionAccountCreated, logs[0].Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, account.ID, meta["account_id"])
assert.Equal(t, "Checking", meta["account_name"])
})
}
func TestAccountService_RenameAccount_RecordsAuditOnlyWhenChanged(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewAccountRepository(dbi.DB)
auditRepo := repository.NewSpaceAuditLogRepository(dbi.DB)
svc := NewAccountService(accountRepo)
svc.SetAuditLogger(NewSpaceAuditLogService(auditRepo))
user := testutil.CreateTestUser(t, dbi.DB, "acct-rename-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Old")
// Rename to a new value records an audit row.
require.NoError(t, svc.RenameAccount(account.ID, "New", user.ID))
// Renaming to the same value does not.
require.NoError(t, svc.RenameAccount(account.ID, "New", user.ID))
count, err := auditRepo.CountAccountEvents(account.ID)
require.NoError(t, err)
assert.Equal(t, 1, count)
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
require.NoError(t, err)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "Old", meta["old_name"])
assert.Equal(t, "New", meta["new_name"])
})
}
func TestAccountService_DeleteAccount_RecordsAuditBeforeDelete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
accountRepo := repository.NewAccountRepository(dbi.DB)
auditRepo := repository.NewSpaceAuditLogRepository(dbi.DB)
svc := NewAccountService(accountRepo)
svc.SetAuditLogger(NewSpaceAuditLogService(auditRepo))
user := testutil.CreateTestUser(t, dbi.DB, "acct-delete-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Target")
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
// Account is gone.
_, err := accountRepo.ByID(account.ID)
require.Error(t, err)
// Audit row still exists and captured the pre-delete name (no FK on metadata).
logs, err := auditRepo.ListAccountEvents(account.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 1)
assert.Equal(t, model.SpaceAuditActionAccountDeleted, logs[0].Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "Target", meta["account_name"])
})
}
func TestAccountService_NoAuditLoggerSet_DoesNotPanic(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
// SetAuditLogger is intentionally optional so existing tests/callers that
// don't care about audit don't have to wire it.
svc := NewAccountService(repository.NewAccountRepository(dbi.DB))
user := testutil.CreateTestUser(t, dbi.DB, "no-audit@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account, err := svc.CreateAccount(space.ID, "x", user.ID)
require.NoError(t, err)
require.NoError(t, svc.RenameAccount(account.ID, "y", user.ID))
require.NoError(t, svc.DeleteAccount(account.ID, user.ID))
})
}

View file

@ -0,0 +1,9 @@
package service
import (
"testing"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
)
func TestMain(m *testing.M) { testutil.PostgresMain(m) }

View file

@ -0,0 +1,100 @@
package service
import (
"encoding/json"
"errors"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeSpaceAuditRepo struct {
created []*model.SpaceAuditLog
failNext error
}
func (f *fakeSpaceAuditRepo) Create(log *model.SpaceAuditLog) error {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return err
}
f.created = append(f.created, log)
return nil
}
func (f *fakeSpaceAuditRepo) ListBySpace(string, int, int) ([]*model.SpaceAuditLogWithActor, error) {
return nil, nil
}
func (f *fakeSpaceAuditRepo) CountBySpace(string) (int, error) { return 0, nil }
func (f *fakeSpaceAuditRepo) ListAccountEvents(string, int, int) ([]*model.SpaceAuditLogWithActor, error) {
return nil, nil
}
func (f *fakeSpaceAuditRepo) CountAccountEvents(string) (int, error) { return 0, nil }
func TestSpaceAuditLogService_Record_PersistsEntry(t *testing.T) {
repo := &fakeSpaceAuditRepo{}
svc := NewSpaceAuditLogService(repo)
svc.Record(RecordOptions{
SpaceID: "space-1",
ActorID: "actor-1",
Action: model.SpaceAuditActionRenamed,
TargetUserID: "target-1",
TargetEmail: "target@example.com",
Metadata: map[string]any{"old_name": "A", "new_name": "B"},
})
require.Len(t, repo.created, 1)
got := repo.created[0]
assert.Equal(t, "space-1", got.SpaceID)
require.NotNil(t, got.ActorID)
assert.Equal(t, "actor-1", *got.ActorID)
require.NotNil(t, got.TargetUserID)
assert.Equal(t, "target-1", *got.TargetUserID)
require.NotNil(t, got.TargetEmail)
assert.Equal(t, "target@example.com", *got.TargetEmail)
assert.Equal(t, model.SpaceAuditActionRenamed, got.Action)
assert.NotEmpty(t, got.ID)
assert.False(t, got.CreatedAt.IsZero())
var meta map[string]any
require.NoError(t, json.Unmarshal(got.Metadata, &meta))
assert.Equal(t, "A", meta["old_name"])
assert.Equal(t, "B", meta["new_name"])
}
func TestSpaceAuditLogService_Record_OmitsBlankOptionalFields(t *testing.T) {
repo := &fakeSpaceAuditRepo{}
svc := NewSpaceAuditLogService(repo)
svc.Record(RecordOptions{
SpaceID: "space-1",
Action: model.SpaceAuditActionDeleted,
})
require.Len(t, repo.created, 1)
got := repo.created[0]
assert.Nil(t, got.ActorID)
assert.Nil(t, got.TargetUserID)
assert.Nil(t, got.TargetEmail)
assert.Empty(t, got.Metadata)
}
func TestSpaceAuditLogService_Record_SwallowsRepoError(t *testing.T) {
// Audit failures must not bubble up to break the user's action.
repo := &fakeSpaceAuditRepo{failNext: errors.New("boom")}
svc := NewSpaceAuditLogService(repo)
assert.NotPanics(t, func() {
svc.Record(RecordOptions{SpaceID: "s", Action: model.SpaceAuditActionRenamed})
})
assert.Empty(t, repo.created)
}
func TestSpaceAuditLogService_Record_NilReceiverIsNoOp(t *testing.T) {
var svc *SpaceAuditLogService
assert.NotPanics(t, func() {
svc.Record(RecordOptions{SpaceID: "s", Action: model.SpaceAuditActionRenamed})
})
}

View file

@ -0,0 +1,76 @@
package service
import (
"encoding/json"
"errors"
"testing"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeTxAuditRepo struct {
created []*model.TransactionAuditLog
failNext error
}
func (f *fakeTxAuditRepo) Create(log *model.TransactionAuditLog) error {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return err
}
f.created = append(f.created, log)
return nil
}
func (f *fakeTxAuditRepo) ListByTransaction(string, int, int) ([]*model.TransactionAuditLogWithActor, error) {
return nil, nil
}
func (f *fakeTxAuditRepo) CountByTransaction(string) (int, error) { return 0, nil }
func (f *fakeTxAuditRepo) ListByAccount(string, int, int) ([]*model.TransactionAuditLogWithActor, error) {
return nil, nil
}
func (f *fakeTxAuditRepo) CountByAccount(string) (int, error) { return 0, nil }
func (f *fakeTxAuditRepo) ListBySpace(string, int, int) ([]*model.TransactionAuditLogWithActor, error) {
return nil, nil
}
func (f *fakeTxAuditRepo) CountBySpace(string) (int, error) { return 0, nil }
func TestTransactionAuditLogService_Record_PersistsEntry(t *testing.T) {
repo := &fakeTxAuditRepo{}
svc := NewTransactionAuditLogService(repo)
svc.Record(TransactionRecordOptions{
TransactionID: "txn-1",
ActorID: "actor-1",
Action: model.TransactionAuditActionEdited,
Metadata: map[string]any{"changes": map[string]any{"title": "x"}},
})
require.Len(t, repo.created, 1)
got := repo.created[0]
assert.Equal(t, "txn-1", got.TransactionID)
require.NotNil(t, got.ActorID)
assert.Equal(t, "actor-1", *got.ActorID)
assert.Equal(t, model.TransactionAuditActionEdited, got.Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(got.Metadata, &meta))
assert.Contains(t, meta, "changes")
}
func TestTransactionAuditLogService_Record_SwallowsRepoError(t *testing.T) {
repo := &fakeTxAuditRepo{failNext: errors.New("boom")}
svc := NewTransactionAuditLogService(repo)
assert.NotPanics(t, func() {
svc.Record(TransactionRecordOptions{TransactionID: "x", Action: model.TransactionAuditActionEdited})
})
}
func TestTransactionAuditLogService_Record_NilReceiverIsNoOp(t *testing.T) {
var svc *TransactionAuditLogService
assert.NotPanics(t, func() {
svc.Record(TransactionRecordOptions{TransactionID: "x", Action: model.TransactionAuditActionEdited})
})
}

View file

@ -0,0 +1,265 @@
package service
import (
"encoding/json"
"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/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// txnFixture builds a fully wired TransactionService against a real DB along with
// the helper repos the tests need to inspect post-state.
type txnFixture struct {
svc *TransactionService
txAudit repository.TransactionAuditLogRepository
accounts repository.AccountRepository
user *model.User
account *model.Account
}
func newTxnFixture(t *testing.T, dbi testutil.DBInfo) *txnFixture {
t.Helper()
txnRepo := repository.NewTransactionRepository(dbi.DB)
categoryRepo := repository.NewCategoryRepository(dbi.DB)
accountRepo := repository.NewAccountRepository(dbi.DB)
auditRepo := repository.NewTransactionAuditLogRepository(dbi.DB)
accountSvc := NewAccountService(accountRepo)
auditSvc := NewTransactionAuditLogService(auditRepo)
svc := NewTransactionService(txnRepo, categoryRepo, accountSvc)
svc.SetAuditLogger(auditSvc)
user := testutil.CreateTestUser(t, dbi.DB, t.Name()+"@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "S")
account := testutil.CreateTestAccount(t, dbi.DB, space.ID, "Acct")
return &txnFixture{
svc: svc,
txAudit: auditRepo,
accounts: accountRepo,
user: user,
account: account,
}
}
func TestTransactionService_Deposit_RecordsAuditAndUpdatesBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
txn, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID,
Title: "Paycheck",
Amount: decimal.NewFromInt(100),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(100).Equal(txn.Value))
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(100).Equal(updated.Balance))
logs, err := f.txAudit.ListByTransaction(txn.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 1)
assert.Equal(t, model.TransactionAuditActionCreated, logs[0].Action)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "deposit", meta["transaction_type"])
assert.Equal(t, f.account.ID, meta["account_id"])
assert.Equal(t, "Paycheck", meta["title"])
assert.Equal(t, "100.00", meta["amount"])
})
}
func TestTransactionService_PayBill_RecordsAuditAndDebitsBalance(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
// Seed some balance via deposit.
_, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "seed", Amount: decimal.NewFromInt(50), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
txn, err := f.svc.PayBill(PayBillInput{
AccountID: f.account.ID,
Title: "Rent",
Amount: decimal.NewFromInt(20),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.NoError(t, err)
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(30).Equal(updated.Balance))
logs, err := f.txAudit.ListByTransaction(txn.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 1)
var meta map[string]any
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, "withdrawal", meta["transaction_type"])
})
}
func TestTransactionService_UpdateDeposit_RebalancesAndDiffs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
original, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "Old", Amount: decimal.NewFromInt(40),
OccurredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
ActorID: f.user.ID,
})
require.NoError(t, err)
_, err = f.svc.UpdateDeposit(UpdateDepositInput{
TransactionID: original.ID,
Title: "New",
Amount: decimal.NewFromInt(60), // +20 net
OccurredAt: time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC),
ActorID: f.user.ID,
})
require.NoError(t, err)
// Balance reflects the swap (40 → 60 means +20 from 40 baseline).
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(60).Equal(updated.Balance))
// 2 audit rows: created + edited (newest first).
logs, err := f.txAudit.ListByTransaction(original.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 2)
assert.Equal(t, model.TransactionAuditActionEdited, logs[0].Action)
var meta struct {
AccountID string `json:"account_id"`
Changes map[string]map[string]any `json:"changes"`
}
require.NoError(t, json.Unmarshal(logs[0].Metadata, &meta))
assert.Equal(t, f.account.ID, meta.AccountID)
assert.Contains(t, meta.Changes, "title")
assert.Equal(t, "Old", meta.Changes["title"]["old"])
assert.Equal(t, "New", meta.Changes["title"]["new"])
assert.Contains(t, meta.Changes, "amount")
assert.Equal(t, "40.00", meta.Changes["amount"]["old"])
assert.Equal(t, "60.00", meta.Changes["amount"]["new"])
assert.Contains(t, meta.Changes, "occurred_at")
})
}
func TestTransactionService_UpdateDeposit_NoChanges_NoAudit(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
original, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "Same", Amount: decimal.NewFromInt(10),
OccurredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
ActorID: f.user.ID,
})
require.NoError(t, err)
// Update with identical values.
_, err = f.svc.UpdateDeposit(UpdateDepositInput{
TransactionID: original.ID,
Title: "Same",
Amount: decimal.NewFromInt(10),
OccurredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
ActorID: f.user.ID,
})
require.NoError(t, err)
// Only the original `created` audit row exists; no `edited` row.
count, err := f.txAudit.CountByTransaction(original.ID)
require.NoError(t, err)
assert.Equal(t, 1, count)
})
}
func TestTransactionService_UpdateBill_RebalancesAndDiffs(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
// Seed funds and then a bill.
_, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "seed", Amount: decimal.NewFromInt(100), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
bill, err := f.svc.PayBill(PayBillInput{
AccountID: f.account.ID, Title: "Cable", Amount: decimal.NewFromInt(30), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
_, err = f.svc.UpdateBill(UpdateBillInput{
TransactionID: bill.ID,
Title: "Internet",
Amount: decimal.NewFromInt(40), // -10 vs original
OccurredAt: bill.OccurredAt,
ActorID: f.user.ID,
})
require.NoError(t, err)
// 100 - 40 = 60.
updated, err := f.accounts.ByID(f.account.ID)
require.NoError(t, err)
assert.True(t, decimal.NewFromInt(60).Equal(updated.Balance))
logs, err := f.txAudit.ListByTransaction(bill.ID, 10, 0)
require.NoError(t, err)
require.Len(t, logs, 2)
assert.Equal(t, model.TransactionAuditActionEdited, logs[0].Action)
})
}
func TestTransactionService_UpdateDeposit_RejectsBillTransaction(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
_, err := f.svc.Deposit(DepositInput{
AccountID: f.account.ID, Title: "seed", Amount: decimal.NewFromInt(50), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
bill, err := f.svc.PayBill(PayBillInput{
AccountID: f.account.ID, Title: "Bill", Amount: decimal.NewFromInt(10), OccurredAt: time.Now(), ActorID: f.user.ID,
})
require.NoError(t, err)
_, err = f.svc.UpdateDeposit(UpdateDepositInput{
TransactionID: bill.ID,
Title: "x",
Amount: decimal.NewFromInt(1),
OccurredAt: time.Now(),
ActorID: f.user.ID,
})
require.Error(t, err)
})
}
func TestTransactionService_Validations(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
f := newTxnFixture(t, dbi)
_, err := f.svc.Deposit(DepositInput{AccountID: f.account.ID, Amount: decimal.NewFromInt(1), OccurredAt: time.Now()})
assert.Error(t, err, "blank title")
_, err = f.svc.Deposit(DepositInput{AccountID: f.account.ID, Title: "x", Amount: decimal.NewFromInt(0), OccurredAt: time.Now()})
assert.Error(t, err, "zero amount")
_, err = f.svc.Deposit(DepositInput{AccountID: f.account.ID, Title: "x", Amount: decimal.NewFromInt(1)})
assert.Error(t, err, "missing date")
_, err = f.svc.PayBill(PayBillInput{Title: "x", Amount: decimal.NewFromInt(1), OccurredAt: time.Now()})
assert.Error(t, err, "missing account id")
})
}