diplomail (Stage D): language detection + lazy translation cache
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m0s
Tests · Integration / integration (pull_request) Successful in 1m35s

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>
This commit is contained in:
Ilia Denisov
2026-05-15 19:16:12 +02:00
parent 362f92e520
commit e22f4b7800
16 changed files with 599 additions and 24 deletions
@@ -11,6 +11,7 @@ import (
"galaxy/backend/internal/config"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/diplomail/translator"
backendpg "galaxy/backend/internal/postgres"
pgshared "galaxy/postgres"
@@ -297,7 +298,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
}
// 2. ListInbox shows the message for the recipient.
inbox, err := svc.ListInbox(ctx, gameID, recipient)
inbox, err := svc.ListInbox(ctx, gameID, recipient, "")
if err != nil {
t.Fatalf("list inbox: %v", err)
}
@@ -315,7 +316,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
}
// 4. Non-recipient reads are 404.
if _, err := svc.GetMessage(ctx, other, msg.MessageID); !errors.Is(err, diplomail.ErrNotFound) {
if _, err := svc.GetMessage(ctx, other, msg.MessageID, ""); !errors.Is(err, diplomail.ErrNotFound) {
t.Fatalf("non-recipient get: %v, want ErrNotFound", err)
}
@@ -359,7 +360,7 @@ func TestDiplomailPersonalFlow(t *testing.T) {
}
// 9. Inbox now excludes the soft-deleted message.
inbox, err = svc.ListInbox(ctx, gameID, recipient)
inbox, err = svc.ListInbox(ctx, gameID, recipient, "")
if err != nil {
t.Fatalf("list inbox after delete: %v", err)
}
@@ -501,7 +502,7 @@ func TestDiplomailAdminBroadcast(t *testing.T) {
// 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)
charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie, "")
if err != nil {
t.Fatalf("kicked inbox: %v", err)
}
@@ -795,7 +796,7 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
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)
inbox, err := svc.ListInbox(ctx, gameID, kicked, "")
if err != nil {
t.Fatalf("kicked inbox: %v", err)
}
@@ -807,6 +808,112 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
}
}
// 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()