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>
This commit is contained in:
@@ -126,8 +126,11 @@ func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification {
|
||||
|
||||
// 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
|
||||
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) {
|
||||
@@ -141,6 +144,58 @@ func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID,
|
||||
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) {
|
||||
@@ -355,6 +410,160 @@ func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user