diplomail (Stage A): add in-game personal mail subsystem
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail channel; the existing `mail` package is a transactional email outbox and the `notification` catalog is one-way platform events. Stage A lands the schema (diplomail_messages / _recipients / _translations), a single-recipient personal send/read/delete service path, a `diplomail.message.received` push kind plumbed through the notification pipeline, and an unread-counts endpoint that drives the lobby badge. Admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge and language detection / translation cache come in stages B–D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
package diplomail_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/diplomail"
|
||||
backendpg "galaxy/backend/internal/postgres"
|
||||
pgshared "galaxy/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
testImage = "postgres:16-alpine"
|
||||
testUser = "galaxy"
|
||||
testPassword = "galaxy"
|
||||
testDatabase = "galaxy_backend"
|
||||
testSchema = "backend"
|
||||
testStartup = 90 * time.Second
|
||||
testOpTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// startPostgres mirrors the harness used by `lobby_e2e_test.go`. It
|
||||
// spins up a postgres:16-alpine container, applies the embedded
|
||||
// migrations, and returns a ready-to-use `*sql.DB`. The container is
|
||||
// torn down via t.Cleanup.
|
||||
func startPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pgContainer, err := tcpostgres.Run(ctx, testImage,
|
||||
tcpostgres.WithDatabase(testDatabase),
|
||||
tcpostgres.WithUsername(testUser),
|
||||
tcpostgres.WithPassword(testPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(testStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
||||
t.Errorf("terminate postgres container: %v", termErr)
|
||||
}
|
||||
})
|
||||
|
||||
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("connection string: %v", err)
|
||||
}
|
||||
scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema)
|
||||
if err != nil {
|
||||
t.Fatalf("scope dsn: %v", err)
|
||||
}
|
||||
cfg := pgshared.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = testOpTimeout
|
||||
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||
if err != nil {
|
||||
t.Fatalf("open primary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close db: %v", err)
|
||||
}
|
||||
})
|
||||
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := parsed.Query()
|
||||
values.Set("search_path", schema)
|
||||
if values.Get("sslmode") == "" {
|
||||
values.Set("sslmode", "disable")
|
||||
}
|
||||
parsed.RawQuery = values.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// recordingPublisher captures every emitted DiplomailNotification so
|
||||
// the test can assert push fan-out without booting the real
|
||||
// notification pipeline.
|
||||
type recordingPublisher struct {
|
||||
mu sync.Mutex
|
||||
captured []diplomail.DiplomailNotification
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) PublishDiplomailEvent(_ context.Context, ev diplomail.DiplomailNotification) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.captured = append(p.captured, ev)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]diplomail.DiplomailNotification, len(p.captured))
|
||||
copy(out, p.captured)
|
||||
return out
|
||||
}
|
||||
|
||||
// staticMembershipLookup serves an in-memory fixture. The test seeds
|
||||
// memberships up-front and the lookup is keyed on (gameID, userID).
|
||||
type staticMembershipLookup struct {
|
||||
rows map[[2]uuid.UUID]diplomail.ActiveMembership
|
||||
}
|
||||
|
||||
func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) {
|
||||
if l == nil || l.rows == nil {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
row, ok := l.rows[[2]uuid.UUID{gameID, userID}]
|
||||
if !ok {
|
||||
return diplomail.ActiveMembership{}, diplomail.ErrNotFound
|
||||
}
|
||||
return row, 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) {
|
||||
t.Helper()
|
||||
_, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO backend.accounts (
|
||||
user_id, email, user_name, preferred_language, time_zone
|
||||
) VALUES ($1, $2, $3, 'en', 'UTC')
|
||||
`, userID, userID.String()+"@test.local", "user-"+userID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("seed account %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedGame inserts a minimal games row so the diplomail_messages.game_id
|
||||
// FK is satisfiable.
|
||||
func seedGame(t *testing.T, db *sql.DB, gameID uuid.UUID, name string) {
|
||||
t.Helper()
|
||||
_, err := db.ExecContext(context.Background(), `
|
||||
INSERT INTO backend.games (
|
||||
game_id, visibility, status, game_name,
|
||||
min_players, max_players, start_gap_hours, start_gap_players,
|
||||
enrollment_ends_at, turn_schedule, target_engine_version,
|
||||
runtime_snapshot
|
||||
) VALUES (
|
||||
$1, 'private', 'enrollment_open', $2,
|
||||
1, 4, 1, 1,
|
||||
now() + interval '1 day', '0 0 * * *', '1.0.0',
|
||||
'{}'::jsonb
|
||||
)
|
||||
`, gameID, name)
|
||||
if err != nil {
|
||||
t.Fatalf("seed game %s: %v", gameID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailPersonalFlow(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
sender := uuid.New()
|
||||
recipient := uuid.New()
|
||||
other := uuid.New()
|
||||
seedAccount(t, db, sender)
|
||||
seedAccount(t, db, recipient)
|
||||
seedAccount(t, db, other)
|
||||
seedGame(t, db, gameID, "Stage A Test Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, sender}: {
|
||||
UserID: sender, GameID: gameID, GameName: "Stage A Test Game",
|
||||
UserName: "sender", RaceName: "Senders",
|
||||
},
|
||||
{gameID, recipient}: {
|
||||
UserID: recipient, GameID: gameID, GameName: "Stage A Test Game",
|
||||
UserName: "recipient", RaceName: "Receivers",
|
||||
},
|
||||
},
|
||||
}
|
||||
publisher := &recordingPublisher{}
|
||||
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Notification: publisher,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
// 1. SendPersonal happy path.
|
||||
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientUserID: recipient,
|
||||
Subject: "Trade proposal",
|
||||
Body: "Care to talk gas mining?",
|
||||
SenderIP: "203.0.113.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send personal: %v", err)
|
||||
}
|
||||
if msg.Kind != diplomail.KindPersonal {
|
||||
t.Fatalf("kind = %q, want personal", msg.Kind)
|
||||
}
|
||||
if rcpt.UserID != recipient {
|
||||
t.Fatalf("recipient.UserID = %s, want %s", rcpt.UserID, recipient)
|
||||
}
|
||||
if rcpt.ReadAt != nil {
|
||||
t.Fatalf("freshly sent message should be unread, read_at=%v", rcpt.ReadAt)
|
||||
}
|
||||
if got := publisher.snapshot(); len(got) != 1 {
|
||||
t.Fatalf("publisher captured %d events, want 1", len(got))
|
||||
} else if got[0].Recipient != recipient {
|
||||
t.Fatalf("push recipient = %s, want %s", got[0].Recipient, recipient)
|
||||
}
|
||||
|
||||
// 2. ListInbox shows the message for the recipient.
|
||||
inbox, err := svc.ListInbox(ctx, gameID, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("list inbox: %v", err)
|
||||
}
|
||||
if len(inbox) != 1 || inbox[0].MessageID != msg.MessageID {
|
||||
t.Fatalf("inbox = %+v, want one matching entry", inbox)
|
||||
}
|
||||
|
||||
// 3. ListSent surfaces the message for the sender.
|
||||
sent, err := svc.ListSent(ctx, gameID, sender)
|
||||
if err != nil {
|
||||
t.Fatalf("list sent: %v", err)
|
||||
}
|
||||
if len(sent) != 1 || sent[0].MessageID != msg.MessageID {
|
||||
t.Fatalf("sent = %+v, want one matching entry", sent)
|
||||
}
|
||||
|
||||
// 4. Non-recipient reads are 404.
|
||||
if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) {
|
||||
t.Fatalf("non-recipient get: %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// 5. Delete before read is a conflict.
|
||||
if _, err := svc.DeleteMessage(ctx, recipient, msg.MessageID); !errors.Is(err, diplomail.ErrConflict) {
|
||||
t.Fatalf("delete before read: %v, want ErrConflict", err)
|
||||
}
|
||||
|
||||
// 6. MarkRead sets read_at; second call is a no-op.
|
||||
read, err := svc.MarkRead(ctx, recipient, msg.MessageID)
|
||||
if err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
if read.ReadAt == nil {
|
||||
t.Fatalf("mark read returned no read_at")
|
||||
}
|
||||
again, err := svc.MarkRead(ctx, recipient, msg.MessageID)
|
||||
if err != nil {
|
||||
t.Fatalf("mark read idempotent: %v", err)
|
||||
}
|
||||
if !again.ReadAt.Equal(*read.ReadAt) {
|
||||
t.Fatalf("mark read idempotent shifted read_at: %v -> %v", read.ReadAt, again.ReadAt)
|
||||
}
|
||||
|
||||
// 7. Unread counts go to zero after the read.
|
||||
counts, err := svc.UnreadCountsForUser(ctx, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("unread counts: %v", err)
|
||||
}
|
||||
if len(counts) != 0 {
|
||||
t.Fatalf("unread counts = %+v, want empty after read", counts)
|
||||
}
|
||||
|
||||
// 8. Soft delete now succeeds.
|
||||
deleted, err := svc.DeleteMessage(ctx, recipient, msg.MessageID)
|
||||
if err != nil {
|
||||
t.Fatalf("delete after read: %v", err)
|
||||
}
|
||||
if deleted.DeletedAt == nil {
|
||||
t.Fatalf("delete after read returned no deleted_at")
|
||||
}
|
||||
|
||||
// 9. Inbox now excludes the soft-deleted message.
|
||||
inbox, err = svc.ListInbox(ctx, gameID, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("list inbox after delete: %v", err)
|
||||
}
|
||||
if len(inbox) != 0 {
|
||||
t.Fatalf("inbox after delete = %+v, want empty", inbox)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailRejectsNonActiveSender(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
sender := uuid.New()
|
||||
recipient := uuid.New()
|
||||
seedAccount(t, db, sender)
|
||||
seedAccount(t, db, recipient)
|
||||
seedGame(t, db, gameID, "Solo Test Game")
|
||||
|
||||
// Only the recipient is on the active roster.
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, recipient}: {
|
||||
UserID: recipient, GameID: gameID, GameName: "Solo Test Game",
|
||||
UserName: "recipient", RaceName: "Receivers",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 4096,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientUserID: recipient,
|
||||
Subject: "Hi",
|
||||
Body: "Trade?",
|
||||
})
|
||||
if !errors.Is(err, diplomail.ErrForbidden) {
|
||||
t.Fatalf("send from non-member: %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiplomailRejectsOverlongBody(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
ctx := context.Background()
|
||||
|
||||
gameID := uuid.New()
|
||||
sender := uuid.New()
|
||||
recipient := uuid.New()
|
||||
seedAccount(t, db, sender)
|
||||
seedAccount(t, db, recipient)
|
||||
seedGame(t, db, gameID, "Length Test Game")
|
||||
|
||||
lookup := &staticMembershipLookup{
|
||||
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
||||
{gameID, sender}: {
|
||||
UserID: sender, GameID: gameID, GameName: "Length Test Game",
|
||||
UserName: "sender", RaceName: "Senders",
|
||||
},
|
||||
{gameID, recipient}: {
|
||||
UserID: recipient, GameID: gameID, GameName: "Length Test Game",
|
||||
UserName: "recipient", RaceName: "Receivers",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc := diplomail.NewService(diplomail.Deps{
|
||||
Store: diplomail.NewStore(db),
|
||||
Memberships: lookup,
|
||||
Config: config.DiplomailConfig{
|
||||
MaxBodyBytes: 32,
|
||||
MaxSubjectBytes: 256,
|
||||
},
|
||||
})
|
||||
|
||||
bigBody := make([]byte, 64)
|
||||
for i := range bigBody {
|
||||
bigBody[i] = 'a'
|
||||
}
|
||||
_, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
||||
GameID: gameID,
|
||||
SenderUserID: sender,
|
||||
RecipientUserID: recipient,
|
||||
Body: string(bigBody),
|
||||
})
|
||||
if !errors.Is(err, diplomail.ErrInvalidInput) {
|
||||
t.Fatalf("send overlong: %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user