Phase 28 (Step 1): backend support for race-name mail send
Phase 28's in-game mail UI groups personal threads by the other party's race. To support that without an extra membership-listing RPC, the diplomail subsystem now: - accepts `recipient_race_name` on `POST /messages` and `POST /admin` (target=user) as an alternative to `recipient_user_id`; the service resolves it via the existing `Memberships.ListMembers(gameID, "active")` and rejects with `forbidden` when the matching member is no longer active; - snapshots `diplomail_messages.sender_race_name` at send time for every player sender (admin / system rows stay NULL). The UI keys per-race threading on this column. Schema, openapi, README, and a focused e2e test for the new path (happy path + dual / missing / unknown / kicked errors) land in this commit; the gateway + UI legs follow in subsequent commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -369,6 +369,114 @@ func TestDiplomailPersonalFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiplomailPersonalByRaceName exercises the Phase 28 contract: the
|
||||
// UI passes a recipient race name (read out of the game report); the
|
||||
// service resolves it to the active member with that race name and
|
||||
// snapshots the sender's race onto the message row. Error cases cover
|
||||
// the validation rules baked into the wire schema.
|
||||
func TestDiplomailPersonalByRaceName(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
sender := uuid.New()
|
||||
recipient := uuid.New()
|
||||
kicked := uuid.New()
|
||||
seedAccount(t, db, sender)
|
||||
seedAccount(t, db, recipient)
|
||||
seedAccount(t, db, kicked)
|
||||
seedGame(t, db, gameID, "Race-Name Resolution Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, sender}: {
|
||||
UserID: sender, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||
UserName: "sender", RaceName: "Senders",
|
||||
},
|
||||
{gameID, recipient}: {
|
||||
UserID: recipient, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||
UserName: "recipient", RaceName: "Receivers",
|
||||
},
|
||||
},
|
||||
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
|
||||
{gameID, kicked}: {
|
||||
UserID: kicked, GameID: gameID, GameName: "Race-Name Resolution Game",
|
||||
UserName: "kicked", RaceName: "Departed", Status: "removed",
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Notification: &recordingPublisher{},
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
// Happy path: race name resolves and sender_race_name is snapshotted.
|
||||
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientRaceName: "Receivers",
|
||||
Subject: "Trade",
|
||||
Body: "Care to talk?",
|
||||
SenderIP: "203.0.113.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send by race name: %v", err)
|
||||
}
|
||||
if rcpt.UserID != recipient {
|
||||
t.Fatalf("recipient = %s, want %s", rcpt.UserID, recipient)
|
||||
}
|
||||
if msg.SenderRaceName == nil || *msg.SenderRaceName != "Senders" {
|
||||
t.Fatalf("sender_race_name = %v, want \"Senders\"", msg.SenderRaceName)
|
||||
}
|
||||
|
||||
// Both identifiers supplied → invalid_request.
|
||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientUserID: recipient,
|
||||
RecipientRaceName: "Receivers",
|
||||
Body: "x",
|
||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
t.Fatalf("dual identifier: %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// Neither identifier supplied → invalid_request.
|
||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
Body: "x",
|
||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
t.Fatalf("no identifier: %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// Race name with no matching active member → invalid_request.
|
||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientRaceName: "Strangers",
|
||||
Body: "x",
|
||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
t.Fatalf("unknown race: %v, want ErrInvalidInput", err)
|
||||
}
|
||||
|
||||
// Race name of a lobby-removed member → invalid_request (the
|
||||
// active-only scope filters them out; the lookup never returns
|
||||
// them).
|
||||
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientRaceName: "Departed",
|
||||
Body: "x",
|
||||
}); !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
t.Fatalf("kicked race: %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user