mirror of
https://github.com/OwO-Network/DeepLX.git
synced 2026-09-10 12:00:58 +00:00
test(translate): add hermetic unit tests for lang resolution, preflight, and oneshot request/decode
The translate package had no tests. These are fully hermetic: callOneshot is exercised against httptest, so go test ./... stays offline and fast. Covers lang resolution (incl. the EN/PT target-vs-source asymmetry), TranslateByDeepLX preflight validation (404/400/413, pre-network only), callOneshot request shaping and gzip/deflate/br decoding, newInstanceID UUIDv4 shape, and the oneshotRequest JSON contract.
This commit is contained in:
parent
88a00874c8
commit
6012d72ba0
151
translate/oneshot_test.go
Normal file
151
translate/oneshot_test.go
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
package translate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andybalholm/brotli"
|
||||||
|
"github.com/imroc/req/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// useHermeticClient pre-seeds the process-wide client cache for the empty
|
||||||
|
// proxy key so callOneshot reuses it instead of building the real
|
||||||
|
// ImpersonateChrome client, whose creation also fires a background warmup
|
||||||
|
// GET to www.deepl.com. The client mirrors production where it matters for
|
||||||
|
// this layer: it pins Accept-Encoding so Go's transport does not auto-
|
||||||
|
// decompress, which is exactly why callOneshot decompresses by hand.
|
||||||
|
func useHermeticClient(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
c := req.C().
|
||||||
|
SetTimeout(5*time.Second).
|
||||||
|
SetCommonHeader("Accept-Encoding", "gzip, deflate, br")
|
||||||
|
oneshotClients.Store("", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallOneshotRequestShaping(t *testing.T) {
|
||||||
|
useHermeticClient(t)
|
||||||
|
|
||||||
|
var gotHeader http.Header
|
||||||
|
var gotBody []byte
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotHeader = r.Header.Clone()
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"translations":[{"text":"hallo","detected_source_language":"EN"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := []byte(`{"text":["hello"],"target_lang":"de"}`)
|
||||||
|
|
||||||
|
t.Run("anonymous sends Authorization None and extension headers", func(t *testing.T) {
|
||||||
|
res, status, err := callOneshot(srv.URL, body, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callOneshot error: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if got := res.Get("translations.0.text").String(); got != "hallo" {
|
||||||
|
t.Errorf("parsed body wrong, translations.0.text = %q", got)
|
||||||
|
}
|
||||||
|
if h := gotHeader.Get("Authorization"); h != "None" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", h, "None")
|
||||||
|
}
|
||||||
|
if h := gotHeader.Get("Content-Type"); h != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/json", h)
|
||||||
|
}
|
||||||
|
if h := gotHeader.Get("Origin"); h != "chrome-extension://"+chromeExtensionID {
|
||||||
|
t.Errorf("Origin = %q, want chrome-extension://%s", h, chromeExtensionID)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{
|
||||||
|
"Sec-Fetch-Site": "cross-site",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
} {
|
||||||
|
if h := gotHeader.Get(k); h != want {
|
||||||
|
t.Errorf("%s = %q, want %q", k, h, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bytes.Equal(gotBody, body) {
|
||||||
|
t.Errorf("forwarded body = %q, want %q", gotBody, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bearer token used when session provided", func(t *testing.T) {
|
||||||
|
if _, _, err := callOneshot(srv.URL, body, "tok123", ""); err != nil {
|
||||||
|
t.Fatalf("callOneshot error: %v", err)
|
||||||
|
}
|
||||||
|
if h := gotHeader.Get("Authorization"); h != "Bearer tok123" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", h, "Bearer tok123")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallOneshotDecompression covers the three hand-rolled decode paths
|
||||||
|
// (gzip/deflate/br) plus identity. This code exists only because the client
|
||||||
|
// pins Accept-Encoding, and it was previously untested — a decode regression
|
||||||
|
// would surface to users as a blind "Translation failed".
|
||||||
|
func TestCallOneshotDecompression(t *testing.T) {
|
||||||
|
useHermeticClient(t)
|
||||||
|
|
||||||
|
const payload = `{"translations":[{"text":"decoded"}]}`
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
encoding string
|
||||||
|
compress func([]byte) []byte
|
||||||
|
}{
|
||||||
|
{"identity", "", func(b []byte) []byte { return b }},
|
||||||
|
{"gzip", "gzip", func(b []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := gzip.NewWriter(&buf)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
_ = w.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}},
|
||||||
|
{"deflate", "deflate", func(b []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w, _ := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
_ = w.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}},
|
||||||
|
{"brotli", "br", func(b []byte) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := brotli.NewWriter(&buf)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
_ = w.Close()
|
||||||
|
return buf.Bytes()
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if tc.encoding != "" {
|
||||||
|
w.Header().Set("Content-Encoding", tc.encoding)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(tc.compress([]byte(payload)))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
res, status, err := callOneshot(srv.URL, []byte(`{}`), "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("callOneshot(%s) error: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
if got := res.Get("translations.0.text").String(); got != "decoded" {
|
||||||
|
t.Errorf("decoded text = %q, want %q (encoding %q)", got, "decoded", tc.encoding)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
206
translate/translate_test.go
Normal file
206
translate/translate_test.go
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
package translate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTargetLang(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
errHas string
|
||||||
|
}{
|
||||||
|
{"lowercase normalizes to canonical", "zh-hans", "zh-Hans", false, ""},
|
||||||
|
{"uppercase ZH maps to Simplified", "ZH", "zh-Hans", false, ""},
|
||||||
|
{"EN convenience alias resolves to en-US", "EN", "en-US", false, ""},
|
||||||
|
{"PT convenience alias resolves to pt-BR", "PT", "pt-BR", false, ""},
|
||||||
|
{"regional EN-GB preserved", "en-gb", "en-GB", false, ""},
|
||||||
|
{"plain de", "de", "de", false, ""},
|
||||||
|
{"empty is required", "", "", true, "required"},
|
||||||
|
{"auto is rejected as a target", "auto", "", true, "cannot be"},
|
||||||
|
{"AUTO rejected case-insensitively", "AUTO", "", true, "cannot be"},
|
||||||
|
{"unsupported code is reported", "xx", "", true, "unsupported target_lang"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := resolveTargetLang(tc.in)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveTargetLang(%q) = %q, want error", tc.in, got)
|
||||||
|
}
|
||||||
|
if tc.errHas != "" && !strings.Contains(err.Error(), tc.errHas) {
|
||||||
|
t.Errorf("error %q does not contain %q", err.Error(), tc.errHas)
|
||||||
|
}
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("on error want empty result, got %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveTargetLang(%q) unexpected error: %v", tc.in, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("resolveTargetLang(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceLang(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"empty means autodetect", "", "", false},
|
||||||
|
{"auto means autodetect", "auto", "", false},
|
||||||
|
{"AUTO case-insensitive", "AUTO", "", false},
|
||||||
|
// EN/PT are first-class *source* codes and resolve to the generic
|
||||||
|
// form, unlike the target map which forces a regional default
|
||||||
|
// (EN -> en-US there). This asymmetry is easy to regress.
|
||||||
|
{"EN as source resolves to generic en", "EN", "en", false},
|
||||||
|
{"PT as source resolves to generic pt", "PT", "pt", false},
|
||||||
|
{"target-only code ZH is also a valid source", "ZH", "zh-Hans", false},
|
||||||
|
{"plain de", "de", "de", false},
|
||||||
|
{"unsupported code errors", "xx", "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := resolveSourceLang(tc.in)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveSourceLang(%q) = %q, want error", tc.in, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSourceLang(%q) unexpected error: %v", tc.in, err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("resolveSourceLang(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTranslateByDeepLXPreflight exercises only the early-return validation
|
||||||
|
// paths that complete BEFORE any network I/O, so the suite stays hermetic.
|
||||||
|
func TestTranslateByDeepLXPreflight(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
target string
|
||||||
|
text string
|
||||||
|
wantCode int
|
||||||
|
msgHas string
|
||||||
|
}{
|
||||||
|
{"empty text returns 404 (shipped contract)", "", "EN", "", http.StatusNotFound, "No text to translate"},
|
||||||
|
{"invalid target returns 400", "", "XX", "hello", http.StatusBadRequest, "unsupported target_lang"},
|
||||||
|
{"invalid source returns 400", "XX", "EN", "hello", http.StatusBadRequest, "unsupported source_lang"},
|
||||||
|
{"over-length ASCII returns 413", "", "EN", strings.Repeat("a", maxFreeTextLength+1), http.StatusRequestEntityTooLarge, "exceeds maximum length"},
|
||||||
|
// The cap counts runes, not bytes: 1501 multibyte runes is well over
|
||||||
|
// 1500 even though it is ~4500 bytes.
|
||||||
|
{"over-length multibyte counts runes returns 413", "", "EN", strings.Repeat("你", maxFreeTextLength+1), http.StatusRequestEntityTooLarge, "exceeds maximum length"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
res, err := TranslateByDeepLX(tc.source, tc.target, tc.text, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Code != tc.wantCode {
|
||||||
|
t.Fatalf("Code = %d, want %d (message: %q)", res.Code, tc.wantCode, res.Message)
|
||||||
|
}
|
||||||
|
if tc.msgHas != "" && !strings.Contains(res.Message, tc.msgHas) {
|
||||||
|
t.Errorf("Message %q does not contain %q", res.Message, tc.msgHas)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||||
|
|
||||||
|
func TestNewInstanceIDIsUUIDv4(t *testing.T) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
id := newInstanceID()
|
||||||
|
if !uuidV4Re.MatchString(id) {
|
||||||
|
t.Fatalf("newInstanceID() = %q, not a valid RFC 4122 v4 UUID", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The process-wide instanceID must itself be a valid v4 UUID.
|
||||||
|
if !uuidV4Re.MatchString(instanceID) {
|
||||||
|
t.Errorf("package instanceID = %q, not a valid v4 UUID", instanceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOneshotRequestJSONShape locks the serialized request body: the field
|
||||||
|
// order and omitempty behavior are reverse-engineered to be byte-identical
|
||||||
|
// to the Chrome extension, so a struct-field reorder or tag change is a
|
||||||
|
// silent WAF signal that this test turns into a failure.
|
||||||
|
func TestOneshotRequestJSONShape(t *testing.T) {
|
||||||
|
anon := oneshotRequest{
|
||||||
|
Text: []string{"hello"},
|
||||||
|
TargetLang: "de",
|
||||||
|
UsageType: "Translate",
|
||||||
|
AppInformation: appInformation{
|
||||||
|
OS: "brex_macOS",
|
||||||
|
OSVersion: "brex_chrome_" + impersonatedChromeMajor + ".0.0.0",
|
||||||
|
AppVersion: chromeExtensionVersion,
|
||||||
|
AppBuild: "chrome_web_store",
|
||||||
|
InstanceID: instanceID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(anon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
|
||||||
|
if strings.Contains(s, "source_lang") {
|
||||||
|
t.Errorf("source_lang must be omitted when empty, got %s", s)
|
||||||
|
}
|
||||||
|
for _, key := range []string{`"text"`, `"target_lang"`, `"usage_type"`, `"app_information"`, `"os_version"`, `"app_version"`, `"app_build"`, `"instance_id"`} {
|
||||||
|
if !strings.Contains(s, key) {
|
||||||
|
t.Errorf("missing expected key %s in %s", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// encoding/json emits struct fields in declaration order; the body
|
||||||
|
// contract depends on it.
|
||||||
|
assertOrder(t, s, `"text"`, `"target_lang"`, `"usage_type"`, `"app_information"`)
|
||||||
|
|
||||||
|
// os_version must track the pinned Chrome major so the TLS handshake,
|
||||||
|
// user-agent, and self-reported version all tell one consistent story.
|
||||||
|
if !strings.Contains(s, "brex_chrome_"+impersonatedChromeMajor+".0.0.0") {
|
||||||
|
t.Errorf("os_version should embed impersonatedChromeMajor %q; got %s", impersonatedChromeMajor, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a source language set, the field appears with its value.
|
||||||
|
withSrc := anon
|
||||||
|
withSrc.SourceLang = "en"
|
||||||
|
b2, _ := json.Marshal(withSrc)
|
||||||
|
if !strings.Contains(string(b2), `"source_lang":"en"`) {
|
||||||
|
t.Errorf("source_lang should be present when set, got %s", string(b2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertOrder(t *testing.T, haystack string, needles ...string) {
|
||||||
|
t.Helper()
|
||||||
|
last := -1
|
||||||
|
for _, n := range needles {
|
||||||
|
idx := strings.Index(haystack, n)
|
||||||
|
if idx == -1 {
|
||||||
|
t.Fatalf("missing %s in %s", n, haystack)
|
||||||
|
}
|
||||||
|
if idx < last {
|
||||||
|
t.Fatalf("field %s appears out of order in %s", n, haystack)
|
||||||
|
}
|
||||||
|
last = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user