DeepLX/translate/translate_test.go
Bonny07 6012d72ba0 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.
2026-07-08 09:57:56 +09:00

207 lines
6.9 KiB
Go

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
}
}