homelab/apps/finance/services/api/main/store_integration_test.go
Gonçalo Rodrigues 40c8632c7e fix(finance): fix 3 store bugs found by integration tests; add store_integration_test.go (#35)
The integration tests (testcontainers + mongo:7) exposed three real bugs:

1. deleteAllUserData filtered with bson.ObjectID on collections that store
   user_id as a plain string (Account, Goal, Property, etc.) — none of them
   were actually deleted. Fixed by using the original string userID for those
   collections; only finance_sessions (AuthSession.UserID is ObjectID) keeps
   the ObjectID filter.

2. consumeInvite correctly sets used_at, but the test was calling
   getInviteByToken afterwards and expecting the invite back — that query
   intentionally excludes used invites ($exists: false). Fixed the assertion
   to check that the token is no longer redeemable (nil return = correct).

3. createEvent stored GoalItems as null when the slice was nil; subsequent
   $push on a null field fails in MongoDB. Fixed by initialising GoalItems
   to []EventGoal{} before insert so the field is always an array.

Combined unit + integration coverage: 64.7% → 79.8%

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:15:03 +01:00

1064 lines
36 KiB
Go

//go:build integration
package main
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
homemongo "homelab/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
)
// ── Test harness ──────────────────────────────────────────────────────────────
var integrationStore *Store
func TestMain(m *testing.M) {
ctx := context.Background()
container, err := mongodb.Run(ctx, "mongo:7")
if err != nil {
panic(fmt.Sprintf("start mongo container: %v", err))
}
defer container.Terminate(ctx) //nolint:errcheck
uri, err := container.ConnectionString(ctx)
if err != nil {
panic(fmt.Sprintf("get connection string: %v", err))
}
// The homelab mongo package reads MONGO_URI and MONGO_DB from env.
os.Setenv("MONGO_URI", uri)
os.Setenv("MONGO_DB", "test_finance")
db, err := homemongo.Connect(ctx)
if err != nil {
panic(fmt.Sprintf("connect to test mongo: %v", err))
}
defer db.Close(ctx) //nolint:errcheck
integrationStore = NewStore(db)
integrationStore.ensureAuthIndexes(ctx)
m.Run()
}
// drop wipes the given collections for test isolation.
func drop(t *testing.T, colls ...string) {
t.Helper()
ctx := context.Background()
for _, c := range colls {
if err := integrationStore.db.Collection(c).Drop(ctx); err != nil {
t.Fatalf("drop %s: %v", c, err)
}
}
}
// ── Accounts ──────────────────────────────────────────────────────────────────
func TestStore_Accounts(t *testing.T) {
drop(t, "finance_accounts")
ctx := context.Background()
a := &Account{ID: "acc1", UserID: "u1", Name: "Checking", Type: "checking"}
if err := integrationStore.createAccount(ctx, a); err != nil {
t.Fatalf("createAccount: %v", err)
}
got, err := integrationStore.getAccounts(ctx, "u1")
if err != nil || len(got) != 1 || got[0].Name != "Checking" {
t.Fatalf("getAccounts: len=%d err=%v", len(got), err)
}
acc, err := integrationStore.getAccount(ctx, "acc1")
if err != nil || acc == nil || acc.Type != "checking" {
t.Fatalf("getAccount: %v %v", acc, err)
}
// isolation: another user sees nothing
none, _ := integrationStore.getAccounts(ctx, "other")
if len(none) != 0 {
t.Fatalf("expected no accounts for other user, got %d", len(none))
}
if err := integrationStore.deleteAccount(ctx, "acc1", "u1"); err != nil {
t.Fatalf("deleteAccount: %v", err)
}
empty, _ := integrationStore.getAccounts(ctx, "u1")
if len(empty) != 0 {
t.Fatalf("expected 0 accounts after delete, got %d", len(empty))
}
}
// ── Categories ────────────────────────────────────────────────────────────────
func TestStore_Categories(t *testing.T) {
drop(t, "finance_categories")
ctx := context.Background()
c := &Category{ID: "cat1", UserID: "u1", Name: "Food", Color: "#FF6384", BudgetCents: 30000}
if err := integrationStore.createCategory(ctx, c); err != nil {
t.Fatalf("createCategory: %v", err)
}
cats, err := integrationStore.getCategories(ctx, "u1")
if err != nil || len(cats) != 1 {
t.Fatalf("getCategories: %v %v", cats, err)
}
c.Name = "Groceries"
c.BudgetCents = 40000
if err := integrationStore.updateCategory(ctx, c); err != nil {
t.Fatalf("updateCategory: %v", err)
}
updated, _ := integrationStore.getCategories(ctx, "u1")
if updated[0].Name != "Groceries" || updated[0].BudgetCents != 40000 {
t.Fatalf("update not applied: %+v", updated[0])
}
if err := integrationStore.deleteCategory(ctx, "cat1", "u1"); err != nil {
t.Fatalf("deleteCategory: %v", err)
}
empty, _ := integrationStore.getCategories(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 categories after delete")
}
}
func TestStore_SeedCategories(t *testing.T) {
drop(t, "finance_categories")
ctx := context.Background()
if err := integrationStore.seedCategories(ctx, "u_seed"); err != nil {
t.Fatalf("seedCategories: %v", err)
}
cats, _ := integrationStore.getCategories(ctx, "u_seed")
if len(cats) == 0 {
t.Fatal("expected seeded categories, got none")
}
first := len(cats)
// second call is idempotent
if err := integrationStore.seedCategories(ctx, "u_seed"); err != nil {
t.Fatalf("seedCategories second call: %v", err)
}
cats2, _ := integrationStore.getCategories(ctx, "u_seed")
if len(cats2) != first {
t.Fatalf("idempotency broken: first=%d second=%d", first, len(cats2))
}
}
// ── Transactions ──────────────────────────────────────────────────────────────
func TestStore_Transactions(t *testing.T) {
drop(t, "finance_transactions")
ctx := context.Background()
now := time.Now()
txns := []Transaction{
{ID: "t1", UserID: "u1", AmountCents: 200000, Category: "Income", Date: now.AddDate(0, -1, 0)},
{ID: "t2", UserID: "u1", AmountCents: -50000, Category: "Food", Date: now.AddDate(0, -1, -5), GoalID: "g1"},
{ID: "t3", UserID: "u2", AmountCents: 10000, Category: "Income", Date: now},
}
if err := integrationStore.createTransactions(ctx, txns); err != nil {
t.Fatalf("createTransactions: %v", err)
}
// list for u1, sorted desc by date
got, err := integrationStore.getTransactions(ctx, "u1", bson.M{})
if err != nil || len(got) != 2 {
t.Fatalf("getTransactions u1: len=%d err=%v", len(got), err)
}
if got[0].ID != "t1" {
t.Fatalf("sort order wrong: first=%s, want t1", got[0].ID)
}
// get by id
tx, err := integrationStore.getTransaction(ctx, "t2", "u1")
if err != nil || tx == nil || tx.Category != "Food" {
t.Fatalf("getTransaction: %v %v", tx, err)
}
// category filter
filtered, _ := integrationStore.getTransactions(ctx, "u1", bson.M{"category": "Food"})
if len(filtered) != 1 || filtered[0].ID != "t2" {
t.Fatalf("filter by category: %v", filtered)
}
// update
if err := integrationStore.updateTransaction(ctx, "t1", "u1", bson.M{"category": "Salary"}); err != nil {
t.Fatalf("updateTransaction: %v", err)
}
upd, _ := integrationStore.getTransaction(ctx, "t1", "u1")
if upd.Category != "Salary" {
t.Fatalf("update not applied: %+v", upd)
}
// delete
if err := integrationStore.deleteTransaction(ctx, "t2", "u1"); err != nil {
t.Fatalf("deleteTransaction: %v", err)
}
after, _ := integrationStore.getTransactions(ctx, "u1", bson.M{})
if len(after) != 1 {
t.Fatalf("expected 1 txn after delete, got %d", len(after))
}
}
func TestStore_AggregateTransactions(t *testing.T) {
drop(t, "finance_transactions")
ctx := context.Background()
now := time.Now()
txns := []Transaction{
{ID: "ag1", UserID: "u1", AmountCents: 100000, Category: "Income", Date: now},
{ID: "ag2", UserID: "u1", AmountCents: 200000, Category: "Income", Date: now},
{ID: "ag3", UserID: "u1", AmountCents: -30000, Category: "Food", Date: now},
}
integrationStore.createTransactions(ctx, txns) //nolint:errcheck
pipeline := bson.A{
bson.M{"$group": bson.M{"_id": "$category", "total": bson.M{"$sum": "$amount_cents"}}},
}
results, err := integrationStore.aggregateTransactions(ctx, "u1", pipeline)
if err != nil || len(results) == 0 {
t.Fatalf("aggregateTransactions: results=%d err=%v", len(results), err)
}
}
func TestStore_GoalFundedCents(t *testing.T) {
drop(t, "finance_transactions")
ctx := context.Background()
now := time.Now()
txns := []Transaction{
{ID: "gf1", UserID: "u1", AmountCents: -50000, Category: "Goals", GoalID: "g1", Date: now},
{ID: "gf2", UserID: "u1", AmountCents: -30000, Category: "Goals", GoalID: "g1", Date: now},
{ID: "gf3", UserID: "u1", AmountCents: -20000, Category: "Goals", GoalID: "g2", Date: now},
{ID: "gf4", UserID: "u1", AmountCents: 10000, Category: "Income", Date: now}, // positive: excluded
}
integrationStore.createTransactions(ctx, txns) //nolint:errcheck
funds, err := integrationStore.getGoalFundedCentsAll(ctx, "u1")
if err != nil {
t.Fatalf("getGoalFundedCentsAll: %v", err)
}
if funds["g1"] != 80000 {
t.Errorf("g1 funded=%d, want 80000", funds["g1"])
}
if funds["g2"] != 20000 {
t.Errorf("g2 funded=%d, want 20000", funds["g2"])
}
goalTxns, err := integrationStore.getGoalTransactions(ctx, "u1", "g1")
if err != nil || len(goalTxns) != 2 {
t.Fatalf("getGoalTransactions: len=%d err=%v", len(goalTxns), err)
}
}
// ── Goals ─────────────────────────────────────────────────────────────────────
func TestStore_Goals(t *testing.T) {
drop(t, "finance_goals")
ctx := context.Background()
now := time.Now()
g := &Goal{
ID: "goal1",
UserID: "u1",
Name: "Emergency Fund",
Type: GoalTypeOnce,
TargetCents: 1000000,
Deadline: now.AddDate(1, 0, 0),
CreatedAt: now,
}
if err := integrationStore.createGoal(ctx, g); err != nil {
t.Fatalf("createGoal: %v", err)
}
goals, err := integrationStore.getGoals(ctx, "u1")
if err != nil || len(goals) != 1 || goals[0].Name != "Emergency Fund" {
t.Fatalf("getGoals: %v %v", goals, err)
}
if err := integrationStore.updateGoal(ctx, "goal1", "u1", bson.M{"committed": true}); err != nil {
t.Fatalf("updateGoal: %v", err)
}
updated, _ := integrationStore.getGoals(ctx, "u1")
if !updated[0].Committed {
t.Fatal("goal not committed after update")
}
if err := integrationStore.deleteGoal(ctx, "goal1", "u1"); err != nil {
t.Fatalf("deleteGoal: %v", err)
}
empty, _ := integrationStore.getGoals(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 goals after delete")
}
}
// ── Trades ────────────────────────────────────────────────────────────────────
func TestStore_Trades(t *testing.T) {
drop(t, "finance_trades")
ctx := context.Background()
now := time.Now()
trades := []Trade{
{ID: "tr1", UserID: "u1", ISIN: "IE00B3WJKG14", Name: "MSCI World",
Type: "buy", Quantity: 10, PriceCents: 4500, TotalCents: 45000, Date: now},
{ID: "tr2", UserID: "u1", ISIN: "IE00B3WJKG14", Name: "MSCI World",
Type: "sell", Quantity: 2, PriceCents: 5000, TotalCents: 10000, Date: now.AddDate(0, 1, 0)},
}
if err := integrationStore.createTrades(ctx, trades); err != nil {
t.Fatalf("createTrades: %v", err)
}
got, err := integrationStore.getTrades(ctx, "u1")
if err != nil || len(got) != 2 {
t.Fatalf("getTrades: len=%d err=%v", len(got), err)
}
// sorted ascending by date
if got[0].ID != "tr1" {
t.Fatalf("sort order wrong: first=%s, want tr1", got[0].ID)
}
if err := integrationStore.deleteTrade(ctx, "tr1", "u1"); err != nil {
t.Fatalf("deleteTrade: %v", err)
}
after, _ := integrationStore.getTrades(ctx, "u1")
if len(after) != 1 || after[0].ID != "tr2" {
t.Fatalf("expected 1 trade after delete, got %v", after)
}
}
// ── Permissions ───────────────────────────────────────────────────────────────
func TestStore_Permissions(t *testing.T) {
drop(t, "finance_permissions")
ctx := context.Background()
now := time.Now()
p := &Permission{ID: "perm1", OwnerID: "u1", ViewerID: "u2", CreatedAt: now}
if err := integrationStore.createPermission(ctx, p); err != nil {
t.Fatalf("createPermission: %v", err)
}
perms, err := integrationStore.getPermissions(ctx, "u1")
if err != nil || len(perms) != 1 || perms[0].ViewerID != "u2" {
t.Fatalf("getPermissions: %v %v", perms, err)
}
viewers, err := integrationStore.getGrantedViewers(ctx, "u2")
if err != nil || len(viewers) != 1 || viewers[0].OwnerID != "u1" {
t.Fatalf("getGrantedViewers: %v %v", viewers, err)
}
if err := integrationStore.deletePermission(ctx, "u1", "u2"); err != nil {
t.Fatalf("deletePermission: %v", err)
}
empty, _ := integrationStore.getPermissions(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 permissions after delete")
}
}
// ── Ticker mappings ───────────────────────────────────────────────────────────
func TestStore_TickerMappings(t *testing.T) {
drop(t, "finance_ticker_mappings")
ctx := context.Background()
if err := integrationStore.saveTickerMapping(ctx, "u1", "IE00B3WJKG14", "SWDA.L"); err != nil {
t.Fatalf("saveTickerMapping: %v", err)
}
mappings, err := integrationStore.getTickerMappings(ctx, "u1")
if err != nil || len(mappings) == 0 {
t.Fatalf("getTickerMappings: %v %v", mappings, err)
}
// upsert: same ISIN, different ticker
if err := integrationStore.saveTickerMapping(ctx, "u1", "IE00B3WJKG14", "IWDA.AS"); err != nil {
t.Fatalf("saveTickerMapping upsert: %v", err)
}
mappings2, _ := integrationStore.getTickerMappings(ctx, "u1")
if len(mappings2) != 1 {
t.Fatalf("expected 1 mapping after upsert, got %d", len(mappings2))
}
}
// ── Household ─────────────────────────────────────────────────────────────────
func TestStore_Household(t *testing.T) {
drop(t, "finance_households")
ctx := context.Background()
h := &Household{ID: "hh1", OwnerID: "u1", PartnerID: "u2"}
if err := integrationStore.createHousehold(ctx, h); err != nil {
t.Fatalf("createHousehold: %v", err)
}
// owner finds it
got, err := integrationStore.getHousehold(ctx, "u1")
if err != nil || got == nil || got.PartnerID != "u2" {
t.Fatalf("getHousehold(owner): %v %v", got, err)
}
// partner also finds it
got2, err := integrationStore.getHousehold(ctx, "u2")
if err != nil || got2 == nil || got2.OwnerID != "u1" {
t.Fatalf("getHousehold(partner): %v %v", got2, err)
}
if err := integrationStore.deleteHousehold(ctx, "u1"); err != nil {
t.Fatalf("deleteHousehold: %v", err)
}
_, err = integrationStore.getHousehold(ctx, "u1")
if err == nil {
t.Fatal("expected error for missing household")
}
}
// ── Import schedules ──────────────────────────────────────────────────────────
func TestStore_ImportSchedules(t *testing.T) {
drop(t, "finance_import_schedules")
ctx := context.Background()
sched := &ImportSchedule{
ID: "sched1", UserID: "u1", AccountID: "acc1",
Label: "CGD Monthly", Format: "cgd", Active: true,
}
if err := integrationStore.createImportSchedule(ctx, sched); err != nil {
t.Fatalf("createImportSchedule: %v", err)
}
schedules, err := integrationStore.getImportSchedules(ctx, "u1")
if err != nil || len(schedules) != 1 || schedules[0].Format != "cgd" {
t.Fatalf("getImportSchedules: %v %v", schedules, err)
}
if err := integrationStore.deleteImportSchedule(ctx, "sched1", "u1"); err != nil {
t.Fatalf("deleteImportSchedule: %v", err)
}
empty, _ := integrationStore.getImportSchedules(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 schedules after delete")
}
}
// ── Auth users & sessions ─────────────────────────────────────────────────────
func TestStore_AuthUsers(t *testing.T) {
drop(t, "finance_users", "finance_sessions")
ctx := context.Background()
u := &AuthUser{Email: "alice@example.com", Name: "Alice", PasswordHash: "hash"}
if err := integrationStore.createAuthUser(ctx, u); err != nil {
t.Fatalf("createAuthUser: %v", err)
}
if u.ID.IsZero() {
t.Fatal("expected ID to be set after createAuthUser")
}
found, err := integrationStore.findAuthUserByEmail(ctx, "alice@example.com")
if err != nil || found == nil || found.Name != "Alice" {
t.Fatalf("findAuthUserByEmail: %v %v", found, err)
}
found2, err := integrationStore.findAuthUserByID(ctx, u.ID.Hex())
if err != nil || found2 == nil {
t.Fatalf("findAuthUserByID: %v %v", found2, err)
}
notFound, err := integrationStore.findAuthUserByEmail(ctx, "nobody@example.com")
if err != nil || notFound != nil {
t.Fatalf("expected nil for unknown email, got %v %v", notFound, err)
}
// google provider
u2 := &AuthUser{Email: "bob@gmail.com", Provider: "google", ProviderID: "g-123"}
integrationStore.createAuthUser(ctx, u2) //nolint:errcheck
g, err := integrationStore.findAuthUserByProvider(ctx, "google", "g-123")
if err != nil || g == nil || g.Email != "bob@gmail.com" {
t.Fatalf("findAuthUserByProvider: %v %v", g, err)
}
}
func TestStore_AuthSessions(t *testing.T) {
drop(t, "finance_users", "finance_sessions")
ctx := context.Background()
u := &AuthUser{Email: "sess@example.com", Name: "Sess"}
if err := integrationStore.createAuthUser(ctx, u); err != nil {
t.Fatalf("createAuthUser: %v", err)
}
sess := &AuthSession{
UserID: u.ID,
Email: u.Email,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := integrationStore.createAuthSession(ctx, sess); err != nil {
t.Fatalf("createAuthSession: %v", err)
}
if sess.ID.IsZero() {
t.Fatal("expected session ID to be set")
}
got, err := integrationStore.getAuthSession(ctx, sess.ID.Hex())
if err != nil || got == nil || got.Email != "sess@example.com" {
t.Fatalf("getAuthSession: %v %v", got, err)
}
sessions, err := integrationStore.getSessionsByUserID(ctx, u.ID.Hex())
if err != nil || len(sessions) != 1 {
t.Fatalf("getSessionsByUserID: len=%d err=%v", len(sessions), err)
}
if err := integrationStore.deleteSessionForUser(ctx, sess.ID.Hex(), u.ID.Hex()); err != nil {
t.Fatalf("deleteSessionForUser: %v", err)
}
empty, _ := integrationStore.getSessionsByUserID(ctx, u.ID.Hex())
if len(empty) != 0 {
t.Fatal("expected 0 sessions after deleteSessionForUser")
}
}
func TestStore_DeleteAuthSession(t *testing.T) {
drop(t, "finance_users", "finance_sessions")
ctx := context.Background()
u := &AuthUser{Email: "del@example.com"}
integrationStore.createAuthUser(ctx, u) //nolint:errcheck
sess := &AuthSession{UserID: u.ID, Email: u.Email, ExpiresAt: time.Now().Add(time.Hour)}
integrationStore.createAuthSession(ctx, sess) //nolint:errcheck
if err := integrationStore.deleteAuthSession(ctx, sess.ID.Hex()); err != nil {
t.Fatalf("deleteAuthSession: %v", err)
}
got, _ := integrationStore.getAuthSession(ctx, sess.ID.Hex())
if got != nil {
t.Fatal("session still exists after delete")
}
}
func TestStore_DeleteAllUserData(t *testing.T) {
drop(t,
"finance_users", "finance_sessions", "finance_accounts", "finance_categories",
"finance_transactions", "finance_trades", "finance_goals", "finance_properties",
"finance_loans", "finance_permissions", "finance_households",
)
ctx := context.Background()
u := &AuthUser{Email: "purge@example.com", Name: "Purge"}
integrationStore.createAuthUser(ctx, u) //nolint:errcheck
uid := u.ID.Hex()
integrationStore.createAccount(ctx, &Account{ID: "da1", UserID: uid, Name: "Main", Type: "checking"}) //nolint:errcheck
integrationStore.createGoal(ctx, &Goal{ID: "dg1", UserID: uid, Name: "Emergency", TargetCents: 100000}) //nolint:errcheck
integrationStore.createProperty(ctx, &Property{ID: "dp1", UserID: uid, Name: "Home", Status: PropertyOwned}) //nolint:errcheck
integrationStore.createHousehold(ctx, &Household{ID: "dhh1", OwnerID: uid, PartnerID: "other"}) //nolint:errcheck
integrationStore.createAuthSession(ctx, &AuthSession{ //nolint:errcheck
UserID: u.ID, Email: u.Email, ExpiresAt: time.Now().Add(time.Hour),
})
if err := integrationStore.deleteAllUserData(ctx, uid); err != nil {
t.Fatalf("deleteAllUserData: %v", err)
}
notFound, _ := integrationStore.findAuthUserByEmail(ctx, "purge@example.com")
if notFound != nil {
t.Fatal("user still exists after deleteAllUserData")
}
accs, _ := integrationStore.getAccounts(ctx, uid)
if len(accs) != 0 {
t.Fatalf("expected 0 accounts after purge, got %d", len(accs))
}
goals, _ := integrationStore.getGoals(ctx, uid)
if len(goals) != 0 {
t.Fatalf("expected 0 goals after purge, got %d", len(goals))
}
}
// ── Properties & Loans ────────────────────────────────────────────────────────
func TestStore_Properties(t *testing.T) {
drop(t, "finance_properties")
ctx := context.Background()
now := time.Now()
p := &Property{
ID: "prop1", UserID: "u1", Name: "Main Residence",
Status: PropertyOwned, CurrentValueCents: 30000000,
PurchasePriceCents: 25000000, PurchaseDate: now.AddDate(-5, 0, 0),
}
if err := integrationStore.createProperty(ctx, p); err != nil {
t.Fatalf("createProperty: %v", err)
}
props, err := integrationStore.getProperties(ctx, "u1")
if err != nil || len(props) != 1 || props[0].Name != "Main Residence" {
t.Fatalf("getProperties: %v %v", props, err)
}
prop, err := integrationStore.getProperty(ctx, "prop1", "u1")
if err != nil || prop == nil {
t.Fatalf("getProperty: %v %v", prop, err)
}
if err := integrationStore.updateProperty(ctx, "prop1", "u1", bson.M{"current_value_cents": int64(32000000)}); err != nil {
t.Fatalf("updateProperty: %v", err)
}
updated, _ := integrationStore.getProperty(ctx, "prop1", "u1")
if updated.CurrentValueCents != 32000000 {
t.Fatalf("update not applied: %d", updated.CurrentValueCents)
}
if err := integrationStore.deleteProperty(ctx, "prop1", "u1"); err != nil {
t.Fatalf("deleteProperty: %v", err)
}
empty, _ := integrationStore.getProperties(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 properties after delete")
}
}
func TestStore_Loans(t *testing.T) {
drop(t, "finance_loans")
ctx := context.Background()
now := time.Now()
l := &Loan{
ID: "loan1", UserID: "u1", PropertyID: "prop1", Name: "Mortgage",
Type: LoanMortgage, Status: LoanActive,
PrincipalCents: 20000000, BalanceCents: 18000000,
InterestRatePct: 3.5, TermMonths: 360,
MonthlyPaymentCents: 100000, StartDate: now.AddDate(-2, 0, 0),
}
if err := integrationStore.createLoan(ctx, l); err != nil {
t.Fatalf("createLoan: %v", err)
}
loans, err := integrationStore.getLoans(ctx, "u1")
if err != nil || len(loans) != 1 || loans[0].Name != "Mortgage" {
t.Fatalf("getLoans: %v %v", loans, err)
}
loan, err := integrationStore.getLoan(ctx, "loan1", "u1")
if err != nil || loan == nil || loan.Type != LoanMortgage {
t.Fatalf("getLoan: %v %v", loan, err)
}
if err := integrationStore.updateLoan(ctx, "loan1", "u1", bson.M{"balance_cents": int64(17500000)}); err != nil {
t.Fatalf("updateLoan: %v", err)
}
updated, _ := integrationStore.getLoan(ctx, "loan1", "u1")
if updated.BalanceCents != 17500000 {
t.Fatalf("update not applied: %d", updated.BalanceCents)
}
if err := integrationStore.deleteLoan(ctx, "loan1", "u1"); err != nil {
t.Fatalf("deleteLoan: %v", err)
}
empty, _ := integrationStore.getLoans(ctx, "u1")
if len(empty) != 0 {
t.Fatal("expected 0 loans after delete")
}
}
// ── Org: orgs, teams, members, invites ────────────────────────────────────────
func TestStore_Orgs(t *testing.T) {
drop(t, "org_organizations", "org_members")
ctx := context.Background()
now := time.Now()
o := &Org{ID: "org1", Name: "Acme Corp", Slug: "acme", OwnerUserID: "u1", CreatedAt: now}
if err := integrationStore.createOrg(ctx, o); err != nil {
t.Fatalf("createOrg: %v", err)
}
got, err := integrationStore.getOrg(ctx, "org1")
if err != nil || got == nil || got.Slug != "acme" {
t.Fatalf("getOrg: %v %v", got, err)
}
got2, err := integrationStore.getOrgBySlug(ctx, "acme")
if err != nil || got2 == nil || got2.ID != "org1" {
t.Fatalf("getOrgBySlug: %v %v", got2, err)
}
exists, err := integrationStore.slugExists(ctx, "acme")
if err != nil || !exists {
t.Fatalf("slugExists: exists=%v err=%v", exists, err)
}
absent, _ := integrationStore.slugExists(ctx, "nope")
if absent {
t.Fatal("slug 'nope' should not exist")
}
// add a member so getOrgsForUser works
m := &OrgMember{ID: "mem1", OrgID: "org1", UserID: "u1", Email: "u1@test.com", Role: OrgRoleAdmin, CreatedAt: now}
if err := integrationStore.createMember(ctx, m); err != nil {
t.Fatalf("createMember: %v", err)
}
orgs, err := integrationStore.getOrgsForUser(ctx, "u1")
if err != nil || len(orgs) != 1 || orgs[0].Org.Slug != "acme" {
t.Fatalf("getOrgsForUser: %v %v", orgs, err)
}
}
func TestStore_Teams(t *testing.T) {
drop(t, "org_teams")
ctx := context.Background()
now := time.Now()
team := &OrgTeam{ID: "team1", OrgID: "org1", Name: "Events", Type: TeamTypeInternal, CreatedAt: now}
if err := integrationStore.createTeam(ctx, team); err != nil {
t.Fatalf("createTeam: %v", err)
}
teams, err := integrationStore.getTeams(ctx, "org1")
if err != nil || len(teams) != 1 || teams[0].Name != "Events" {
t.Fatalf("getTeams: %v %v", teams, err)
}
got, err := integrationStore.getTeam(ctx, "team1", "org1")
if err != nil || got == nil {
t.Fatalf("getTeam: %v %v", got, err)
}
if err := integrationStore.deleteTeam(ctx, "team1", "org1"); err != nil {
t.Fatalf("deleteTeam: %v", err)
}
empty, _ := integrationStore.getTeams(ctx, "org1")
if len(empty) != 0 {
t.Fatal("expected 0 teams after delete")
}
}
func TestStore_Members(t *testing.T) {
drop(t, "org_members")
ctx := context.Background()
now := time.Now()
m := &OrgMember{ID: "m1", OrgID: "org1", UserID: "u1", Email: "u1@t.com", Role: OrgRoleMember, CreatedAt: now}
if err := integrationStore.createMember(ctx, m); err != nil {
t.Fatalf("createMember: %v", err)
}
members, err := integrationStore.getMembers(ctx, "org1")
if err != nil || len(members) != 1 {
t.Fatalf("getMembers: %v %v", members, err)
}
got, err := integrationStore.getMember(ctx, "org1", "u1")
if err != nil || got == nil || got.Role != OrgRoleMember {
t.Fatalf("getMember: %v %v", got, err)
}
if err := integrationStore.updateMemberRole(ctx, "m1", "org1", OrgRoleFinance); err != nil {
t.Fatalf("updateMemberRole: %v", err)
}
updated, _ := integrationStore.getMember(ctx, "org1", "u1")
if updated.Role != OrgRoleFinance {
t.Fatalf("role not updated: %v", updated.Role)
}
if err := integrationStore.removeMember(ctx, "m1", "org1"); err != nil {
t.Fatalf("removeMember: %v", err)
}
empty, _ := integrationStore.getMembers(ctx, "org1")
if len(empty) != 0 {
t.Fatal("expected 0 members after remove")
}
}
func TestStore_Invites(t *testing.T) {
drop(t, "org_invites")
ctx := context.Background()
now := time.Now()
inv := &OrgInvite{
ID: "inv1", OrgID: "org1", Email: "new@test.com",
Role: OrgRoleMember, Token: "tok-abc123",
ExpiresAt: now.Add(48 * time.Hour), CreatedAt: now,
}
if err := integrationStore.createInvite(ctx, inv); err != nil {
t.Fatalf("createInvite: %v", err)
}
invites, err := integrationStore.getInvites(ctx, "org1")
if err != nil || len(invites) != 1 {
t.Fatalf("getInvites: %v %v", invites, err)
}
got, err := integrationStore.getInviteByToken(ctx, "tok-abc123")
if err != nil || got == nil || got.Email != "new@test.com" {
t.Fatalf("getInviteByToken: %v %v", got, err)
}
if err := integrationStore.consumeInvite(ctx, "inv1"); err != nil {
t.Fatalf("consumeInvite: %v", err)
}
// getInviteByToken filters out consumed invites — nil means it was correctly marked used.
consumed, _ := integrationStore.getInviteByToken(ctx, "tok-abc123")
if consumed != nil {
t.Fatal("consumed invite should no longer be findable by token")
}
// revoke a second invite
inv2 := &OrgInvite{ID: "inv2", OrgID: "org1", Token: "tok-xyz", ExpiresAt: now.Add(time.Hour), CreatedAt: now}
integrationStore.createInvite(ctx, inv2) //nolint:errcheck
if err := integrationStore.revokeInvite(ctx, "inv2", "org1"); err != nil {
t.Fatalf("revokeInvite: %v", err)
}
all, _ := integrationStore.getInvites(ctx, "org1")
for _, i := range all {
if i.ID == "inv2" {
t.Fatal("revoked invite still in list")
}
}
}
// ── Fiscal years ──────────────────────────────────────────────────────────────
func TestStore_FiscalYears(t *testing.T) {
drop(t, "org_fiscal_years")
ctx := context.Background()
now := time.Now()
fy := &FiscalYear{
ID: "fy2025", OrgID: "org1", Label: "2025",
Status: FiscalYearDraft,
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC),
CreatedAt: now,
}
if err := integrationStore.createFiscalYear(ctx, fy); err != nil {
t.Fatalf("createFiscalYear: %v", err)
}
years, err := integrationStore.getFiscalYears(ctx, "org1")
if err != nil || len(years) != 1 || years[0].Label != "2025" {
t.Fatalf("getFiscalYears: %v %v", years, err)
}
year, err := integrationStore.getFiscalYear(ctx, "fy2025", "org1")
if err != nil || year == nil {
t.Fatalf("getFiscalYear: %v %v", year, err)
}
// activate
if err := integrationStore.updateFiscalYearStatus(ctx, "fy2025", "org1", FiscalYearActive, bson.M{"started_at": now}); err != nil {
t.Fatalf("updateFiscalYearStatus activate: %v", err)
}
active, err := integrationStore.getActiveFiscalYear(ctx, "org1")
if err != nil || active == nil || active.Status != FiscalYearActive {
t.Fatalf("getActiveFiscalYear: %v %v", active, err)
}
// close
if err := integrationStore.updateFiscalYearStatus(ctx, "fy2025", "org1", FiscalYearClosed, bson.M{"closed_at": now}); err != nil {
t.Fatalf("updateFiscalYearStatus close: %v", err)
}
closed, _ := integrationStore.getFiscalYear(ctx, "fy2025", "org1")
if closed.Status != FiscalYearClosed {
t.Fatalf("expected closed, got %v", closed.Status)
}
}
// ── Events, budget lines, comments ───────────────────────────────────────────
func TestStore_Events(t *testing.T) {
drop(t, "org_events", "org_budget_lines", "org_event_comments")
ctx := context.Background()
now := time.Now()
ev := &OrgEvent{
ID: "ev1", OrgID: "org1", FiscalYearID: "fy1",
Name: "Annual Gala", Status: EventDraft,
CreatedBy: "u1", CreatedAt: now,
}
if err := integrationStore.createEvent(ctx, ev); err != nil {
t.Fatalf("createEvent: %v", err)
}
events, err := integrationStore.getEvents(ctx, "org1", "fy1")
if err != nil || len(events) != 1 {
t.Fatalf("getEvents: %v %v", events, err)
}
got, err := integrationStore.getEvent(ctx, "ev1", "org1")
if err != nil || got == nil || got.Name != "Annual Gala" {
t.Fatalf("getEvent: %v %v", got, err)
}
if err := integrationStore.updateEvent(ctx, "ev1", "org1", bson.M{"status": EventReview}); err != nil {
t.Fatalf("updateEvent: %v", err)
}
updated, _ := integrationStore.getEvent(ctx, "ev1", "org1")
if updated.Status != EventReview {
t.Fatalf("status not updated: %v", updated.Status)
}
// goal items
gi := EventGoal{ID: "gi1", Text: "Feed 100 people"}
if err := integrationStore.addGoalItem(ctx, "ev1", "org1", gi); err != nil {
t.Fatalf("addGoalItem: %v", err)
}
if err := integrationStore.toggleGoalItem(ctx, "ev1", "org1", "gi1", true, "u1"); err != nil {
t.Fatalf("toggleGoalItem: %v", err)
}
withGoal, _ := integrationStore.getEvent(ctx, "ev1", "org1")
if len(withGoal.GoalItems) == 0 || !withGoal.GoalItems[0].Done {
t.Fatalf("goal item not toggled: %+v", withGoal.GoalItems)
}
if err := integrationStore.deleteGoalItem(ctx, "ev1", "org1", "gi1"); err != nil {
t.Fatalf("deleteGoalItem: %v", err)
}
// budget line
bl := &BudgetLine{
ID: "bl1", EventID: "ev1", OrgID: "org1",
Category: "Catering", Type: BudgetExpense, PlannedCents: 500000, CreatedAt: now,
}
if err := integrationStore.createBudgetLine(ctx, bl); err != nil {
t.Fatalf("createBudgetLine: %v", err)
}
lines, err := integrationStore.getBudgetLines(ctx, "ev1", "org1")
if err != nil || len(lines) != 1 {
t.Fatalf("getBudgetLines: %v %v", lines, err)
}
if err := integrationStore.deleteBudgetLine(ctx, "bl1", "org1"); err != nil {
t.Fatalf("deleteBudgetLine: %v", err)
}
// event comment
c := &EventComment{
ID: "ec1", EventID: "ev1", OrgID: "org1", UserID: "u1",
Kind: CommentReview, Body: "Looks good", CreatedAt: now,
}
if err := integrationStore.createEventComment(ctx, c); err != nil {
t.Fatalf("createEventComment: %v", err)
}
comments, err := integrationStore.getEventComments(ctx, "ev1", "org1")
if err != nil || len(comments) != 1 {
t.Fatalf("getEventComments: %v %v", comments, err)
}
// delete event
if err := integrationStore.deleteEvent(ctx, "ev1", "org1"); err != nil {
t.Fatalf("deleteEvent: %v", err)
}
empty, _ := integrationStore.getEvents(ctx, "org1", "fy1")
if len(empty) != 0 {
t.Fatal("expected 0 events after delete")
}
}
// ── TxRequests ────────────────────────────────────────────────────────────────
func TestStore_TxRequests(t *testing.T) {
drop(t, "org_tx_requests")
ctx := context.Background()
now := time.Now()
req := &TxRequest{
ID: "req1", OrgID: "org1", FiscalYearID: "fy1",
Type: TxReimbursement, AmountCents: 15000,
SubmittedBy: "m1", CreatedAt: now,
StatusLog: []StatusLogEntry{{
Status: TxSubmitted,
ChangedBy: "m1",
ChangedAt: now,
}},
}
if err := integrationStore.createTxRequest(ctx, req); err != nil {
t.Fatalf("createTxRequest: %v", err)
}
reqs, err := integrationStore.getTxRequests(ctx, "org1", bson.M{})
if err != nil || len(reqs) != 1 {
t.Fatalf("getTxRequests: %v %v", reqs, err)
}
got, err := integrationStore.getTxRequest(ctx, "req1", "org1")
if err != nil || got == nil || got.AmountCents != 15000 {
t.Fatalf("getTxRequest: %v %v", got, err)
}
entry := StatusLogEntry{Status: TxApproved, ChangedBy: "admin1", ChangedAt: now}
if err := integrationStore.appendStatusLog(ctx, "req1", "org1", entry); err != nil {
t.Fatalf("appendStatusLog: %v", err)
}
if err := integrationStore.updateTxRequest(ctx, "req1", "org1", bson.M{"status": TxApproved}); err != nil {
t.Fatalf("updateTxRequest: %v", err)
}
updated, _ := integrationStore.getTxRequest(ctx, "req1", "org1")
if len(updated.StatusLog) < 2 {
t.Fatalf("expected ≥2 status log entries, got %d", len(updated.StatusLog))
}
}
// ── Ledger ────────────────────────────────────────────────────────────────────
func TestStore_Ledger(t *testing.T) {
drop(t, "org_ledger")
ctx := context.Background()
now := time.Now()
entry := &OrgLedgerEntry{
ID: "le1", OrgID: "org1", FiscalYearID: "fy1",
RequestID: "req1", AmountCents: 15000, Description: "Reimbursement",
Date: now, CreatedAt: now,
}
if err := integrationStore.createLedgerEntry(ctx, entry); err != nil {
t.Fatalf("createLedgerEntry: %v", err)
}
entries, err := integrationStore.getLedgerEntries(ctx, "org1", "fy1", bson.M{})
if err != nil || len(entries) != 1 {
t.Fatalf("getLedgerEntries: %v %v", entries, err)
}
if err := integrationStore.updateLedgerEntry(ctx, "le1", "org1", bson.M{"description": "Updated"}); err != nil {
t.Fatalf("updateLedgerEntry: %v", err)
}
updated, _ := integrationStore.getLedgerEntries(ctx, "org1", "fy1", bson.M{})
if updated[0].Description != "Updated" {
t.Fatalf("ledger entry not updated: %v", updated[0].Description)
}
}
// ── Attachments ───────────────────────────────────────────────────────────────
func TestStore_Attachments(t *testing.T) {
drop(t, "org_attachments")
ctx := context.Background()
now := time.Now()
att := &OrgAttachment{
ID: "att1", OrgID: "org1", RequestID: "req1",
Filename: "invoice.pdf", StoragePath: "/data/org-files/org1/req1/att1",
UploadedBy: "u1", UploadedAt: now,
}
if err := integrationStore.createAttachment(ctx, att); err != nil {
t.Fatalf("createAttachment: %v", err)
}
atts, err := integrationStore.getAttachments(ctx, "req1", "org1")
if err != nil || len(atts) != 1 || atts[0].Filename != "invoice.pdf" {
t.Fatalf("getAttachments: %v %v", atts, err)
}
}