diplomail (Stage E): LibreTranslate client + async translation worker
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s

Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 20:15:28 +02:00
parent e22f4b7800
commit 9f7c9099bc
16 changed files with 1222 additions and 155 deletions
@@ -808,6 +808,176 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
}
}
// TestDiplomailAsyncTranslationDelivery covers the Stage E flow:
// 1. SendPersonal where recipient.preferred_language != body_lang
// materialises a recipient with `AvailableAt == nil`; the inbox
// is empty until the worker runs.
// 2. After one Worker.Tick(), the translation cache row exists,
// `AvailableAt` is populated, and the push event fires.
// 3. The inbox now surfaces the message together with the cached
// translation under `Translation`.
func TestDiplomailAsyncTranslationDelivery(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, "Async Translation Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, sender}: {
UserID: sender, GameID: gameID, GameName: "Async Translation Game",
UserName: "sender", RaceName: "SenderRace",
PreferredLanguage: "en",
},
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Async Translation Game",
UserName: "recipient", RaceName: "RecipientRace",
PreferredLanguage: "ru",
},
},
}
publisher := &recordingPublisher{}
stub := &staticTranslator{engine: translator.LibreTranslateEngine}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: publisher,
Detector: detectorFn(func(_ string) string { return "en" }),
Translator: stub,
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
TranslatorMaxAttempts: 5,
WorkerInterval: time.Second,
},
})
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
Subject: "Hello",
Body: "Trade proposal.",
})
if err != nil {
t.Fatalf("send: %v", err)
}
if rcpt.AvailableAt != nil {
t.Fatalf("recipient marked available_at on send: %v (want NULL — pending translation)", rcpt.AvailableAt)
}
if got := publisher.snapshot(); len(got) != 0 {
t.Fatalf("push fired before worker delivered: %d events", len(got))
}
inbox, err := svc.ListInbox(ctx, gameID, recipient, "")
if err != nil {
t.Fatalf("inbox before worker: %v", err)
}
if len(inbox) != 0 {
t.Fatalf("inbox before worker = %d, want empty", len(inbox))
}
worker := diplomail.NewWorker(svc)
if err := worker.Tick(ctx); err != nil {
t.Fatalf("worker tick: %v", err)
}
if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != recipient {
t.Fatalf("publisher after tick = %+v", got)
}
inboxAfter, err := svc.ListInbox(ctx, gameID, recipient, "ru")
if err != nil {
t.Fatalf("inbox after worker: %v", err)
}
if len(inboxAfter) != 1 {
t.Fatalf("inbox after worker = %d, want 1", len(inboxAfter))
}
if inboxAfter[0].Translation == nil {
t.Fatalf("translation missing on inbox entry")
}
if inboxAfter[0].Translation.TranslatedBody != "[ru] Trade proposal." {
t.Fatalf("translated body = %q", inboxAfter[0].Translation.TranslatedBody)
}
_ = msg
}
// TestDiplomailAsyncFallbackOnUnsupportedPair covers the terminal
// "translation unavailable" path: the translator returns
// ErrUnsupportedLanguagePair, so the worker delivers the recipient
// with no cached translation. The user sees the original body.
func TestDiplomailAsyncFallbackOnUnsupportedPair(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, "Unsupported Pair Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, sender}: {
UserID: sender, GameID: gameID, GameName: "Unsupported Pair Game",
UserName: "sender", PreferredLanguage: "en",
},
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Unsupported Pair Game",
UserName: "recipient", PreferredLanguage: "xx",
},
},
}
publisher := &recordingPublisher{}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: publisher,
Detector: detectorFn(func(_ string) string { return "en" }),
Translator: &erroringTranslator{err: translator.ErrUnsupportedLanguagePair},
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
TranslatorMaxAttempts: 5,
},
})
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
Body: "Hello there.",
}); err != nil {
t.Fatalf("send: %v", err)
}
worker := diplomail.NewWorker(svc)
if err := worker.Tick(ctx); err != nil {
t.Fatalf("worker tick: %v", err)
}
inbox, err := svc.ListInbox(ctx, gameID, recipient, "xx")
if err != nil {
t.Fatalf("inbox: %v", err)
}
if len(inbox) != 1 {
t.Fatalf("inbox after fallback = %d, want 1", len(inbox))
}
if inbox[0].Translation != nil {
t.Fatalf("translation should be nil after fallback, got %+v", inbox[0].Translation)
}
}
type erroringTranslator struct {
err error
}
func (e *erroringTranslator) Translate(_ context.Context, _, _, _, _ string) (translator.Result, error) {
return translator.Result{}, e.err
}
// staticTranslator returns deterministic renderings so the
// translation-cache test can assert against known output.
type staticTranslator struct {