9f7c9099bc
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>
142 lines
4.2 KiB
Go
142 lines
4.2 KiB
Go
package translator
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLibreTranslateHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
var (
|
|
requestSource string
|
|
requestTarget string
|
|
requestQ []string
|
|
requestFormat string
|
|
)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var in requestBody
|
|
if err := json.Unmarshal(body, &in); err != nil {
|
|
t.Errorf("unmarshal: %v", err)
|
|
}
|
|
requestSource = in.Source
|
|
requestTarget = in.Target
|
|
requestQ = in.Q
|
|
requestFormat = in.Format
|
|
_ = json.NewEncoder(w).Encode(responseBody{
|
|
TranslatedText: []string{"[ru] " + in.Q[0], "[ru] " + in.Q[1]},
|
|
})
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
tr, err := NewLibreTranslate(LibreTranslateConfig{URL: server.URL, Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
res, err := tr.Translate(context.Background(), "en", "ru", "Hello", "World")
|
|
if err != nil {
|
|
t.Fatalf("translate: %v", err)
|
|
}
|
|
if res.Engine != LibreTranslateEngine {
|
|
t.Fatalf("engine = %q, want %q", res.Engine, LibreTranslateEngine)
|
|
}
|
|
if res.Subject != "[ru] Hello" || res.Body != "[ru] World" {
|
|
t.Fatalf("result = %+v", res)
|
|
}
|
|
if requestSource != "en" || requestTarget != "ru" || requestFormat != "text" {
|
|
t.Fatalf("request fields: src=%q dst=%q fmt=%q", requestSource, requestTarget, requestFormat)
|
|
}
|
|
if len(requestQ) != 2 || requestQ[0] != "Hello" || requestQ[1] != "World" {
|
|
t.Fatalf("request q = %v", requestQ)
|
|
}
|
|
}
|
|
|
|
func TestLibreTranslateNormalisesLanguageCodes(t *testing.T) {
|
|
t.Parallel()
|
|
var src, dst string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var in requestBody
|
|
_ = json.Unmarshal(body, &in)
|
|
src, dst = in.Source, in.Target
|
|
_ = json.NewEncoder(w).Encode(responseBody{TranslatedText: []string{"a", "b"}})
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
|
if _, err := tr.Translate(context.Background(), "EN-US", "ru-RU", "x", "y"); err != nil {
|
|
t.Fatalf("translate: %v", err)
|
|
}
|
|
if src != "en" || dst != "ru" {
|
|
t.Fatalf("normalised codes src=%q dst=%q, want en/ru", src, dst)
|
|
}
|
|
}
|
|
|
|
func TestLibreTranslateUnsupportedPair(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"language not supported"}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
|
_, err := tr.Translate(context.Background(), "en", "xx", "subject", "body")
|
|
if !errors.Is(err, ErrUnsupportedLanguagePair) {
|
|
t.Fatalf("err = %v, want ErrUnsupportedLanguagePair", err)
|
|
}
|
|
}
|
|
|
|
func TestLibreTranslateServerError(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("kaboom"))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
|
_, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
if errors.Is(err, ErrUnsupportedLanguagePair) {
|
|
t.Fatalf("err mis-classified as unsupported pair: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "500") {
|
|
t.Fatalf("err = %v, want mention of 500", err)
|
|
}
|
|
}
|
|
|
|
func TestLibreTranslateSameSourceAndTargetIsNoop(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Errorf("translator should not call the server for identical src/dst: %s", r.URL.Path)
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
|
res, err := tr.Translate(context.Background(), "en", "EN", "x", "y")
|
|
if err != nil {
|
|
t.Fatalf("translate: %v", err)
|
|
}
|
|
if res.Engine != NoopEngine {
|
|
t.Fatalf("engine = %q, want %q", res.Engine, NoopEngine)
|
|
}
|
|
}
|
|
|
|
func TestLibreTranslateRequiresURL(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := NewLibreTranslate(LibreTranslateConfig{URL: ""})
|
|
if err == nil {
|
|
t.Fatalf("expected error for empty URL")
|
|
}
|
|
}
|