196 lines
5.2 KiB
Go
196 lines
5.2 KiB
Go
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
|
|
}
|