package diplomail_test import ( "context" "database/sql" "errors" "net/url" "sync" "testing" "time" "galaxy/backend/internal/config" "galaxy/backend/internal/diplomail" "galaxy/backend/internal/diplomail/translator" 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)) } } // staticEntitlement satisfies diplomail.EntitlementReader by reading // a fixed map keyed on user_id. type staticEntitlement struct { paid map[uuid.UUID]bool } func (s *staticEntitlement) IsPaidTier(_ context.Context, userID uuid.UUID) (bool, error) { if s == nil { return false, nil } return s.paid[userID], nil } // staticGameLookup satisfies diplomail.GameLookup by walking a fixed // list of GameSnapshot fixtures. Tests prepend rows via the New // helper. type staticGameLookup struct { games map[uuid.UUID]diplomail.GameSnapshot } func (l *staticGameLookup) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) { if l == nil { return nil, nil } out := make([]diplomail.GameSnapshot, 0, len(l.games)) for _, g := range l.games { switch g.Status { case "running", "paused", "ready_to_start", "starting": out = append(out, g) } } return out, nil } func (l *staticGameLookup) ListFinishedGamesBefore(_ context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) { if l == nil { return nil, nil } out := make([]diplomail.GameSnapshot, 0, len(l.games)) for _, g := range l.games { if g.Status != "finished" && g.Status != "cancelled" { continue } if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) { continue } out = append(out, g) } return out, nil } func (l *staticGameLookup) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) { if l == nil { return diplomail.GameSnapshot{}, diplomail.ErrNotFound } g, ok := l.games[gameID] if !ok { return diplomail.GameSnapshot{}, diplomail.ErrNotFound } return g, nil } func TestDiplomailPaidTierBroadcast(t *testing.T) { db := startPostgres(t) ctx := context.Background() gameID := uuid.New() paidPlayer := uuid.New() freePlayer := uuid.New() alice := uuid.New() bob := uuid.New() seedAccount(t, db, paidPlayer) seedAccount(t, db, freePlayer) seedAccount(t, db, alice) seedAccount(t, db, bob) seedGame(t, db, gameID, "Paid Broadcast Game") lookup := &staticMembershipLookup{ rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ {gameID, paidPlayer}: { UserID: paidPlayer, GameID: gameID, GameName: "Paid Broadcast Game", UserName: "paid", RaceName: "PaidRace", }, {gameID, freePlayer}: { UserID: freePlayer, GameID: gameID, GameName: "Paid Broadcast Game", UserName: "free", RaceName: "FreeRace", }, {gameID, alice}: { UserID: alice, GameID: gameID, GameName: "Paid Broadcast Game", UserName: "alice", RaceName: "AliceRace", }, {gameID, bob}: { UserID: bob, GameID: gameID, GameName: "Paid Broadcast Game", UserName: "bob", RaceName: "BobRace", }, }, } publisher := &recordingPublisher{} entitlements := &staticEntitlement{paid: map[uuid.UUID]bool{paidPlayer: true}} svc := diplomail.NewService(diplomail.Deps{ Store: diplomail.NewStore(db), Memberships: lookup, Notification: publisher, Entitlements: entitlements, Config: config.DiplomailConfig{ MaxBodyBytes: 4096, MaxSubjectBytes: 256, }, }) // Paid sender: broadcast succeeds. msg, recipients, err := svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ GameID: gameID, SenderUserID: paidPlayer, Subject: "Alliance", Body: "Let us form a coalition.", }) if err != nil { t.Fatalf("paid broadcast: %v", err) } if msg.Kind != diplomail.KindPersonal || msg.BroadcastScope != diplomail.BroadcastScopeGameBroadcast { t.Fatalf("kind=%q scope=%q, want personal/game_broadcast", msg.Kind, msg.BroadcastScope) } if len(recipients) != 3 { t.Fatalf("broadcast recipients=%d, want 3 (everyone but sender)", len(recipients)) } // Free-tier sender: 403. _, _, err = svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ GameID: gameID, SenderUserID: freePlayer, Body: "Should not be allowed.", }) if !errors.Is(err, diplomail.ErrForbidden) { t.Fatalf("free broadcast: %v, want ErrForbidden", err) } } func TestDiplomailMultiGameBroadcastAndCleanup(t *testing.T) { db := startPostgres(t) ctx := context.Background() game1 := uuid.New() game2 := uuid.New() finished := uuid.New() alice := uuid.New() bob := uuid.New() carol := uuid.New() for _, id := range []uuid.UUID{alice, bob, carol} { seedAccount(t, db, id) } seedGame(t, db, game1, "Active Game 1") seedGame(t, db, game2, "Active Game 2") seedGame(t, db, finished, "Finished Game") // Mark `finished` terminal with a long-past finished_at. if _, err := db.ExecContext(ctx, ` UPDATE backend.games SET status='finished', finished_at = now() - interval '3 years' WHERE game_id = $1 `, finished); err != nil { t.Fatalf("backdate finished: %v", err) } lookup := &staticMembershipLookup{ rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ {game1, alice}: {UserID: alice, GameID: game1, GameName: "Active Game 1", UserName: "alice", RaceName: "AliceRace"}, {game1, bob}: {UserID: bob, GameID: game1, GameName: "Active Game 1", UserName: "bob", RaceName: "BobRace"}, {game2, carol}: {UserID: carol, GameID: game2, GameName: "Active Game 2", UserName: "carol", RaceName: "CarolRace"}, {finished, alice}: {UserID: alice, GameID: finished, GameName: "Finished Game", UserName: "alice", RaceName: "AliceRace"}, }, } publisher := &recordingPublisher{} finAt := time.Now().UTC().AddDate(-3, 0, 0) games := &staticGameLookup{games: map[uuid.UUID]diplomail.GameSnapshot{ game1: {GameID: game1, GameName: "Active Game 1", Status: "running"}, game2: {GameID: game2, GameName: "Active Game 2", Status: "running"}, finished: {GameID: finished, GameName: "Finished Game", Status: "finished", FinishedAt: &finAt}, }} svc := diplomail.NewService(diplomail.Deps{ Store: diplomail.NewStore(db), Memberships: lookup, Notification: publisher, Games: games, Config: config.DiplomailConfig{ MaxBodyBytes: 4096, MaxSubjectBytes: 256, }, }) // First, drop a personal message into the finished game so cleanup // has something to remove. if _, _, err := svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ GameID: finished, CallerKind: diplomail.CallerKindAdmin, CallerUsername: "ops", RecipientUserID: alice, Body: "Audit ping", }); err != nil { t.Fatalf("seed finished-game mail: %v", err) } // Multi-game broadcast across all running games. msgs, total, err := svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{ CallerUsername: "ops", Scope: diplomail.MultiGameScopeAllRunning, RecipientScope: diplomail.RecipientScopeActive, Subject: "Maintenance", Body: "Brief turn-engine restart in 10 minutes.", }) if err != nil { t.Fatalf("multi-game broadcast: %v", err) } if len(msgs) != 2 { t.Fatalf("multi-game messages=%d, want 2 (game1 + game2)", len(msgs)) } if total != 3 { t.Fatalf("multi-game recipient count=%d, want 3 (alice+bob in g1, carol in g2)", total) } // Bulk cleanup with 1-year cutoff should sweep the finished game. result, err := svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: 1}) if err != nil { t.Fatalf("bulk cleanup: %v", err) } if len(result.GameIDs) != 1 || result.GameIDs[0] != finished { t.Fatalf("cleanup game_ids=%v, want [%s]", result.GameIDs, finished) } if result.MessagesDeleted < 1 { t.Fatalf("cleanup messages_deleted=%d, want >=1", result.MessagesDeleted) } // Admin listing sees the multi-game messages. page, err := svc.ListMessagesForAdmin(ctx, diplomail.AdminMessageListing{Page: 1, PageSize: 50}) if err != nil { t.Fatalf("list admin messages: %v", err) } if page.Total < 2 { t.Fatalf("list total=%d, want >=2 after cleanup", page.Total) } } 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) } } // staticTranslator returns deterministic renderings so the // translation-cache test can assert against known output. type staticTranslator struct { engine string } func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) { return translator.Result{ Subject: "[" + dstLang + "] " + subject, Body: "[" + dstLang + "] " + body, Engine: s.engine, }, nil } func TestDiplomailTranslationCache(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, "Translation Test Game") lookup := &staticMembershipLookup{ rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ {gameID, sender}: { UserID: sender, GameID: gameID, GameName: "Translation Test Game", UserName: "sender", RaceName: "SendersRace", }, {gameID, recipient}: { UserID: recipient, GameID: gameID, GameName: "Translation Test Game", UserName: "recipient", RaceName: "ReceiversRace", }, }, } publisher := &recordingPublisher{} englishDetector := detectorFn(func(_ string) string { return "en" }) svc := diplomail.NewService(diplomail.Deps{ Store: diplomail.NewStore(db), Memberships: lookup, Notification: publisher, Detector: englishDetector, Translator: &staticTranslator{engine: "stub"}, Config: config.DiplomailConfig{ MaxBodyBytes: 4096, MaxSubjectBytes: 256, }, }) msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ GameID: gameID, SenderUserID: sender, RecipientUserID: recipient, Subject: "Hello", Body: "Please share the latest map snapshot.", }) if err != nil { t.Fatalf("send: %v", err) } if msg.BodyLang != "en" { t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang) } // First read materialises a cached translation. entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") if err != nil { t.Fatalf("get message: %v", err) } if entry.Translation == nil { t.Fatalf("translation missing on first read") } if entry.Translation.TargetLang != "ru" { t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang) } if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." { t.Fatalf("translated body = %q", entry.Translation.TranslatedBody) } // Second read returns the same cached row (no re-translation). entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") if err != nil { t.Fatalf("get message twice: %v", err) } if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID { t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID) } // Same language as body: no translation. entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en") if err != nil { t.Fatalf("get message same lang: %v", err) } if entrySame.Translation != nil { t.Fatalf("translation populated when target == body_lang") } } // detectorFn lets the test inject deterministic detection without // dragging the whatlanggo profile into the test fixtures. type detectorFn func(string) string func (f detectorFn) Detect(s string) string { return f(s) } 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) } }