Files
21e7299707 MUL-8230: feat(i18n): add Traditional Chinese (zh-Hant) locale (#9507)
* feat(i18n): add zh-Hant locale to web and desktop

Add Traditional Chinese (zh-Hant) as a supported locale with a full
translation of the shared views bundles. Browser tags zh-TW/zh-HK/
zh-Hant now resolve to zh-Hant instead of collapsing to zh-Hans; the
document lang is zh-TW, and docs/use-case links reuse the /zh pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a6b3f6ba53a605c3e06fd6c4d30fd9df6fca8645)

* feat(mobile): add zh-Hant locale

Ship Traditional Chinese bundles for mobile and resolve zh-Hant/TW/HK/MO
device locales to them instead of falling back to English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 04563c22662bdb735a9a6c04be784abd9f87da06)

* feat(ui-lab): add zh-Hant locale

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1351ea572ed5d885d8a29034d6c0317de58641ea)

* feat(server): accept zh-Hant as user language

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 50dfe77a4c45452fc877c62497aba226d5aaa426)

* fix(i18n): sync zh-Hant with upstream copy changes

Upstream added conversation-starter, instruction-preview and board column
strings, removed restated descriptions (MUL-8162) and renamed Qoder Cloud
Agents. Mirror those key additions, removals and rewordings in zh-Hant so
the locale parity test passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3a97dfc7e0bbf7568925f735c00fc69b6d94313d)

* fix(i18n): add zh-Hant copy for member-scoped runtime visibility

Mirror the runtime visibility "members" choice and the reworded revoke
dialog in zh-Hant so the locale parity test keeps passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(i18n): replace Mainland terms in zh-Hant copy with Taiwan usage

Several zh-Hant strings were converted character by character from
zh-Hans and kept Mainland vocabulary, which reads wrong to Traditional
Chinese users: 郵箱 → 信箱, 賬單/賬號 → 帳單/帳號, and similar. Values
only; no keys added or removed.

* fix(i18n): polish zh-Hant copy from review

- 分割槽 → 分區 and 許可權 → 權限 (OpenCC phrase mis-conversions)
- drop stray blank lines before zh-Hant workspace names

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-26 15:09:38 +08:00

199 lines
6.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/testutil"
)
func TestUpdateMeFrenchLanguageRoundTrip(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
userID := dbfx.User(t, "French user", "[email protected]", testutil.Cols{"language": "en"})
var updated UserResponse
testutil.Call(t, testHandler.UpdateMe, newPatchMeRequest(userID, `{"language":"fr"}`)).Want(http.StatusOK).JSON(&updated)
if updated.Language == nil || *updated.Language != "fr" {
t.Fatalf("updated language = %v, want fr", updated.Language)
}
// A fresh request after reload must restore French, not the old preference.
get := newRequest(http.MethodGet, "/api/me", nil)
get.Header.Set("X-User-ID", userID)
var reloaded UserResponse
testutil.Call(t, testHandler.GetMe, get).Want(http.StatusOK).JSON(&reloaded)
if reloaded.Language == nil || *reloaded.Language != "fr" {
t.Fatalf("reloaded language = %v, want fr", reloaded.Language)
}
}
func TestUpdateMeTraditionalChineseLanguageRoundTrip(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
userID := dbfx.User(t, "Traditional Chinese user", "[email protected]", testutil.Cols{"language": "en"})
var updated UserResponse
testutil.Call(t, testHandler.UpdateMe, newPatchMeRequest(userID, `{"language":"zh-Hant"}`)).Want(http.StatusOK).JSON(&updated)
if updated.Language == nil || *updated.Language != "zh-Hant" {
t.Fatalf("updated language = %v, want zh-Hant", updated.Language)
}
get := newRequest(http.MethodGet, "/api/me", nil)
get.Header.Set("X-User-ID", userID)
var reloaded UserResponse
testutil.Call(t, testHandler.GetMe, get).Want(http.StatusOK).JSON(&reloaded)
if reloaded.Language == nil || *reloaded.Language != "zh-Hant" {
t.Fatalf("reloaded language = %v, want zh-Hant", reloaded.Language)
}
}
func newLanguageTestUser(t *testing.T, email string) string {
t.Helper()
ctx := context.Background()
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
"Language Test", email,
).Scan(&userID); err != nil {
t.Fatalf("insert test user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID)
})
return userID
}
func newPatchMeRequest(userID, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)
return req
}
func TestUpdateMeAcceptsLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"zh-Hans"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang == nil || *lang != "zh-Hans" {
t.Fatalf("expected language=zh-Hans, got %v", lang)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "zh-Hans" {
t.Fatalf("expected response language=zh-Hans, got %v", resp["language"])
}
}
func TestUpdateMeAcceptsKoreanLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"ko"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "ko" {
t.Fatalf("expected response language=ko, got %v", resp["language"])
}
}
func TestUpdateMeAcceptsJapaneseLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"ja"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "ja" {
t.Fatalf("expected response language=ja, got %v", resp["language"])
}
}
func TestUpdateMeRejectsUnsupportedLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"<script>"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang != nil {
t.Fatalf("expected language unchanged (NULL), got %v", *lang)
}
}
// COALESCE semantics: omitting language must NOT clear an existing value.
func TestUpdateMePreservesLanguageWhenNotProvided(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
if _, err := testPool.Exec(context.Background(),
`UPDATE "user" SET language = 'en' WHERE id = $1`, userID,
); err != nil {
t.Fatalf("preset language: %v", err)
}
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"name":"Updated Name"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang == nil || *lang != "en" {
t.Fatalf("expected language=en preserved, got %v", lang)
}
}