Files
galaxy-game/backend/internal/diplomail/diplomail_e2e_test.go
T
Ilia Denisov b3f24cc440
Tests · Go / test (push) Successful in 1m52s
Tests · Go / test (pull_request) Successful in 1m53s
Tests · Integration / integration (pull_request) Successful in 1m36s
diplomail (Stage B): admin/owner sends + lifecycle hooks
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).

Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:47:54 +02:00

614 lines
18 KiB
Go

package diplomail_test
import (
"context"
"database/sql"
"errors"
"net/url"
"sync"
"testing"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/diplomail"
backendpg "galaxy/backend/internal/postgres"
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"
)
const (
testImage = "postgres:16-alpine"
testUser = "galaxy"
testPassword = "galaxy"
testDatabase = "galaxy_backend"
testSchema = "backend"
testStartup = 90 * time.Second
testOpTimeout = 10 * time.Second
)
// startPostgres mirrors the harness used by `lobby_e2e_test.go`. It
// spins up a postgres:16-alpine container, applies the embedded
// migrations, and returns a ready-to-use `*sql.DB`. The container is
// torn down via t.Cleanup.
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, testImage,
tcpostgres.WithDatabase(testDatabase),
tcpostgres.WithUsername(testUser),
tcpostgres.WithPassword(testPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(testStartup),
),
)
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)
}
scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
if err != nil {
t.Fatalf("scope dsn: %v", err)
}
cfg := pgshared.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = testOpTimeout
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close db: %v", err)
}
})
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
}
// recordingPublisher captures every emitted DiplomailNotification so
// the test can assert push fan-out without booting the real
// notification pipeline.
type recordingPublisher struct {
mu sync.Mutex
captured []diplomail.DiplomailNotification
}
func (p *recordingPublisher) PublishDiplomailEvent(_ context.Context, ev diplomail.DiplomailNotification) error {
p.mu.Lock()
defer p.mu.Unlock()
p.captured = append(p.captured, ev)
return nil
}
func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]diplomail.DiplomailNotification, len(p.captured))
copy(out, p.captured)
return out
}
// staticMembershipLookup serves an in-memory fixture. The test seeds
// memberships up-front and the lookup is keyed on (gameID, userID).
// Inactive rows (status != "active") are encoded by populating
// `inactive` instead of `rows`.
type staticMembershipLookup struct {
rows map[[2]uuid.UUID]diplomail.ActiveMembership
inactive map[[2]uuid.UUID]diplomail.MemberSnapshot
}
func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
if l == nil || l.rows == nil {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
row, ok := l.rows[[2]uuid.UUID{gameID, userID}]
if !ok {
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
}
return row, nil
}
func (l *staticMembershipLookup) GetMembershipAnyStatus(_ context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) {
if l == nil {
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}
if row, ok := l.rows[[2]uuid.UUID{gameID, userID}]; ok {
return diplomail.MemberSnapshot{
UserID: row.UserID,
GameID: row.GameID,
GameName: row.GameName,
UserName: row.UserName,
RaceName: row.RaceName,
Status: "active",
}, nil
}
if row, ok := l.inactive[[2]uuid.UUID{gameID, userID}]; ok {
return row, nil
}
return diplomail.MemberSnapshot{}, diplomail.ErrNotFound
}
func (l *staticMembershipLookup) ListMembers(_ context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) {
if l == nil {
return nil, nil
}
var out []diplomail.MemberSnapshot
for key, row := range l.rows {
if key[0] != gameID {
continue
}
out = append(out, diplomail.MemberSnapshot{
UserID: row.UserID,
GameID: row.GameID,
GameName: row.GameName,
UserName: row.UserName,
RaceName: row.RaceName,
Status: "active",
})
}
if scope == diplomail.RecipientScopeActiveAndRemoved || scope == diplomail.RecipientScopeAllMembers {
for key, row := range l.inactive {
if key[0] != gameID {
continue
}
if scope == diplomail.RecipientScopeActiveAndRemoved && row.Status != "removed" {
continue
}
out = append(out, row)
}
}
return out, nil
}
// seedAccount inserts a minimal accounts row so memberships and mail
// recipient FKs are satisfiable.
func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) {
t.Helper()
_, err := db.ExecContext(context.Background(), `
INSERT INTO backend.accounts (
user_id, email, user_name, preferred_language, time_zone
) VALUES ($1, $2, $3, 'en', 'UTC')
`, userID, userID.String()+"@test.local", "user-"+userID.String()[:8])
if err != nil {
t.Fatalf("seed account %s: %v", userID, err)
}
}
// seedGame inserts a minimal games row so the diplomail_messages.game_id
// FK is satisfiable.
func seedGame(t *testing.T, db *sql.DB, gameID uuid.UUID, name string) {
t.Helper()
_, err := db.ExecContext(context.Background(), `
INSERT INTO backend.games (
game_id, visibility, status, game_name,
min_players, max_players, start_gap_hours, start_gap_players,
enrollment_ends_at, turn_schedule, target_engine_version,
runtime_snapshot
) VALUES (
$1, 'private', 'enrollment_open', $2,
1, 4, 1, 1,
now() + interval '1 day', '0 0 * * *', '1.0.0',
'{}'::jsonb
)
`, gameID, name)
if err != nil {
t.Fatalf("seed game %s: %v", gameID, err)
}
}
func TestDiplomailPersonalFlow(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
sender := uuid.New()
recipient := uuid.New()
other := uuid.New()
seedAccount(t, db, sender)
seedAccount(t, db, recipient)
seedAccount(t, db, other)
seedGame(t, db, gameID, "Stage A Test Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, sender}: {
UserID: sender, GameID: gameID, GameName: "Stage A Test Game",
UserName: "sender", RaceName: "Senders",
},
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Stage A Test Game",
UserName: "recipient", RaceName: "Receivers",
},
},
}
publisher := &recordingPublisher{}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: publisher,
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
},
})
// 1. SendPersonal happy path.
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
Subject: "Trade proposal",
Body: "Care to talk gas mining?",
SenderIP: "203.0.113.4",
})
if err != nil {
t.Fatalf("send personal: %v", err)
}
if msg.Kind != diplomail.KindPersonal {
t.Fatalf("kind = %q, want personal", msg.Kind)
}
if rcpt.UserID != recipient {
t.Fatalf("recipient.UserID = %s, want %s", rcpt.UserID, recipient)
}
if rcpt.ReadAt != nil {
t.Fatalf("freshly sent message should be unread, read_at=%v", rcpt.ReadAt)
}
if got := publisher.snapshot(); len(got) != 1 {
t.Fatalf("publisher captured %d events, want 1", len(got))
} else if got[0].Recipient != recipient {
t.Fatalf("push recipient = %s, want %s", got[0].Recipient, recipient)
}
// 2. ListInbox shows the message for the recipient.
inbox, err := svc.ListInbox(ctx, gameID, recipient)
if err != nil {
t.Fatalf("list inbox: %v", err)
}
if len(inbox) != 1 || inbox[0].MessageID != msg.MessageID {
t.Fatalf("inbox = %+v, want one matching entry", inbox)
}
// 3. ListSent surfaces the message for the sender.
sent, err := svc.ListSent(ctx, gameID, sender)
if err != nil {
t.Fatalf("list sent: %v", err)
}
if len(sent) != 1 || sent[0].MessageID != msg.MessageID {
t.Fatalf("sent = %+v, want one matching entry", sent)
}
// 4. Non-recipient reads are 404.
if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) {
t.Fatalf("non-recipient get: %v, want ErrNotFound", err)
}
// 5. Delete before read is a conflict.
if _, err := svc.DeleteMessage(ctx, recipient, msg.MessageID); !errors.Is(err, diplomail.ErrConflict) {
t.Fatalf("delete before read: %v, want ErrConflict", err)
}
// 6. MarkRead sets read_at; second call is a no-op.
read, err := svc.MarkRead(ctx, recipient, msg.MessageID)
if err != nil {
t.Fatalf("mark read: %v", err)
}
if read.ReadAt == nil {
t.Fatalf("mark read returned no read_at")
}
again, err := svc.MarkRead(ctx, recipient, msg.MessageID)
if err != nil {
t.Fatalf("mark read idempotent: %v", err)
}
if !again.ReadAt.Equal(*read.ReadAt) {
t.Fatalf("mark read idempotent shifted read_at: %v -> %v", read.ReadAt, again.ReadAt)
}
// 7. Unread counts go to zero after the read.
counts, err := svc.UnreadCountsForUser(ctx, recipient)
if err != nil {
t.Fatalf("unread counts: %v", err)
}
if len(counts) != 0 {
t.Fatalf("unread counts = %+v, want empty after read", counts)
}
// 8. Soft delete now succeeds.
deleted, err := svc.DeleteMessage(ctx, recipient, msg.MessageID)
if err != nil {
t.Fatalf("delete after read: %v", err)
}
if deleted.DeletedAt == nil {
t.Fatalf("delete after read returned no deleted_at")
}
// 9. Inbox now excludes the soft-deleted message.
inbox, err = svc.ListInbox(ctx, gameID, recipient)
if err != nil {
t.Fatalf("list inbox after delete: %v", err)
}
if len(inbox) != 0 {
t.Fatalf("inbox after delete = %+v, want empty", inbox)
}
}
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
sender := uuid.New()
recipient := uuid.New()
seedAccount(t, db, sender)
seedAccount(t, db, recipient)
seedGame(t, db, gameID, "Solo Test Game")
// Only the recipient is on the active roster.
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Solo Test Game",
UserName: "recipient", RaceName: "Receivers",
},
},
}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
},
})
_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
Subject: "Hi",
Body: "Trade?",
})
if !errors.Is(err, diplomail.ErrForbidden) {
t.Fatalf("send from non-member: %v, want ErrForbidden", err)
}
}
func TestDiplomailAdminBroadcast(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
owner := uuid.New()
alice := uuid.New()
bob := uuid.New()
kickedCharlie := uuid.New()
seedAccount(t, db, owner)
seedAccount(t, db, alice)
seedAccount(t, db, bob)
seedAccount(t, db, kickedCharlie)
seedGame(t, db, gameID, "Broadcast Test Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, alice}: {
UserID: alice, GameID: gameID, GameName: "Broadcast Test Game",
UserName: "alice", RaceName: "AliceRace",
},
{gameID, bob}: {
UserID: bob, GameID: gameID, GameName: "Broadcast Test Game",
UserName: "bob", RaceName: "BobRace",
},
},
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
{gameID, kickedCharlie}: {
UserID: kickedCharlie, GameID: gameID, GameName: "Broadcast Test Game",
UserName: "charlie", RaceName: "CharlieRace", Status: "removed",
},
},
}
publisher := &recordingPublisher{}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: publisher,
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
},
})
ownerID := owner
msg, recipients, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
GameID: gameID,
CallerKind: diplomail.CallerKindOwner,
CallerUserID: &ownerID,
CallerUsername: "owner",
RecipientScope: diplomail.RecipientScopeActive,
Subject: "All hands",
Body: "Welcome to round two.",
SenderIP: "203.0.113.7",
})
if err != nil {
t.Fatalf("admin broadcast: %v", err)
}
if msg.Kind != diplomail.KindAdmin || msg.SenderKind != diplomail.SenderKindPlayer {
t.Fatalf("kind=%q sender_kind=%q, want admin/player", msg.Kind, msg.SenderKind)
}
if len(recipients) != 2 {
t.Fatalf("broadcast hit %d recipients, want 2 (alice+bob, kicked charlie excluded by active scope)", len(recipients))
}
if got := publisher.snapshot(); len(got) != 2 {
t.Fatalf("publisher captured %d events, want 2", len(got))
}
// active_and_removed should include the kicked recipient too.
msg2, recipients2, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{
GameID: gameID,
CallerKind: diplomail.CallerKindAdmin,
CallerUsername: "site-admin",
RecipientScope: diplomail.RecipientScopeActiveAndRemoved,
Body: "Post-game retrospective.",
})
if err != nil {
t.Fatalf("admin broadcast active_and_removed: %v", err)
}
if msg2.SenderKind != diplomail.SenderKindAdmin {
t.Fatalf("sender_kind=%q, want admin", msg2.SenderKind)
}
if len(recipients2) != 3 {
t.Fatalf("active_and_removed broadcast hit %d, want 3", len(recipients2))
}
// Kicked charlie sees the admin message but not the personal mail
// that alice might have sent before the kick (none here — the
// store path itself is exercised; the soft-access filter belongs
// to a separate test below).
charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie)
if err != nil {
t.Fatalf("kicked inbox: %v", err)
}
if len(charlieInbox) != 1 {
t.Fatalf("kicked inbox = %d entries, want 1 (only the active_and_removed broadcast)", len(charlieInbox))
}
}
func TestDiplomailLifecycleMembershipKick(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
kicked := uuid.New()
seedAccount(t, db, kicked)
seedGame(t, db, gameID, "Lifecycle Test Game")
lookup := &staticMembershipLookup{
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
{gameID, kicked}: {
UserID: kicked, GameID: gameID, GameName: "Lifecycle Test Game",
UserName: "kicked", RaceName: "KickedRace", Status: "blocked",
},
},
}
publisher := &recordingPublisher{}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: publisher,
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
},
})
target := kicked
if err := svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{
GameID: gameID,
Kind: diplomail.LifecycleKindMembershipBlocked,
Actor: "an administrator",
Reason: "rule violation",
TargetUser: &target,
}); err != nil {
t.Fatalf("publish lifecycle: %v", err)
}
if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked {
t.Fatalf("publisher captured %+v, want one event addressed to kicked", got)
}
inbox, err := svc.ListInbox(ctx, gameID, kicked)
if err != nil {
t.Fatalf("kicked inbox: %v", err)
}
if len(inbox) != 1 {
t.Fatalf("kicked inbox = %d, want 1 system message", len(inbox))
}
if inbox[0].Kind != diplomail.KindAdmin || inbox[0].SenderKind != diplomail.SenderKindSystem {
t.Fatalf("kind=%q sender_kind=%q, want admin/system", inbox[0].Kind, inbox[0].SenderKind)
}
}
func TestDiplomailRejectsOverlongBody(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
sender := uuid.New()
recipient := uuid.New()
seedAccount(t, db, sender)
seedAccount(t, db, recipient)
seedGame(t, db, gameID, "Length Test Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, sender}: {
UserID: sender, GameID: gameID, GameName: "Length Test Game",
UserName: "sender", RaceName: "Senders",
},
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Length Test Game",
UserName: "recipient", RaceName: "Receivers",
},
},
}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Config: config.DiplomailConfig{
MaxBodyBytes: 32,
MaxSubjectBytes: 256,
},
})
bigBody := make([]byte, 64)
for i := range bigBody {
bigBody[i] = 'a'
}
_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
Body: string(bigBody),
})
if !errors.Is(err, diplomail.ErrInvalidInput) {
t.Fatalf("send overlong: %v, want ErrInvalidInput", err)
}
}