diplomail (Stage E): LibreTranslate client + async translation worker
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user