2d36b54b8d
Closes the documentation gaps from the freshly-audited diplomail implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with the full user-facing story across all five stages, mirrored into FUNCTIONAL_ru.md as the project conventions require. A new backend/docs/diplomail-translator-setup.md captures the LibreTranslate operational recipe (Docker image, env wiring, manual smoke test, troubleshooting). The package README gains a "Multi-instance posture" note documenting the deliberate absence of FOR UPDATE in the worker pickup query — single-instance is safe today; multi-instance scaling will revisit the claim mechanism. Two small edge-case tests round things out: malformed LibreTranslate response bodies (single string, short array, empty array, missing field) must surface as errors so the worker falls back instead of crashing; and an empty translation queue must produce zero events on three consecutive Worker.Tick calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
5.3 KiB
Go
174 lines
5.3 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")
|
|
}
|
|
}
|
|
|
|
// TestLibreTranslateRejectsMalformedArray defends against a server
|
|
// that returns a partial / unexpected `translatedText` payload. The
|
|
// client must surface an error (not panic, not return a half-empty
|
|
// Result) so the worker can decide between retry and fallback.
|
|
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"single string", `{"translatedText": "only one"}`},
|
|
{"array of one", `{"translatedText": ["only one"]}`},
|
|
{"empty array", `{"translatedText": []}`},
|
|
{"missing field", `{"foo":"bar"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
body := tc.body
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
|
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
|
if err == nil {
|
|
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
|
|
}
|
|
})
|
|
}
|
|
}
|