Files
galaxy-game/backend/internal/notification/submit_test.go
T
2026-05-06 10:14:55 +03:00

459 lines
13 KiB
Go

package notification_test
import (
"context"
"database/sql"
"errors"
"net/url"
"sync"
"testing"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/notification"
backendpg "galaxy/backend/internal/postgres"
"galaxy/backend/internal/user"
pgshared "galaxy/postgres"
"github.com/google/uuid"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"go.uber.org/zap/zaptest"
)
const (
pgImage = "postgres:16-alpine"
pgUser = "galaxy"
pgPassword = "galaxy"
pgDatabase = "galaxy_backend"
pgSchema = "backend"
pgStartup = 90 * time.Second
pgOpTO = 10 * time.Second
)
// startPostgres mirrors the mail/auth scaffolding: spin up Postgres,
// apply migrations, return *sql.DB.
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx, pgImage,
tcpostgres.WithDatabase(pgDatabase),
tcpostgres.WithUsername(pgUser),
tcpostgres.WithPassword(pgPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(pgStartup),
),
)
if err != nil {
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
}
t.Cleanup(func() {
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
t.Errorf("terminate postgres container: %v", termErr)
}
})
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
scoped, err := dsnWithSearchPath(baseDSN, pgSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scoped
cfg.OperationTimeout = pgOpTO
db, err := pgshared.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := parsed.Query()
values.Set("search_path", schema)
if values.Get("sslmode") == "" {
values.Set("sslmode", "disable")
}
parsed.RawQuery = values.Encode()
return parsed.String(), nil
}
// recordingMailer captures every EnqueueTemplate call.
type recordingMailer struct {
mu sync.Mutex
calls []recordedEnqueue
err error
}
type recordedEnqueue struct {
TemplateID string
Recipient string
Payload map[string]any
IdempotencyKey string
}
func (r *recordingMailer) EnqueueTemplate(_ context.Context, templateID, recipient string, payload map[string]any, idempotencyKey string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.err != nil {
return r.err
}
r.calls = append(r.calls, recordedEnqueue{
TemplateID: templateID,
Recipient: recipient,
Payload: payload,
IdempotencyKey: idempotencyKey,
})
return nil
}
func (r *recordingMailer) Calls() []recordedEnqueue {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedEnqueue, len(r.calls))
copy(out, r.calls)
return out
}
// recordingPush captures every PublishClientEvent call.
type recordingPush struct {
mu sync.Mutex
calls []recordedPushEvent
}
type recordedPushEvent struct {
UserID uuid.UUID
Kind string
Payload map[string]any
EventID string
RequestID string
TraceID string
}
func (r *recordingPush) PublishClientEvent(_ context.Context, userID uuid.UUID, _ *uuid.UUID, kind string, payload map[string]any, eventID, requestID, traceID string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, recordedPushEvent{
UserID: userID,
Kind: kind,
Payload: payload,
EventID: eventID,
RequestID: requestID,
TraceID: traceID,
})
return nil
}
func (r *recordingPush) Calls() []recordedPushEvent {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedPushEvent, len(r.calls))
copy(out, r.calls)
return out
}
// stubAccounts hands back a fixed account record for any user_id, so
// tests don't need to seed the accounts table.
type stubAccounts struct {
account user.Account
err error
}
func (s *stubAccounts) GetAccount(_ context.Context, userID uuid.UUID) (user.Account, error) {
if s.err != nil {
return user.Account{}, s.err
}
out := s.account
out.UserID = userID
return out, nil
}
func newService(t *testing.T, db *sql.DB, mailer notification.Mailer, push notification.PushPublisher, accounts notification.AccountResolver, adminEmail string) *notification.Service {
t.Helper()
cfg := config.NotificationConfig{
AdminEmail: adminEmail,
WorkerInterval: 10 * time.Millisecond,
MaxAttempts: 3,
}
return notification.NewService(notification.Deps{
Store: notification.NewStore(db),
Mail: mailer,
Push: push,
Accounts: accounts,
Config: cfg,
Logger: zaptest.NewLogger(t),
})
}
func TestSubmitFansOutLobbyInviteToPushAndEmail(t *testing.T) {
t.Parallel()
db := startPostgres(t)
mailer := &recordingMailer{}
push := &recordingPush{}
accounts := &stubAccounts{account: user.Account{
Email: "alice@example.test",
PreferredLanguage: "en",
}}
svc := newService(t, db, mailer, push, accounts, "")
recipient := uuid.New()
id, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindLobbyInviteReceived,
IdempotencyKey: "invite:" + uuid.NewString(),
Recipients: []uuid.UUID{recipient},
Payload: map[string]any{
"game_id": uuid.NewString(),
"inviter_user_id": uuid.NewString(),
},
})
if err != nil {
t.Fatalf("submit: %v", err)
}
if id == uuid.Nil {
t.Fatal("submit returned nil id")
}
// Best-effort dispatch ran synchronously; both channels should
// have observed exactly one call.
if got := len(push.Calls()); got != 1 {
t.Errorf("push calls=%d, want 1", got)
}
if got := len(mailer.Calls()); got != 1 {
t.Errorf("mail calls=%d, want 1", got)
} else {
call := mailer.Calls()[0]
if call.Recipient != "alice@example.test" {
t.Errorf("mail recipient=%q", call.Recipient)
}
if call.TemplateID != notification.KindLobbyInviteReceived {
t.Errorf("mail template=%q", call.TemplateID)
}
}
}
func TestSubmitIsIdempotent(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{account: user.Account{Email: "x@example.test"}}, "")
intent := notification.Intent{
Kind: notification.KindLobbyApplicationSubmitted,
IdempotencyKey: "dedupe-key",
Recipients: []uuid.UUID{uuid.New()},
Payload: map[string]any{"game_id": uuid.NewString(), "application_id": uuid.NewString()},
}
first, err := svc.Submit(context.Background(), intent)
if err != nil {
t.Fatalf("first submit: %v", err)
}
second, err := svc.Submit(context.Background(), intent)
if err != nil {
t.Fatalf("second submit: %v", err)
}
if first != second {
t.Fatalf("idempotent submit must return same id: %s vs %s", first, second)
}
}
func TestSubmitMalformedPersists(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{}, "")
id, err := svc.Submit(context.Background(), notification.Intent{
Kind: "nonsense.kind",
IdempotencyKey: "anything",
Recipients: []uuid.UUID{uuid.New()},
})
if err != nil {
t.Fatalf("submit: %v", err)
}
if id != uuid.Nil {
t.Fatalf("malformed submit must return nil id, got %s", id)
}
page, err := svc.AdminListMalformed(context.Background(), 1, 10)
if err != nil {
t.Fatalf("list malformed: %v", err)
}
if page.Total < 1 {
t.Fatalf("malformed total=%d, want >= 1", page.Total)
}
}
func TestSubmitAdminEmailSkipsWhenNotConfigured(t *testing.T) {
t.Parallel()
db := startPostgres(t)
mailer := &recordingMailer{}
svc := newService(t, db, mailer, &recordingPush{}, &stubAccounts{}, "")
id, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindRuntimeImagePullFailed,
IdempotencyKey: "ipf-1",
Payload: map[string]any{"game_id": uuid.NewString(), "image_ref": "registry/img:tag"},
})
if err != nil {
t.Fatalf("submit: %v", err)
}
if id == uuid.Nil {
t.Fatal("admin submit returned nil id")
}
if got := len(mailer.Calls()); got != 0 {
t.Errorf("mail calls=%d, want 0 (admin email unset)", got)
}
}
func TestSubmitAdminEmailDispatchesWhenConfigured(t *testing.T) {
t.Parallel()
db := startPostgres(t)
mailer := &recordingMailer{}
svc := newService(t, db, mailer, &recordingPush{}, &stubAccounts{}, "ops@example.test")
if _, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindRuntimeContainerStartFailed,
IdempotencyKey: "csf-1",
Payload: map[string]any{"game_id": uuid.NewString()},
}); err != nil {
t.Fatalf("submit: %v", err)
}
calls := mailer.Calls()
if len(calls) != 1 {
t.Fatalf("mail calls=%d, want 1", len(calls))
}
if calls[0].Recipient != "ops@example.test" {
t.Errorf("admin recipient=%q", calls[0].Recipient)
}
}
func TestSubmitMissingAccountSkipsEmail(t *testing.T) {
t.Parallel()
db := startPostgres(t)
mailer := &recordingMailer{}
push := &recordingPush{}
accounts := &stubAccounts{err: user.ErrAccountNotFound}
svc := newService(t, db, mailer, push, accounts, "")
if _, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindLobbyApplicationApproved,
IdempotencyKey: "missing-1",
Recipients: []uuid.UUID{uuid.New()},
Payload: map[string]any{"game_id": uuid.NewString()},
}); err != nil {
t.Fatalf("submit: %v", err)
}
if got := len(mailer.Calls()); got != 0 {
t.Errorf("mail calls=%d want 0 when account missing", got)
}
if got := len(push.Calls()); got != 0 {
t.Errorf("push calls=%d want 0 when account missing", got)
}
}
func TestWorkerRetryAndDeadLetter(t *testing.T) {
t.Parallel()
db := startPostgres(t)
failingMailer := &recordingMailer{err: errors.New("smtp down")}
push := &recordingPush{}
accounts := &stubAccounts{account: user.Account{Email: "alice@example.test", PreferredLanguage: "en"}}
svc := newService(t, db, failingMailer, push, accounts, "")
// MaxAttempts=3 from newService config. Submit fires one
// best-effort attempt; subsequent Tick calls drive attempts 2 and
// 3, the last one dead-letters.
if _, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindLobbyInviteReceived,
IdempotencyKey: "fail-1",
Recipients: []uuid.UUID{uuid.New()},
Payload: map[string]any{"game_id": uuid.NewString(), "inviter_user_id": uuid.NewString()},
}); err != nil {
t.Fatalf("submit: %v", err)
}
// Force every retry to be due immediately.
if _, err := db.Exec(`UPDATE backend.notification_routes SET next_attempt_at = now() WHERE channel = 'email'`); err != nil {
t.Fatalf("force due: %v", err)
}
worker := notification.NewWorker(svc)
for range 5 {
if err := worker.Tick(context.Background()); err != nil {
t.Fatalf("tick: %v", err)
}
if _, err := db.Exec(`UPDATE backend.notification_routes SET next_attempt_at = now() WHERE channel = 'email' AND status = 'retrying'`); err != nil {
t.Fatalf("force due: %v", err)
}
}
dead, err := svc.AdminListDeadLetters(context.Background(), 1, 10)
if err != nil {
t.Fatalf("list dead-letters: %v", err)
}
if dead.Total < 1 {
t.Fatalf("expected dead-letter row, got total=%d (mail attempts=%d)", dead.Total, len(failingMailer.Calls()))
}
}
func TestOnUserDeletedSkipsPendingRoutes(t *testing.T) {
t.Parallel()
db := startPostgres(t)
failingMailer := &recordingMailer{err: errors.New("smtp down")}
push := &recordingPush{}
userID := uuid.New()
accounts := &stubAccounts{account: user.Account{Email: "alice@example.test", PreferredLanguage: "en"}}
svc := newService(t, db, failingMailer, push, accounts, "")
// Submit something that owns user_id so the cascade picks it up.
if _, err := svc.Submit(context.Background(), notification.Intent{
Kind: notification.KindLobbyApplicationApproved,
IdempotencyKey: "cascade-1",
Recipients: []uuid.UUID{userID},
Payload: map[string]any{"game_id": uuid.NewString()},
}); err != nil {
t.Fatalf("submit: %v", err)
}
if err := svc.OnUserDeleted(context.Background(), userID); err != nil {
t.Fatalf("OnUserDeleted: %v", err)
}
var skipped int
if err := db.QueryRow(`
SELECT COUNT(*)
FROM backend.notification_routes r
JOIN backend.notifications n ON n.notification_id = r.notification_id
WHERE n.user_id = $1 AND r.status = 'skipped'
`, userID).Scan(&skipped); err != nil {
t.Fatalf("count skipped: %v", err)
}
if skipped == 0 {
t.Fatal("expected at least one skipped route after cascade")
}
}
func TestAdminGetMissing(t *testing.T) {
t.Parallel()
db := startPostgres(t)
svc := newService(t, db, &recordingMailer{}, &recordingPush{}, &stubAccounts{}, "")
if _, err := svc.AdminGetNotification(context.Background(), uuid.New()); !errors.Is(err, notification.ErrNotificationNotFound) {
t.Fatalf("got %v, want ErrNotificationNotFound", err)
}
}