diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user