diplomail (Stage D): language detection + lazy translation cache
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user