package user_test import ( "context" "errors" "testing" "time" "galaxy/backend/internal/user" "github.com/google/uuid" ) // TestSoftDeleteCascadeRunsAuthLobbyNotificationGeoInOrder verifies the // documented cascade order. The test uses recording stubs for every // hook and asserts that each one received the soft-delete signal // exactly once for the right user_id. func TestSoftDeleteCascadeRunsAuthLobbyNotificationGeoInOrder(t *testing.T) { db := startPostgres(t) revoker := &orderTracker{name: "auth"} lobby := &orderingLobbyCascade{name: "lobby"} notif := &orderingNotificationCascade{name: "notification"} geo := &orderingGeoCascade{name: "geo"} var order []string revoker.appendTo = func(s string) { order = append(order, s) } lobby.appendTo = func(s string) { order = append(order, s) } notif.appendTo = func(s string) { order = append(order, s) } geo.appendTo = func(s string) { order = append(order, s) } svc := user.NewService(user.Deps{ Store: user.NewStore(db), Cache: user.NewCache(), Lobby: lobby, Notification: notif, Geo: geo, SessionRevoker: revoker, UserNameMaxRetries: 10, Now: time.Now, }) uid, err := svc.EnsureByEmail(context.Background(), "leo@example.test", "en", "UTC", "") if err != nil { t.Fatalf("EnsureByEmail: %v", err) } if err := svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()}); err != nil { t.Fatalf("SoftDelete: %v", err) } want := []string{"auth", "lobby", "notification", "geo"} if !equalStrings(order, want) { t.Fatalf("cascade order = %v, want %v", order, want) } // Second call is a no-op — cascade must not fire again. if err := svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()}); err != nil { t.Fatalf("idempotent SoftDelete: %v", err) } if !equalStrings(order, want) { t.Fatalf("idempotent SoftDelete fired cascade again: %v", order) } } // TestSoftDeleteCascadeErrorDoesNotRollback covers the contract that // cascade failures are surfaced to the caller but do not undo the // `accounts.deleted_at` write. func TestSoftDeleteCascadeErrorDoesNotRollback(t *testing.T) { db := startPostgres(t) failingNotif := &failingNotificationCascade{err: errors.New("notification down")} svc := user.NewService(user.Deps{ Store: user.NewStore(db), Cache: user.NewCache(), Lobby: &orderingLobbyCascade{}, Notification: failingNotif, Geo: &orderingGeoCascade{}, SessionRevoker: &orderTracker{}, UserNameMaxRetries: 10, Now: time.Now, }) uid, err := svc.EnsureByEmail(context.Background(), "mia@example.test", "en", "UTC", "") if err != nil { t.Fatalf("EnsureByEmail: %v", err) } err = svc.SoftDelete(context.Background(), uid, user.ActorRef{Type: "user", ID: uid.String()}) if err == nil { t.Fatalf("SoftDelete returned nil despite failing cascade") } if !errors.Is(err, failingNotif.err) { t.Fatalf("SoftDelete error = %v, want join containing %v", err, failingNotif.err) } var deletedAt *time.Time if scanErr := db.QueryRowContext(context.Background(), `SELECT deleted_at FROM backend.accounts WHERE user_id = $1`, uid, ).Scan(&deletedAt); scanErr != nil { t.Fatalf("SELECT deleted_at: %v", scanErr) } if deletedAt == nil { t.Fatalf("deleted_at = NULL despite SoftDelete commit") } } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // orderTracker spies on a single call kind and pushes its name into // the ordered slice when invoked. It satisfies user.SessionRevoker. type orderTracker struct { name string calls int lastUser uuid.UUID lastActor user.SessionRevokeActor appendTo func(string) } func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID, actor user.SessionRevokeActor) error { r.calls++ r.lastUser = userID r.lastActor = actor if r.appendTo != nil && r.name != "" { r.appendTo(r.name) } return nil } type orderingLobbyCascade struct { name string appendTo func(string) deleted int blocked int } func (c *orderingLobbyCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error { c.deleted++ if c.appendTo != nil && c.name != "" { c.appendTo(c.name) } return nil } func (c *orderingLobbyCascade) OnUserBlocked(_ context.Context, _ uuid.UUID) error { c.blocked++ return nil } type orderingNotificationCascade struct { name string appendTo func(string) calls int } func (c *orderingNotificationCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error { c.calls++ if c.appendTo != nil && c.name != "" { c.appendTo(c.name) } return nil } type orderingGeoCascade struct { name string appendTo func(string) calls int } func (c *orderingGeoCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error { c.calls++ if c.appendTo != nil && c.name != "" { c.appendTo(c.name) } return nil } type failingNotificationCascade struct { err error calls int } func (c *failingNotificationCascade) OnUserDeleted(_ context.Context, _ uuid.UUID) error { c.calls++ return c.err }