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

162 lines
4.7 KiB
Go

package main
import (
"context"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func (s *Store) createAuthUser(ctx context.Context, u *AuthUser) error {
u.ID = bson.NewObjectID()
u.CreatedAt = time.Now()
_, err := s.db.Collection("finance_users").InsertOne(ctx, u)
return err
}
func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) {
var u AuthUser
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
var u AuthUser
err = s.db.Collection("finance_users").FindOne(ctx, bson.M{"_id": oid}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) {
var u AuthUser
err := s.db.Collection("finance_users").FindOne(ctx, bson.M{
"provider": provider,
"provider_id": providerID,
}).Decode(&u)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &u, err
}
func (s *Store) createAuthSession(ctx context.Context, sess *AuthSession) error {
sess.ID = bson.NewObjectID()
sess.CreatedAt = time.Now()
_, err := s.db.Collection("finance_sessions").InsertOne(ctx, sess)
return err
}
func (s *Store) getAuthSession(ctx context.Context, id string) (*AuthSession, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, nil
}
var sess AuthSession
err = s.db.Collection("finance_sessions").FindOne(ctx, bson.M{"_id": oid}).Decode(&sess)
if err == mongo.ErrNoDocuments {
return nil, nil
}
return &sess, err
}
func (s *Store) deleteAuthSession(ctx context.Context, id string) error {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil
}
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": oid})
return err
}
func (s *Store) getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error) {
oid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil, nil
}
cur, err := s.db.Collection("finance_sessions").Find(ctx,
bson.M{"user_id": oid},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}),
)
if err != nil {
return nil, err
}
var sessions []AuthSession
if err := cur.All(ctx, &sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *Store) deleteSessionForUser(ctx context.Context, sessionID, userID string) error {
sid, err := bson.ObjectIDFromHex(sessionID)
if err != nil {
return nil
}
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return nil
}
_, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": sid, "user_id": uid})
return err
}
// deleteAllUserData purges every record belonging to userID across all collections,
// then deletes the user account itself. Irreversible.
func (s *Store) deleteAllUserData(ctx context.Context, userID string) error {
uid, err := bson.ObjectIDFromHex(userID)
if err != nil {
return err
}
// Most collections store user_id as a plain string; only sessions use ObjectID.
strFilter := bson.M{"user_id": userID}
orFilter := bson.M{"$or": bson.A{bson.M{"owner_id": userID}, bson.M{"partner_id": userID}}}
orFilterPerms := bson.M{"$or": bson.A{bson.M{"owner_id": userID}, bson.M{"viewer_id": userID}}}
collections := []struct {
name string
filter interface{}
}{
{"finance_accounts", strFilter},
{"finance_categories", strFilter},
{"finance_transactions", strFilter},
{"finance_trades", strFilter},
{"finance_ticker_mappings", strFilter},
{"finance_goals", strFilter},
{"finance_import_schedules", strFilter},
{"finance_properties", strFilter},
{"finance_loans", strFilter},
{"finance_permissions", orFilterPerms},
{"finance_households", orFilter},
{"finance_sessions", bson.M{"user_id": uid}}, // AuthSession.UserID is bson.ObjectID
}
for _, c := range collections {
if _, err := s.db.Collection(c.name).DeleteMany(ctx, c.filter); err != nil {
return err
}
}
_, err = s.db.Collection("finance_users").DeleteOne(ctx, bson.M{"_id": uid})
return err
}
func (s *Store) ensureAuthIndexes(ctx context.Context) {
s.db.Collection("finance_users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true).SetSparse(true),
})
s.db.Collection("finance_sessions").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "expires_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
})
}