Skip to content
Merged
29 changes: 25 additions & 4 deletions internal/i18n/gateway/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,15 @@ func fetchAppTranslations(ctx context.Context, runtimeScope scope.Scope, app, la
for _, m := range moduleNames {
moduleNamesAny = append(moduleNamesAny, m)
}
// Generated protos wrap the single TS param as google.protobuf.Value req
// (TranslationTerm_GetTranslations_Req), matching document gateway's {"req": ...} shape.
reqMsg := dynamicpb.NewMessage(md.Input())
if err := converter.MapToMessage(map[string]any{
"lang": lang,
"module_names": moduleNamesAny,
"hash": "",
"req": map[string]any{
"lang": lang,
"module_names": moduleNamesAny,
"hash": "",
},
}, reqMsg); err != nil {
return nil, fmt.Errorf("build GetTranslations request: %w", err)
}
Expand All @@ -70,7 +74,24 @@ func fetchAppTranslations(ctx context.Context, runtimeScope scope.Scope, app, la
if err != nil {
return nil, fmt.Errorf("decode GetTranslations response: %w", err)
}
return parseAppTranslations(out), nil
payload, err := unwrapGetTranslationsPayload(out)
if err != nil {
return nil, err
}
return parseAppTranslations(payload), nil
}

// unwrapGetTranslationsPayload accepts Resp{ Value result = 1 } or a legacy
// unwrapped body. A present non-object result is a decode error (not empty catalog).
func unwrapGetTranslationsPayload(out map[string]any) (map[string]any, error) {
payload, ok := out["result"].(map[string]any)
if ok {
return payload, nil
}
if _, hasResult := out["result"]; hasResult {
return nil, fmt.Errorf("decode GetTranslations response: result must be an object")
}
return out, nil
}

func parseAppTranslations(out map[string]any) *appTranslations {
Expand Down
24 changes: 24 additions & 0 deletions internal/i18n/gateway/rpc_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ func (s *rpcTestScope) FactoryInput() scope.FactoryInput {
return scopetest.FactoryInputFromConfig(s.Config())
}

func TestUnwrapGetTranslationsPayload(t *testing.T) {
wrapped, err := unwrapGetTranslationsPayload(map[string]any{
"result": map[string]any{"hash": "abc", "terms_by_module": map[string]any{}},
})
if err != nil || wrapped["hash"] != "abc" {
t.Fatalf("wrapped = %#v err=%v", wrapped, err)
}

legacy, err := unwrapGetTranslationsPayload(map[string]any{"hash": "legacy"})
if err != nil || legacy["hash"] != "legacy" {
t.Fatalf("legacy = %#v err=%v", legacy, err)
}

_, err = unwrapGetTranslationsPayload(map[string]any{"result": "not-an-object"})
if err == nil || !strings.Contains(err.Error(), "result must be an object") {
t.Fatalf("malformed result err = %v", err)
}

_, err = unwrapGetTranslationsPayload(map[string]any{"result": nil})
if err == nil || !strings.Contains(err.Error(), "result must be an object") {
t.Fatalf("null result err = %v", err)
}
}

func TestParseAppTranslationsBranches(t *testing.T) {
empty := parseAppTranslations(map[string]any{"hash": "<nil>"})
if empty.Hash != "" || len(empty.Terms) != 0 {
Expand Down
45 changes: 28 additions & 17 deletions internal/i18n/gateway/terms_rpc_fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,10 @@ service TranslationTerm {
}

message GetTranslationsReq {
string lang = 1;
repeated string module_names = 2;
string hash = 3;
google.protobuf.Value req = 1;
}
message GetTranslationsResp {
string hash = 1;
google.protobuf.Struct terms_by_module = 2;
google.protobuf.Value result = 1;
}

message SearchReq {
Expand Down Expand Up @@ -103,6 +100,9 @@ type translationTermRPCBehavior struct {
getHash string
getTerms map[string]any
getErr error
// getResultRaw when non-nil is written as the GetTranslations "result" Value
// (e.g. a string) instead of the normal catalog object.
getResultRaw any
}

func newTranslationTermDialer(t *testing.T, behavior *translationTermRPCBehavior) grpcclient.ServiceDialer {
Expand Down Expand Up @@ -150,11 +150,17 @@ func newTranslationTermDialer(t *testing.T, behavior *translationTermRPCBehavior
if behavior.getErr != nil {
return behavior.getErr
}
payload := map[string]any{"hash": behavior.getHash}
if behavior.getTerms != nil {
payload["terms_by_module"] = behavior.getTerms
var result any
if behavior.getResultRaw != nil {
result = behavior.getResultRaw
} else {
payload := map[string]any{"hash": behavior.getHash}
if behavior.getTerms != nil {
payload["terms_by_module"] = behavior.getTerms
}
result = payload
}
if err := converter.MapToMessage(payload, resp); err != nil {
if err := converter.MapToMessage(map[string]any{"result": result}, resp); err != nil {
return err
}
default:
Expand Down Expand Up @@ -338,6 +344,15 @@ func TestFetchAppTranslationsSuccess(t *testing.T) {
}
}

func TestFetchAppTranslationsMalformedResult(t *testing.T) {
behavior := &translationTermRPCBehavior{getResultRaw: "not-an-object"}
ctx := grpcclient.ContextWithServiceDialer(context.Background(), newTranslationTermDialer(t, behavior))
_, err := fetchAppTranslations(ctx, nil, "auth", "zh_CN", []string{"auth"})
if err == nil || !strings.Contains(err.Error(), "result must be an object") {
t.Fatalf("err = %v, want result must be an object", err)
}
}

func TestFetchAppTranslationsDialFailureWithDescriptor(t *testing.T) {
registerAuthTranslationTermProtoForTests()
ctx := grpcclient.ContextWithServiceDialer(context.Background(), func(ctx context.Context, serviceName string) (*grpc.ClientConn, error) {
Expand Down Expand Up @@ -465,9 +480,7 @@ service TranslationTerm {
}

message GetTranslationsReq {
string lang = 1;
repeated string module_names = 2;
string hash = 3;
google.protobuf.Value req = 1;
}
message SearchReq {
google.protobuf.Struct condition = 1;
Expand Down Expand Up @@ -560,13 +573,11 @@ service TranslationTerm {
rpc Count(CountReq) returns (CountResp);
}
message BadGetReq {
google.protobuf.Struct lang = 1;
repeated string module_names = 2;
string hash = 3;
// ListValue cannot accept the map payload MapToMessage sends for "req".
google.protobuf.ListValue req = 1;
}
message GetTranslationsResp {
string hash = 1;
google.protobuf.Struct terms_by_module = 2;
google.protobuf.Value result = 1;
}
message SearchReq {
google.protobuf.Struct condition = 1;
Expand Down
7 changes: 3 additions & 4 deletions internal/module/artifact/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,9 @@ func Execute(ctx context.Context, plan planner.Plan, root *meta.Module, cb Callb
if app == "" {
return nil
}
// "web" application is handled by the global web build stage.
if strings.EqualFold(app, "web") {
return nil
}
// application "web" still needs proto/service/web-client staging for
// TranslationTerm (EnsureServiceEntry). Global SPA build remains a
// separate stage writing dist/web.
if err := checkCtx(); err != nil {
return err
}
Expand Down
28 changes: 23 additions & 5 deletions internal/module/artifact/pipeline/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,9 @@ func TestExecuteInfoLogsSummarizeAppStageAndHideManifestCommit(t *testing.T) {
t.Fatalf("expected app stage summary record in logs, got %q", logs)
}
}
func TestExecuteInstallSkipsWebModuleGeneration(t *testing.T) {
func TestExecuteInstallGeneratesWebApplicationModules(t *testing.T) {
rootDir := t.TempDir()
modulesRoot := filepath.Join(rootDir, "modules")
root := &meta.Module{Name: "webmod", ApplicationStr: "web"}
installCalls := 0
generateCalls := 0
Expand All @@ -502,11 +504,27 @@ func TestExecuteInstallSkipsWebModuleGeneration(t *testing.T) {
},
AppTargets: func(appName string) (string, ModulesAppTargets, error) {
appTargetsCalls++
return "", ModulesAppTargets{}, nil
if appName != "web" {
t.Fatalf("unexpected app %q", appName)
}
return "", ModulesAppTargets{
ProtoDir: filepath.Join(modulesRoot, "api", "proto", appName),
WebDir: filepath.Join(modulesRoot, "api", "web", appName),
ServiceDir: filepath.Join(modulesRoot, "api", "service", appName),
}, nil
},
GenerateApp: func(ctx context.Context, appName string, modulesStaging ModulesAppTargets, distAppStagingDir string) error {
generateCalls++
return nil
if appName != "web" {
t.Fatalf("unexpected generate app %q", appName)
}
if err := writeStageFile(modulesStaging.ProtoDir, "web.proto", "syntax = \"proto3\";"); err != nil {
return err
}
if err := writeStageFile(modulesStaging.WebDir, "index.ts", "export const web = true"); err != nil {
return err
}
return writeStageFile(modulesStaging.ServiceDir, "index.ts", "export const service = true")
},
})
if err != nil {
Expand All @@ -515,8 +533,8 @@ func TestExecuteInstallSkipsWebModuleGeneration(t *testing.T) {
if installCalls != 1 {
t.Fatalf("install calls = %d, want 1", installCalls)
}
if appTargetsCalls != 0 || generateCalls != 0 {
t.Fatalf("expected web app module generation to be skipped, got appTargets=%d generate=%d", appTargetsCalls, generateCalls)
if appTargetsCalls != 1 || generateCalls != 1 {
t.Fatalf("expected web app module generation once, got appTargets=%d generate=%d", appTargetsCalls, generateCalls)
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/module/artifact/runtimeapi/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func SyncMissingProtos(distRoot string, apps []string) ([]string, error) {
seen := map[string]bool{}
for _, app := range apps {
app = strings.TrimSpace(app)
if app == "" || strings.EqualFold(app, "web") || seen[app] || strings.ContainsAny(app, `/\`) || strings.Contains(app, "..") || app != filepath.Base(app) {
if app == "" || seen[app] || strings.ContainsAny(app, `/\`) || strings.Contains(app, "..") || app != filepath.Base(app) {
continue
}
seen[app] = true
Expand Down
11 changes: 9 additions & 2 deletions internal/module/artifact/runtimeapi/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,15 @@ func TestSyncMissingProtosRestoresFromGenerated(t *testing.T) {
if err != nil {
t.Fatalf("SyncMissingProtos: %v", err)
}
if len(synced) != 2 {
t.Fatalf("synced = %v, want auth+task", synced)
if len(synced) != 3 {
t.Fatalf("synced = %v, want auth+task+web", synced)
}
seen := map[string]bool{}
for _, app := range synced {
seen[app] = true
}
if !seen["auth"] || !seen["task"] || !seen["web"] {
t.Fatalf("synced = %v, want auth+task+web", synced)
}

authBody, err := os.ReadFile(filepath.Join(root, "api", "auth", "proto", "auth.proto"))
Expand Down
Loading