e22f4b7800
Replaces the LangUndetermined placeholder with whatlanggo-backed body detection on every send path, then adds a translation cache keyed on (message_id, target_lang) populated lazily on the per-message read endpoint. The noop translator that ships with Stage D returns engine="noop", which the service treats as "translation unavailable" — wiring a real backend (LibreTranslate HTTP client is the documented next step) is a one-file swap. GetMessage and ListInbox now accept a targetLang argument; the HTTP layer resolves the caller's accounts.preferred_language and forwards it. Inbox uses the cache only (never calls the translator) so bulk reads stay fast under future SaaS backends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
964 lines
29 KiB
Go
964 lines
29 KiB
Go
package diplomail_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
"galaxy/backend/internal/diplomail"
|
|
"galaxy/backend/internal/diplomail/translator"
|
|
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).
|
|
// Inactive rows (status != "active") are encoded by populating
|
|
// `inactive` instead of `rows`.
|
|
type staticMembershipLookup struct {
|
|
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) {
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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 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))
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// staticTranslator returns deterministic renderings so the
|
|
// translation-cache test can assert against known output.
|
|
type staticTranslator struct {
|
|
engine string
|
|
}
|
|
|
|
func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) {
|
|
return translator.Result{
|
|
Subject: "[" + dstLang + "] " + subject,
|
|
Body: "[" + dstLang + "] " + body,
|
|
Engine: s.engine,
|
|
}, nil
|
|
}
|
|
|
|
func TestDiplomailTranslationCache(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, "Translation Test Game")
|
|
|
|
lookup := &staticMembershipLookup{
|
|
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
|
|
{gameID, sender}: {
|
|
UserID: sender, GameID: gameID, GameName: "Translation Test Game",
|
|
UserName: "sender", RaceName: "SendersRace",
|
|
},
|
|
{gameID, recipient}: {
|
|
UserID: recipient, GameID: gameID, GameName: "Translation Test Game",
|
|
UserName: "recipient", RaceName: "ReceiversRace",
|
|
},
|
|
},
|
|
}
|
|
publisher := &recordingPublisher{}
|
|
|
|
englishDetector := detectorFn(func(_ string) string { return "en" })
|
|
svc := diplomail.NewService(diplomail.Deps{
|
|
Store: diplomail.NewStore(db),
|
|
Memberships: lookup,
|
|
Notification: publisher,
|
|
Detector: englishDetector,
|
|
Translator: &staticTranslator{engine: "stub"},
|
|
Config: config.DiplomailConfig{
|
|
MaxBodyBytes: 4096,
|
|
MaxSubjectBytes: 256,
|
|
},
|
|
})
|
|
|
|
msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
|
|
GameID: gameID,
|
|
SenderUserID: sender,
|
|
RecipientUserID: recipient,
|
|
Subject: "Hello",
|
|
Body: "Please share the latest map snapshot.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if msg.BodyLang != "en" {
|
|
t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang)
|
|
}
|
|
|
|
// First read materialises a cached translation.
|
|
entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
|
|
if err != nil {
|
|
t.Fatalf("get message: %v", err)
|
|
}
|
|
if entry.Translation == nil {
|
|
t.Fatalf("translation missing on first read")
|
|
}
|
|
if entry.Translation.TargetLang != "ru" {
|
|
t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang)
|
|
}
|
|
if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." {
|
|
t.Fatalf("translated body = %q", entry.Translation.TranslatedBody)
|
|
}
|
|
|
|
// Second read returns the same cached row (no re-translation).
|
|
entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru")
|
|
if err != nil {
|
|
t.Fatalf("get message twice: %v", err)
|
|
}
|
|
if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID {
|
|
t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID)
|
|
}
|
|
|
|
// Same language as body: no translation.
|
|
entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en")
|
|
if err != nil {
|
|
t.Fatalf("get message same lang: %v", err)
|
|
}
|
|
if entrySame.Translation != nil {
|
|
t.Fatalf("translation populated when target == body_lang")
|
|
}
|
|
}
|
|
|
|
// detectorFn lets the test inject deterministic detection without
|
|
// dragging the whatlanggo profile into the test fixtures.
|
|
type detectorFn func(string) string
|
|
|
|
func (f detectorFn) Detect(s string) string { return f(s) }
|
|
|
|
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)
|
|
}
|
|
}
|