feat: drop sqlite support
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m27s
This commit is contained in:
parent
5e00060421
commit
da718427bd
27 changed files with 1296 additions and 115 deletions
213
internal/service/account_activity_test.go
Normal file
213
internal/service/account_activity_test.go
Normal 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)
|
||||
}
|
||||
112
internal/service/account_test.go
Normal file
112
internal/service/account_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
9
internal/service/main_test.go
Normal file
9
internal/service/main_test.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/testutil"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { testutil.PostgresMain(m) }
|
||||
100
internal/service/space_audit_log_test.go
Normal file
100
internal/service/space_audit_log_test.go
Normal 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})
|
||||
})
|
||||
}
|
||||
76
internal/service/transaction_audit_log_test.go
Normal file
76
internal/service/transaction_audit_log_test.go
Normal 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})
|
||||
})
|
||||
}
|
||||
265
internal/service/transaction_test.go
Normal file
265
internal/service/transaction_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue