feat: backend service
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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
|
||||
appendTo func(string)
|
||||
}
|
||||
|
||||
func (r *orderTracker) RevokeAllForUser(_ context.Context, userID uuid.UUID) error {
|
||||
r.calls++
|
||||
r.lastUser = userID
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user