Phase 28 (Step 1): backend support for race-name mail send
Tests · Go / test (push) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m2s

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:
Ilia Denisov
2026-05-15 22:07:48 +02:00
parent 74c1e7ab24
commit 7b43ce5844
12 changed files with 372 additions and 87 deletions
@@ -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()