diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s

Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 19:02:46 +02:00
parent b3f24cc440
commit 362f92e520
14 changed files with 1423 additions and 4 deletions
@@ -510,6 +510,249 @@ func TestDiplomailAdminBroadcast(t *testing.T) {
}
}
// 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()