From 7876886f3e32b529668c6f0a7be2a665ab23f5d9 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 15:52:58 +0800 Subject: [PATCH 1/9] fix(i18n-gateway): wrap GetTranslations args as protobuf Value req - Encode lang/module_names/hash under the generated req field and unwrap result so catalog reads match TranslationTerm_GetTranslations_* shapes. Co-authored-by: Cursor --- internal/i18n/gateway/client.go | 17 +++++++++++---- .../i18n/gateway/terms_rpc_fixture_test.go | 21 +++++++------------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/internal/i18n/gateway/client.go b/internal/i18n/gateway/client.go index 7ee6d939..2c5f2ffc 100644 --- a/internal/i18n/gateway/client.go +++ b/internal/i18n/gateway/client.go @@ -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) } @@ -70,7 +74,12 @@ 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 + // Response is TranslationTerm_GetTranslations_Resp{ Value result = 1 }. + payload, _ := out["result"].(map[string]any) + if payload == nil { + payload = out + } + return parseAppTranslations(payload), nil } func parseAppTranslations(out map[string]any) *appTranslations { diff --git a/internal/i18n/gateway/terms_rpc_fixture_test.go b/internal/i18n/gateway/terms_rpc_fixture_test.go index f54c25c2..e1d90c21 100644 --- a/internal/i18n/gateway/terms_rpc_fixture_test.go +++ b/internal/i18n/gateway/terms_rpc_fixture_test.go @@ -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 { @@ -154,7 +151,7 @@ func newTranslationTermDialer(t *testing.T, behavior *translationTermRPCBehavior if behavior.getTerms != nil { payload["terms_by_module"] = behavior.getTerms } - if err := converter.MapToMessage(payload, resp); err != nil { + if err := converter.MapToMessage(map[string]any{"result": payload}, resp); err != nil { return err } default: @@ -465,9 +462,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; @@ -560,13 +555,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; From 1942f078b0fa286cb5e4fbc8abbc107f1bca2327 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 16:33:44 +0800 Subject: [PATCH 2/9] fix(i18n): enable web TranslationTerm proto and runtime surface - Include application web in app-stage codegen and stop treating it as SPA-only. - Load api/web/proto plus bundle scripts on ApplicationService(web) while keeping dist/web static assets. - Persist missing TranslationTerm IR before GenerateApp so EnsureServiceEntry hosts emit descriptors. Co-authored-by: Cursor --- internal/module/artifact/pipeline/pipeline.go | 7 +- .../module/artifact/pipeline/pipeline_test.go | 28 ++++-- internal/module/artifact/runtimeapi/sync.go | 2 +- .../module/artifact/runtimeapi/sync_test.go | 11 ++- internal/module/lifecycle/modulemanager.go | 87 ++++++++++++++++++- .../lifecycle/modulemanager_coverage_test.go | 24 ++--- internal/module/plan/planner.go | 9 +- internal/module/plan/planner_test.go | 8 +- internal/server/runplan/dist_validation.go | 30 ++++++- .../server/runplan/dist_validation_test.go | 30 ++++++- internal/server/runplan/plan_test.go | 11 ++- internal/service/service.go | 24 +++-- internal/service/service_test.go | 15 +++- 13 files changed, 231 insertions(+), 55 deletions(-) diff --git a/internal/module/artifact/pipeline/pipeline.go b/internal/module/artifact/pipeline/pipeline.go index feb94a0a..33b85490 100644 --- a/internal/module/artifact/pipeline/pipeline.go +++ b/internal/module/artifact/pipeline/pipeline.go @@ -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 } diff --git a/internal/module/artifact/pipeline/pipeline_test.go b/internal/module/artifact/pipeline/pipeline_test.go index 4b09e42a..15a40ad5 100644 --- a/internal/module/artifact/pipeline/pipeline_test.go +++ b/internal/module/artifact/pipeline/pipeline_test.go @@ -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 @@ -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 { @@ -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) } } diff --git a/internal/module/artifact/runtimeapi/sync.go b/internal/module/artifact/runtimeapi/sync.go index 1a10839c..910e21c0 100644 --- a/internal/module/artifact/runtimeapi/sync.go +++ b/internal/module/artifact/runtimeapi/sync.go @@ -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 diff --git a/internal/module/artifact/runtimeapi/sync_test.go b/internal/module/artifact/runtimeapi/sync_test.go index 00212b50..c1f568f3 100644 --- a/internal/module/artifact/runtimeapi/sync_test.go +++ b/internal/module/artifact/runtimeapi/sync_test.go @@ -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")) diff --git a/internal/module/lifecycle/modulemanager.go b/internal/module/lifecycle/modulemanager.go index 581fb3c5..a9ff27d6 100644 --- a/internal/module/lifecycle/modulemanager.go +++ b/internal/module/lifecycle/modulemanager.go @@ -67,6 +67,13 @@ func (m *ModuleManager) generateAppToDirs(ctx context.Context, appName string, m return xfmt.Errorf("error loading module for app %s: %w", appName, result.Error) } + // Ensure EnsureServiceEntry Specs (TranslationTerm) are in IR before codegen. + // BundleInject alone puts models into JS without Persist; skipping this leaves + // getApplication empty for Ensure-only apps (web) and can drop stale protos. + if err := m.ensureInjectedAppModelsForCodegen(ctx, &mod); err != nil { + return err + } + gen := modulegenerator.NewGrpcGenerator(m.runtimeScope, &mod) genToTargets, ok := gen.(module.GeneratorToTargets) if !ok { @@ -78,6 +85,60 @@ func (m *ModuleManager) generateAppToDirs(ctx context.Context, appName string, m return nil } +// ensureInjectedAppModelsForCodegen rebuilds the app's representative module when +// TranslationTerm is missing from effective IR so GenerateApp can emit protos. +// BundleInject alone does not Persist; Ensure-only apps (web) otherwise stay empty. +func (m *ModuleManager) ensureInjectedAppModelsForCodegen(ctx context.Context, mod *meta.Module) error { + if m == nil || mod == nil { + return nil + } + app := strings.TrimSpace(mod.ApplicationStr) + if app == "" || app == "core" { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + var count int64 + if err := m.runtimeScope.Session().Model(&meta.Model{}). + Where("application = ? AND name = ? AND abstract = ?", app, "TranslationTerm", false). + Where("(module_id IS NULL OR module_id = '')"). + Count(&count).Error; err != nil { + return xfmt.Errorf("count TranslationTerm for application %s: %w", app, err) + } + if count > 0 { + return nil + } + + runtimeOpts := m.resolvedRuntimeOptions() + entry := strings.TrimSpace(mod.ServiceEntryPoint) + if entry != "" && !filepath.IsAbs(entry) { + entry = filepath.Join(runtimeOpts.modulesPath, mod.Name, entry) + } + builder := internalbackendbuilder.NewModuleBuilder( + m.runtimeScope, + m.jsExecutor, + mod, + entry, + internalbackendbuilder.WithPublishDist(false), + ) + split, ok := builder.(module.SplitBuilder) + if !ok { + return xfmt.Errorf("builder does not support BuildWithoutPersist for module %s", mod.Name) + } + buildResult, err := split.BuildWithoutPersist() + if err != nil { + return xfmt.Errorf("rebuild module %s for TranslationTerm inject: %w", mod.Name, err) + } + if err := split.Persist(buildResult); err != nil { + return xfmt.Errorf("persist TranslationTerm inject for application %s: %w", app, err) + } + return nil +} + func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string, distAppDir string) error { select { case <-ctx.Done(): @@ -91,7 +152,7 @@ func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string var mods []meta.Module if err := m.runtimeScope.Session(). - Where("application_str = ? AND status = ? AND service_entry_point <> ''", appName, meta.Installed). + Where("application_str = ? AND status = ?", appName, meta.Installed). Order("id ASC"). Find(&mods).Error; err != nil { return xfmt.Errorf("error loading backend modules for app %s: %w", appName, err) @@ -107,6 +168,7 @@ func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string entryFilePath := filepath.Join(distAppDir, "__choysum_app_entry.ts") var b strings.Builder b.WriteString("// Generated by Choysum. DO NOT EDIT.\n") + hasEntryImport := false for i := range mods { entry := strings.TrimSpace(mods[i].ServiceEntryPoint) if entry == "" { @@ -119,6 +181,12 @@ func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string b.WriteString("import \"") b.WriteString(entry) b.WriteString("\";\n") + hasEntryImport = true + } + // Ensure-only apps (web) have no persisted ServiceEntryPoint; still emit a + // stub entry so BundleInjectAppModels can attach TranslationTerm. + if !hasEntryImport { + b.WriteString("export {};\n") } if err := os.WriteFile(entryFilePath, []byte(b.String()), 0o644); err != nil { return xfmt.Errorf("write app entry: %w", err) @@ -127,6 +195,13 @@ func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string // Use the last installed module as representative for build metadata. rep := &mods[len(mods)-1] builder := internalbackendbuilder.NewModuleBuilder(m.runtimeScope, m.jsExecutor, rep, entryFilePath, internalbackendbuilder.WithPublishDist(true)) + ptrs := make([]*meta.Module, 0, len(mods)) + for i := range mods { + ptrs = append(ptrs, &mods[i]) + } + if err := ensureBundleC2VirtualImports(builder, nil, nil, ptrs); err != nil { + return err + } bundlerToDir, ok := builder.(module.BundlerToDir) if !ok { return xfmt.Errorf("backend builder does not support BundleToDirCtx") @@ -1378,7 +1453,7 @@ func (m *ModuleManager) Upgrade(ctx context.Context, name string) error { return rollbackUpgradeOrigin(err) } if meta.IsCoreModule(mod.Name) { - apps, err := m.listInstalledNonWebApps(ctx) + apps, err := m.listInstalledApps(ctx) if err != nil { return rollbackUpgradeOrigin(err) } @@ -1558,7 +1633,9 @@ func (b moduleOpCtxBinder) upgrade(mod *meta.Module) error { return b.m.upgradeWithCtx(mod, b.opCtx) } -func (m *ModuleManager) listInstalledNonWebApps(ctx context.Context) ([]string, error) { +// listInstalledApps returns distinct non-empty application_str values for +// installed modules (including application "web" for TranslationTerm codegen). +func (m *ModuleManager) listInstalledApps(ctx context.Context) ([]string, error) { if ctx == nil { ctx = context.Background() } @@ -1576,9 +1653,11 @@ func (m *ModuleManager) listInstalledNonWebApps(ctx context.Context) ([]string, apps := make([]string, 0, len(names)) for _, name := range names { name = strings.TrimSpace(name) - if name == "" || strings.EqualFold(name, "web") { + if name == "" { continue } + // Include application "web" so core upgrades regenerate api/web/proto + // (TranslationTerm EnsureServiceEntry). Global SPA remains dist/web. apps = append(apps, name) } diff --git a/internal/module/lifecycle/modulemanager_coverage_test.go b/internal/module/lifecycle/modulemanager_coverage_test.go index b7f569d2..b21ce353 100644 --- a/internal/module/lifecycle/modulemanager_coverage_test.go +++ b/internal/module/lifecycle/modulemanager_coverage_test.go @@ -125,7 +125,7 @@ func seedDuplicateLiveModelsForTest(t *testing.T, db *gorm.DB) { } } -func TestModuleManagerListInstalledNonWebApps(t *testing.T) { +func TestModuleManagerListInstalledApps(t *testing.T) { db := newModuleIndexSyncDB(t) if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) @@ -143,24 +143,24 @@ func TestModuleManagerListInstalledNonWebApps(t *testing.T) { runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) manager := NewModuleManager(runtimeScope, nil) - apps, err := manager.listInstalledNonWebApps(context.Background()) + apps, err := manager.listInstalledApps(context.Background()) if err != nil { - t.Fatalf("listInstalledNonWebApps() error = %v", err) + t.Fatalf("listInstalledApps() error = %v", err) } - if len(apps) != 1 || apps[0] != "crm" { - t.Fatalf("listInstalledNonWebApps() = %#v, want [crm]", apps) + if len(apps) != 2 || apps[0] != "crm" || apps[1] != "web" { + t.Fatalf("listInstalledApps() = %#v, want [crm web]", apps) } - apps, err = manager.listInstalledNonWebApps(nil) + apps, err = manager.listInstalledApps(nil) if err != nil { - t.Fatalf("listInstalledNonWebApps(nil ctx) error = %v", err) + t.Fatalf("listInstalledApps(nil ctx) error = %v", err) } - if len(apps) != 1 || apps[0] != "crm" { - t.Fatalf("listInstalledNonWebApps(nil ctx) = %#v, want [crm]", apps) + if len(apps) != 2 || apps[0] != "crm" || apps[1] != "web" { + t.Fatalf("listInstalledApps(nil ctx) = %#v, want [crm web]", apps) } } -func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { +func TestModuleManagerListInstalledAppsQueryError(t *testing.T) { db := newModuleIndexSyncDB(t) if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { t.Fatalf("auto migrate meta entities: %v", err) @@ -172,9 +172,9 @@ func TestModuleManagerListInstalledNonWebAppsQueryError(t *testing.T) { runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) manager := NewModuleManager(runtimeScope, nil) - _, err := manager.listInstalledNonWebApps(context.Background()) + _, err := manager.listInstalledApps(context.Background()) if err == nil || !strings.Contains(err.Error(), "list installed apps") { - t.Fatalf("listInstalledNonWebApps() error = %v, want list installed apps failure", err) + t.Fatalf("listInstalledApps() error = %v, want list installed apps failure", err) } } diff --git a/internal/module/plan/planner.go b/internal/module/plan/planner.go index 13ebbfae..6788544e 100644 --- a/internal/module/plan/planner.go +++ b/internal/module/plan/planner.go @@ -48,11 +48,10 @@ func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (P if name == "" { return } - // "web" application is handled by the global web build stage (dist/web). - // Including it in app-stage would collide with dist/web publication. - if strings.EqualFold(name, "web") { - return - } + // Keep application "web" in AffectedApps so app-stage can generate + // api/web/proto (TranslationTerm via EnsureServiceEntry). Global SPA + // output stays on dist/web (NeedsGlobalWebBuild); modules WebDir is + // generated/web/web and does not collide with that publication. apps[name] = true } diff --git a/internal/module/plan/planner_test.go b/internal/module/plan/planner_test.go index 09a9a657..89e05ce1 100644 --- a/internal/module/plan/planner_test.go +++ b/internal/module/plan/planner_test.go @@ -85,7 +85,7 @@ func TestBuildPlanInstallErrorsAndAppCollection(t *testing.T) { if len(plan.ModuleOrder) != 3 || plan.ModuleOrder[0] != "dep" || plan.ModuleOrder[1] != "webmod" || plan.ModuleOrder[2] != "base" { t.Fatalf("unexpected module order: %v", plan.ModuleOrder) } - if len(plan.AffectedApps) != 1 || plan.AffectedApps[0] != "crm" { + if len(plan.AffectedApps) != 2 || plan.AffectedApps[0] != "crm" || plan.AffectedApps[1] != "web" { t.Fatalf("unexpected affected apps: %v", plan.AffectedApps) } if !plan.NeedsGlobalWebBuild { @@ -366,10 +366,10 @@ func TestBuildPlan_AffectedAppsSortedForStableLogs(t *testing.T) { t.Fatalf("BuildPlan error: %v", err) } - if len(plan.AffectedApps) != 3 { - t.Fatalf("expected 3 affected apps, got %v", plan.AffectedApps) + if len(plan.AffectedApps) != 4 { + t.Fatalf("expected 4 affected apps, got %v", plan.AffectedApps) } - want := []string{"alpha", "beta", "zeta"} + want := []string{"alpha", "beta", "web", "zeta"} for i := range want { if plan.AffectedApps[i] != want[i] { t.Fatalf("expected sorted affected apps %v, got %v", want, plan.AffectedApps) diff --git a/internal/server/runplan/dist_validation.go b/internal/server/runplan/dist_validation.go index df5be1b7..3ecb8e04 100644 --- a/internal/server/runplan/dist_validation.go +++ b/internal/server/runplan/dist_validation.go @@ -32,6 +32,9 @@ func ValidateDistForTargets(bundleMode string, distRoot string, targets []string } if name == "web" { needsWeb = true + // web also hosts TranslationTerm gRPC (EnsureServiceEntry); treat it + // as a backend proto target while still requiring dist/web for SPA. + backendTargets = append(backendTargets, name) continue } backendTargets = append(backendTargets, name) @@ -69,6 +72,20 @@ func ValidateDistForTargets(bundleMode string, distRoot string, targets []string case "application": appsDir := filepath.Join(distRoot, "apps") for _, app := range backendTargets { + if app == "web" { + // SPA lives under dist/web; backend scripts/proto for web use + // dist/apps/web when not in bundle mode. + appDir := filepath.Join(appsDir, app) + indexJS := filepath.Join(appDir, "index.js") + if st, err := os.Stat(indexJS); err != nil || st.IsDir() { + return xfmt.Errorf("app index missing: %s", indexJS) + } + protoDir := config.APIAppProtoDir(distRoot, app) + if st, err := os.Stat(protoDir); err != nil || !st.IsDir() { + return xfmt.Errorf("api proto assets missing: %s", protoDir) + } + continue + } appDir := filepath.Join(appsDir, app) indexJS := filepath.Join(appDir, "index.js") if st, err := os.Stat(indexJS); err != nil || st.IsDir() { @@ -147,8 +164,17 @@ func resolveDefaultTargetsFromDist(bundleMode string, distRoot string) ([]string out := make([]string, 0, len(backend)+1) out = append(out, backend...) - if st, err := os.Stat(filepath.Join(distRoot, "web")); err == nil && st.IsDir() { - out = append(out, "web") + hasWeb := false + for _, app := range backend { + if app == "web" { + hasWeb = true + break + } + } + if !hasWeb { + if st, err := os.Stat(filepath.Join(distRoot, "web")); err == nil && st.IsDir() { + out = append(out, "web") + } } return out, nil } diff --git a/internal/server/runplan/dist_validation_test.go b/internal/server/runplan/dist_validation_test.go index 5b2ac25d..e1b8db8d 100644 --- a/internal/server/runplan/dist_validation_test.go +++ b/internal/server/runplan/dist_validation_test.go @@ -37,13 +37,33 @@ func TestValidateDistForTargets_BundleMode_BundlesIndexMissing(t *testing.T) { } } -func TestValidateDistForTargets_WebOnly_DoesNotRequireBundles(t *testing.T) { +func TestValidateDistForTargets_WebOnly_RequiresBundlesAndProto(t *testing.T) { distRoot := t.TempDir() if err := os.MkdirAll(filepath.Join(distRoot, "web"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } + err := ValidateDistForTargets("bundle", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "bundles dir missing") { + t.Fatalf("expected bundles dir missing for web-only, got %v", err) + } + + if err := os.MkdirAll(filepath.Join(distRoot, "bundles"), 0o755); err != nil { + t.Fatalf("mkdir bundles: %v", err) + } + if err := os.WriteFile(filepath.Join(distRoot, "bundles", "index.js"), []byte("export {}\n"), 0o644); err != nil { + t.Fatalf("write bundles index: %v", err) + } + err = ValidateDistForTargets("bundle", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "api proto assets missing") { + t.Fatalf("expected api proto missing for web-only, got %v", err) + } + + webProto := config.APIAppProtoDir(distRoot, "web") + if err := os.MkdirAll(webProto, 0o755); err != nil { + t.Fatalf("mkdir web proto: %v", err) + } if err := ValidateDistForTargets("bundle", distRoot, []string{"web"}); err != nil { - t.Fatalf("expected nil error, got %v", err) + t.Fatalf("expected nil error with web+bundles+proto, got %v", err) } } @@ -70,6 +90,7 @@ func TestValidateDistForTargets_DefaultBundleMode_SucceedsWithBackendAndWeb(t *t for _, dir := range []string{ filepath.Join(distRoot, "bundles"), config.APIAppProtoDir(distRoot, "auth"), + config.APIAppProtoDir(distRoot, "web"), filepath.Join(distRoot, "web"), } { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -137,7 +158,9 @@ func TestValidateDistForTargets_ApplicationMode_SucceedsWithAssetsAndWeb(t *test for _, dir := range []string{ filepath.Join(distRoot, "apps", "auth", "assets"), filepath.Join(distRoot, "apps", "base", "assets"), + filepath.Join(distRoot, "apps", "web"), filepath.Join(distRoot, "web"), + config.APIAppProtoDir(distRoot, "web"), } { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) @@ -149,6 +172,9 @@ func TestValidateDistForTargets_ApplicationMode_SucceedsWithAssetsAndWeb(t *test if err := os.WriteFile(filepath.Join(distRoot, "apps", "base", "index.js"), []byte("// base"), 0o644); err != nil { t.Fatalf("write base index: %v", err) } + if err := os.WriteFile(filepath.Join(distRoot, "apps", "web", "index.js"), []byte("// web"), 0o644); err != nil { + t.Fatalf("write web index: %v", err) + } if err := os.WriteFile(filepath.Join(distRoot, "apps", "auth", "assets", "a.proto"), []byte("syntax = \"proto3\";"), 0o644); err != nil { t.Fatalf("write auth asset: %v", err) } diff --git a/internal/server/runplan/plan_test.go b/internal/server/runplan/plan_test.go index fe115e7e..692ca836 100644 --- a/internal/server/runplan/plan_test.go +++ b/internal/server/runplan/plan_test.go @@ -78,7 +78,7 @@ func TestBuildRunDecision_ExplicitBackendValidationFailureIsError(t *testing.T) } } -func TestBuildRunDecision_DefaultWebOnlyCanRunApplication(t *testing.T) { +func TestBuildRunDecision_DefaultWebOnlyWithoutBackendFallsBackToBootstrap(t *testing.T) { distRoot := t.TempDir() if err := os.MkdirAll(filepath.Join(distRoot, "web"), 0o755); err != nil { t.Fatalf("MkdirAll(web) error = %v", err) @@ -88,10 +88,9 @@ func TestBuildRunDecision_DefaultWebOnlyCanRunApplication(t *testing.T) { if err != nil { t.Fatalf("buildRunDecision() error = %v", err) } - if decision.RunMode != RunModeApplication { - t.Fatalf("buildRunDecision() run mode = %q, want %q", decision.RunMode, RunModeApplication) - } - if len(decision.ServeTargets) != 1 || decision.ServeTargets[0] != "web" { - t.Fatalf("buildRunDecision() targets = %#v, want [web]", decision.ServeTargets) + // web now requires bundles + api/web/proto for TranslationTerm; SPA-only + // dist/web is not enough to enter application mode. + if decision.RunMode != RunModeBootstrap { + t.Fatalf("buildRunDecision() run mode = %q, want %q", decision.RunMode, RunModeBootstrap) } } diff --git a/internal/service/service.go b/internal/service/service.go index 79287b86..3c1bc912 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -31,6 +31,10 @@ type ApplicationService struct { name string runtimeOptions runtimeOptions appDistPath string + // scriptDistPath is where index.js lives for QuickJS service handlers. + // For application "web", static UI stays on appDistPath (dist/web) while + // scripts come from bundles (or dist/apps/web) so TranslationTerm RPC works. + scriptDistPath string protoRootDir string bundleMode string jsExecutor jsexecutor.ScriptExecutor @@ -301,10 +305,11 @@ func (s *ApplicationService) ServiceDescs() ([]*grpc.ServiceDesc, error) { } func (s *ApplicationService) ServiceScripts() []*jsengine.JsScript { - if s.name == "web" { - return nil + scriptRoot := strings.TrimSpace(s.scriptDistPath) + if scriptRoot == "" { + scriptRoot = s.appDistPath } - scriptPath := filepath.Join(s.appDistPath, "index.js") + scriptPath := filepath.Join(scriptRoot, "index.js") if _, err := os.Stat(scriptPath); os.IsNotExist(err) { return nil } @@ -470,16 +475,25 @@ func NewApplicationService(runtimeScope scope.Scope, name string, jsExecutor jse distPath := service.runtimeOptions.distPath // Resolve dist paths for runtime. + // application "web" still serves static assets from dist/web, but also + // exposes api/web/proto + backend scripts (TranslationTerm EnsureServiceEntry). if name == "web" { service.appDistPath = filepath.Join(distPath, "web") - service.protoImportPaths = nil - service.protoRootDir = "" + service.protoRootDir = config.APIAppProtoDir(distPath, name) + service.protoImportPaths = []string{service.protoRootDir} + if mode == "bundle" { + service.scriptDistPath = filepath.Join(distPath, "bundles") + } else { + service.scriptDistPath = filepath.Join(distPath, "apps", name) + } } else if mode == "bundle" { service.appDistPath = filepath.Join(distPath, "bundles") + service.scriptDistPath = service.appDistPath service.protoRootDir = config.APIAppProtoDir(distPath, name) service.protoImportPaths = []string{config.APIAppProtoDir(distPath, name)} } else { service.appDistPath = filepath.Join(distPath, "apps", name) + service.scriptDistPath = service.appDistPath service.protoRootDir = config.APIAppProtoDir(distPath, name) service.protoImportPaths = []string{config.APIAppProtoDir(distPath, name)} } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 04b28a5c..308a560b 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -705,8 +705,11 @@ func TestServiceScriptsAndWebHandlers(t *testing.T) { if len(scripts) != 1 || scripts[0].FileName != filepath.Join(appDir, "index.js") || scripts[0].Content != "console.log('auth')" { t.Fatalf("unexpected service scripts: %#v", scripts) } - if webScripts := (&ApplicationService{runtimeScope: runtimeScope, name: "web", appDistPath: webDir}).ServiceScripts(); webScripts != nil { - t.Fatalf("expected web service scripts to be nil, got %#v", webScripts) + if webScripts := (&ApplicationService{runtimeScope: runtimeScope, name: "web", appDistPath: webDir, scriptDistPath: appDir}).ServiceScripts(); len(webScripts) != 1 { + t.Fatalf("expected web service scripts from scriptDistPath, got %#v", webScripts) + } + if webScriptsNil := (&ApplicationService{runtimeScope: runtimeScope, name: "web", appDistPath: webDir}).ServiceScripts(); webScriptsNil != nil { + t.Fatalf("expected web without scriptDistPath/index.js to return nil, got %#v", webScriptsNil) } if missingScripts := (&ApplicationService{runtimeScope: runtimeScope, name: "auth", appDistPath: filepath.Join(distDir, "apps", "missing")}).ServiceScripts(); missingScripts != nil { t.Fatalf("expected missing script path to return nil, got %#v", missingScripts) @@ -871,11 +874,13 @@ func TestSafeStaticPathRejectsParentRoot(t *testing.T) { func TestNewApplicationServiceResolvesPaths(t *testing.T) { distDir := t.TempDir() authAPIProtoDir := config.APIAppProtoDir(distDir, "auth") + webAPIProtoDir := config.APIAppProtoDir(distDir, "web") for _, dir := range []string{ filepath.Join(distDir, "web"), filepath.Join(distDir, "bundles"), filepath.Join(distDir, "apps", "auth"), authAPIProtoDir, + webAPIProtoDir, } { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) @@ -906,7 +911,11 @@ func TestNewApplicationServiceResolvesPaths(t *testing.T) { if err != nil { t.Fatalf("NewApplicationService(web) error = %v", err) } - if webSvc.appDistPath != filepath.Join(distDir, "web") || webSvc.protoRootDir != "" || webSvc.protoImportPaths != nil { + if webSvc.appDistPath != filepath.Join(distDir, "web") || + webSvc.scriptDistPath != filepath.Join(distDir, "bundles") || + webSvc.protoRootDir != webAPIProtoDir || + len(webSvc.protoImportPaths) != 1 || + webSvc.protoImportPaths[0] != webAPIProtoDir { t.Fatalf("unexpected web service paths: %#v", webSvc) } } From 84da3ab7d3834bbcb8a805cb7a78b550113d34db Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 16:51:56 +0800 Subject: [PATCH 3/9] fix(i18n): register app protos under {app}/ path for ProtoLoader - Prefix ServiceDescs RegisterProto paths as {app}/{file}.proto so web works without JS bundle registration. - Cover api//proto layout and loaderRegisterPath helpers in unit tests. Co-authored-by: Cursor --- internal/service/service.go | 25 ++++++++++++- internal/service/service_test.go | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/service/service.go b/internal/service/service.go index 3c1bc912..32602493 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -274,6 +274,8 @@ func (s *ApplicationService) ServiceDescs() ([]*grpc.ServiceDesc, error) { serviceDescs = append(serviceDescs, parsed...) // Register app protos into the global loader for ExecuteJob routing. + // Paths must be "{app}/{file}.proto" so ProtoLoader.appProtoFiles(app) + // can find them (same convention as generated service clients). if len(protoFiles) > 0 && len(s.protoImportPaths) > 0 { importRoot := s.protoImportPaths[0] for _, file := range protoFiles { @@ -289,7 +291,7 @@ func (s *ApplicationService) ServiceDescs() ([]*grpc.ServiceDesc, error) { if err != nil { continue } - loader.Global().RegisterProto(rel, string(content)) + loader.Global().RegisterProto(loaderRegisterPath(s.name, rel), string(content)) } } } @@ -504,3 +506,24 @@ func NewApplicationService(runtimeScope scope.Scope, name string, jsExecutor jse return service, nil } + +// loaderRegisterPath mirrors generate.resolveProtoRegisterPath: app-owned +// files become "{app}/{rel}", while google/* stays unprefixed. +func loaderRegisterPath(appName, relPath string) string { + rel := filepath.ToSlash(strings.TrimSpace(relPath)) + if rel == "" || rel == "." { + return "" + } + if strings.HasPrefix(rel, "google/") { + return rel + } + app := strings.TrimSpace(appName) + if app == "" { + return rel + } + // Already "{app}/..." (e.g. tests that walk a parent import root). + if strings.HasPrefix(rel, app+"/") { + return rel + } + return filepath.ToSlash(filepath.Join(app, rel)) +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 308a560b..ad61138c 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -1794,6 +1794,21 @@ func TestUnaryHandlerErrorPaths(t *testing.T) { }) } +func TestLoaderRegisterPath(t *testing.T) { + if got := loaderRegisterPath("web", "web.proto"); got != "web/web.proto" { + t.Fatalf("api-layout path = %q, want web/web.proto", got) + } + if got := loaderRegisterPath("web", "web/web.proto"); got != "web/web.proto" { + t.Fatalf("already-prefixed path = %q, want unchanged", got) + } + if got := loaderRegisterPath("web", "google/protobuf/empty.proto"); got != "google/protobuf/empty.proto" { + t.Fatalf("google path = %q", got) + } + if got := loaderRegisterPath("", "x.proto"); got != "x.proto" { + t.Fatalf("empty app = %q", got) + } +} + func TestServiceDescsRegistersLoaderAndSkipsTaskWorkerForWeb(t *testing.T) { t.Run("non-web service registers proto in global loader", func(t *testing.T) { root := t.TempDir() @@ -1879,6 +1894,51 @@ message PingReply { string msg = 1; } t.Fatalf("expected web.WebService only without TaskWorker/I18n, got %#v", descs) } }) + + t.Run("api-app-proto-dir layout registers web/web.proto for loader", func(t *testing.T) { + // Matches NewApplicationService: protoRootDir == protoImportPaths == api//proto. + root := t.TempDir() + protoDir := filepath.Join(root, "api", "web", "proto") + if err := os.MkdirAll(protoDir, 0o755); err != nil { + t.Fatalf("mkdir api web proto: %v", err) + } + protoText := `syntax = "proto3"; +package web; + +service TranslationTerm { + rpc GetTranslations(GetTranslationsReq) returns (GetTranslationsResp); +} + +message GetTranslationsReq { string lang = 1; } +message GetTranslationsResp { string hash = 1; } +` + if err := os.WriteFile(filepath.Join(protoDir, "web.proto"), []byte(protoText), 0o644); err != nil { + t.Fatalf("write web.proto: %v", err) + } + + loader.ResetGlobalForTests() + runtimeScope := newHelperScope(root) + svc := &ApplicationService{ + runtimeScope: runtimeScope, + name: "web", + appDistPath: filepath.Join(root, "web"), + protoRootDir: protoDir, + protoImportPaths: []string{protoDir}, + } + if err := os.MkdirAll(svc.appDistPath, 0o755); err != nil { + t.Fatalf("mkdir web dist: %v", err) + } + if _, err := svc.ServiceDescs(); err != nil { + t.Fatalf("ServiceDescs(web api layout) error = %v", err) + } + md, err := loader.Global().GetMethodDescriptor("web.TranslationTerm.GetTranslations") + if err != nil { + t.Fatalf("expected web GetTranslations descriptor after Go RegisterProto, got %v", err) + } + if string(md.FullName()) != "web.TranslationTerm.GetTranslations" { + t.Fatalf("unexpected method: %s", md.FullName()) + } + }) } func TestServiceDescsEdgeCases(t *testing.T) { From 5ad02a13529df0bfdd711ee8ed7724fab36d5182 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 17:41:46 +0800 Subject: [PATCH 4/9] fix(i18n): bypass RecordRule in GetTranslations catalog Search - Wrap gateway catalog Search with withRepositoryAuthzRuleBypass so non-meta hosts return terms under internal identity. - Cover the bypass path in TranslationTerm GetTranslations unit tests. Co-authored-by: Cursor --- .../orm/model/translation_term_base_model.ts | 18 ++++++---- ...anslation_term_base_model_coverage.test.ts | 33 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/modules/core/service/orm/model/translation_term_base_model.ts b/modules/core/service/orm/model/translation_term_base_model.ts index 7979e24b..93fba08c 100644 --- a/modules/core/service/orm/model/translation_term_base_model.ts +++ b/modules/core/service/orm/model/translation_term_base_model.ts @@ -4,6 +4,7 @@ import { Field } from '../decorator/field'; import { MetadataStorage } from '../metadata/storage'; import { raiseDomainError } from '@/core/service/error'; +import { withRepositoryAuthzRuleBypass } from '../repository/authz'; import BaseModel from './model'; import type { InstantiableModelCtor } from './types'; import type { @@ -350,12 +351,17 @@ export default class TranslationTermBaseModel extends BaseModel { await ensureTermUniqueIndex(this); // Match Go TermStore: hash is language-wide; module_names only filters the payload. - const rows = (await (this as any).Search( - { And: [['Lang', '=', lang]] }, - { - fields: ['Module', 'Scope', 'Src', 'Value', 'Kind', 'Source'] as any, - limit: 0, - } as any + // Gateway dials GetTranslations with internal identity (no userId). Without a + // RecordRule bypass, non-meta hosts return an empty read set — same pattern as + // FieldDefault.GetEffective (§7.3 authz bypass, no Model.sudo audit noise). + const rows = (await withRepositoryAuthzRuleBypass(async () => + (this as any).Search( + { And: [['Lang', '=', lang]] }, + { + fields: ['Module', 'Scope', 'Src', 'Value', 'Kind', 'Source'] as any, + limit: 0, + } as any + ) )) as TranslationTermBaseModel[]; const list = Array.isArray(rows) ? rows : []; diff --git a/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts b/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts index 1889e704..a7acca4d 100644 --- a/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts +++ b/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts @@ -38,6 +38,39 @@ function installSearch(ctor: typeof TtCovTerm, rows: any[] | null | undefined) { }; } +test('GetTranslations Search runs under authz rule bypass (gateway internal identity)', async () => { + __resetTranslationTermUniqueIndexTablesForTest(); + const { + getRepositoryFieldRuleBypassDepth, + getRepositoryRecordRuleBypassDepth, + } = await import('../repository/authz'); + const key = '$choysum'; + const hadOwn = Object.prototype.hasOwnProperty.call(globalThis as object, key); + const previous = (globalThis as Record)[key]; + (globalThis as Record)[key] = { + request: { context: { req: { id: `tt-bypass-${Date.now()}` } } }, + db: { dialectName: 'sqlite', execute: async () => undefined }, + }; + let sawBypass = false; + const original = TtCovTerm.Search; + TtCovTerm.Search = (async () => { + if (getRepositoryRecordRuleBypassDepth() > 0 && getRepositoryFieldRuleBypassDepth() > 0) { + sawBypass = true; + } + return [{ Module: 'auth', Scope: 'ui', Src: 'Hi', Value: '你好', Kind: 'literal', Source: 'packaged' }]; + }) as any; + try { + const out = await TtCovTerm.GetTranslations({ lang: 'zh_CN', module_names: ['auth'] }); + expect(sawBypass).toBe(true); + expect(out.terms_by_module).toEqual({ auth: { ui: { Hi: '你好' } } }); + } finally { + if (hadOwn) (globalThis as Record)[key] = previous; + else delete (globalThis as Record)[key]; + TtCovTerm.Search = original; + __resetTranslationTermUniqueIndexTablesForTest(); + } +}); + test('GetTranslations rejects missing lang and softDelete models', async () => { await expectRejects(TtCovTerm.GetTranslations({} as any), 'TRANSLATION_TERM_LANG_REQUIRED'); await expectRejects(TtCovTerm.GetTranslations({ lang: ' ' }), 'TRANSLATION_TERM_LANG_REQUIRED'); From 97e67f6a5a00fbada51fb216115d476fbcdb47a2 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 17:53:19 +0800 Subject: [PATCH 5/9] fix(server): seed web backend fixtures in mode-switch tests - Treat web as TranslationTerm backend in tests by seeding bundles and api/web/proto. - Keep application-mode bootstrap readiness covering apps/web index + proto. Co-authored-by: Cursor --- .../server_bootstrap_orchestration_test.go | 14 ++----- internal/server/server_serve_test.go | 4 +- ...ver_service_registration_bootstrap_test.go | 1 + internal/server/server_test_fixtures_test.go | 37 +++++++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/internal/server/server_bootstrap_orchestration_test.go b/internal/server/server_bootstrap_orchestration_test.go index 155d13e1..18e70211 100644 --- a/internal/server/server_bootstrap_orchestration_test.go +++ b/internal/server/server_bootstrap_orchestration_test.go @@ -10,8 +10,6 @@ import ( "log/slog" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" @@ -152,9 +150,7 @@ func TestServerRequestBootstrapModeSwitchTransitionsToApplicationAndRestarts(t * runtimeScope.cfg.DistPath = t.TempDir() runtimeScope.cfg.Compile.BundleMode = "bundle" - if err := os.MkdirAll(filepath.Join(runtimeScope.cfg.DistPath, "web"), 0o755); err != nil { - t.Fatalf("MkdirAll(web) error = %v", err) - } + seedBundleModeWebReadyDist(t, runtimeScope.cfg.DistPath) srv := NewServer(runtimeScope).(*GRPCWebServer) restoreRunStateForTest(srv, runStateSnapshot{ @@ -199,9 +195,7 @@ func TestServerRequestBootstrapModeSwitchDefaultRestartUsesColdStart(t *testing. runtimeScope.cfg.Server.EnableGrpcWebProxy = false runtimeScope.cfg.Server.HotReload = false - if err := os.MkdirAll(filepath.Join(runtimeScope.cfg.DistPath, "web"), 0o755); err != nil { - t.Fatalf("MkdirAll(web) error = %v", err) - } + seedBundleModeWebReadyDist(t, runtimeScope.cfg.DistPath) srv := NewServer(runtimeScope).(*GRPCWebServer) t.Cleanup(func() { @@ -251,9 +245,7 @@ func TestServerRequestBootstrapModeSwitchRestoresStateWhenRestartFails(t *testin runtimeScope.cfg.DistPath = t.TempDir() runtimeScope.cfg.Compile.BundleMode = "bundle" - if err := os.MkdirAll(filepath.Join(runtimeScope.cfg.DistPath, "web"), 0o755); err != nil { - t.Fatalf("MkdirAll(web) error = %v", err) - } + seedBundleModeWebReadyDist(t, runtimeScope.cfg.DistPath) previousManifest := &distmanifest.DistManifestV2{} previousTargets := []string{"bootstrap"} diff --git a/internal/server/server_serve_test.go b/internal/server/server_serve_test.go index 087d9d3d..aa67e4f3 100644 --- a/internal/server/server_serve_test.go +++ b/internal/server/server_serve_test.go @@ -65,9 +65,7 @@ func TestServerServeFallsThroughToServeAfterValidation(t *testing.T) { runtimeScope.cfg.Server.HotReload = false runtimeScope.cfg.Server.JsEngineFactory = "missing" - if err := os.MkdirAll(filepath.Join(runtimeScope.cfg.DistPath, "web"), 0o755); err != nil { - t.Fatalf("MkdirAll(web) error = %v", err) - } + seedBundleModeWebReadyDist(t, runtimeScope.cfg.DistPath) srv := NewServer(runtimeScope).(*GRPCWebServer) t.Cleanup(func() { diff --git a/internal/server/server_service_registration_bootstrap_test.go b/internal/server/server_service_registration_bootstrap_test.go index 3881c896..ac405d67 100644 --- a/internal/server/server_service_registration_bootstrap_test.go +++ b/internal/server/server_service_registration_bootstrap_test.go @@ -25,6 +25,7 @@ func TestBootstrapValidateRuntimeReadyUsesManifestCompileBundleMode(t *testing.T if err := os.MkdirAll(filepath.Join(distRoot, "web"), 0o755); err != nil { t.Fatalf("mkdir web: %v", err) } + seedApplicationModeWebBackendDist(t, distRoot) if err := os.MkdirAll(filepath.Join(distRoot, "apps", "auth", "assets"), 0o755); err != nil { t.Fatalf("mkdir app assets: %v", err) diff --git a/internal/server/server_test_fixtures_test.go b/internal/server/server_test_fixtures_test.go index a25af759..1fadabb6 100644 --- a/internal/server/server_test_fixtures_test.go +++ b/internal/server/server_test_fixtures_test.go @@ -446,3 +446,40 @@ func writeServerTestAppDist(t *testing.T, distRoot string, appName string, scrip t.Fatalf("WriteFile(service.proto) error = %v", err) } } + +// seedBundleModeWebReadyDist creates the minimal layout for "web" as both SPA +// (dist/web) and TranslationTerm backend (bundles + api/web/proto). +func seedBundleModeWebReadyDist(t *testing.T, distRoot string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(distRoot, "web"), 0o755); err != nil { + t.Fatalf("MkdirAll(web) error = %v", err) + } + bundlesDir := filepath.Join(distRoot, "bundles") + if err := os.MkdirAll(bundlesDir, 0o755); err != nil { + t.Fatalf("MkdirAll(bundles) error = %v", err) + } + if err := os.WriteFile(filepath.Join(bundlesDir, "index.js"), []byte("export {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(bundles/index.js) error = %v", err) + } + protoDir := config.APIAppProtoDir(distRoot, "web") + if err := os.MkdirAll(protoDir, 0o755); err != nil { + t.Fatalf("MkdirAll(api/web/proto) error = %v", err) + } +} + +// seedApplicationModeWebBackendDist adds apps/web + api/web/proto required when +// ValidateDistForTargets treats web as a backend target in application mode. +func seedApplicationModeWebBackendDist(t *testing.T, distRoot string) { + t.Helper() + appDir := filepath.Join(distRoot, "apps", "web") + if err := os.MkdirAll(appDir, 0o755); err != nil { + t.Fatalf("MkdirAll(apps/web) error = %v", err) + } + if err := os.WriteFile(filepath.Join(appDir, "index.js"), []byte("export {}\n"), 0o644); err != nil { + t.Fatalf("WriteFile(apps/web/index.js) error = %v", err) + } + protoDir := config.APIAppProtoDir(distRoot, "web") + if err := os.MkdirAll(protoDir, 0o755); err != nil { + t.Fatalf("MkdirAll(api/web/proto) error = %v", err) + } +} From 7d23568c75a91266b0db95f39bbf380b3381a846 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 18:18:10 +0800 Subject: [PATCH 6/9] fix(i18n): harden GetTranslations result unwrap and bypass coverage - Reject a present non-object GetTranslations result instead of legacy empty-catalog fallback. - Assert authz bypass depths restore after GetTranslations Search. - Clarify that catalog-wide bypass is intentional (FieldDefault.GetEffective pattern). Co-authored-by: Cursor --- internal/i18n/gateway/client.go | 20 ++++++++++++---- internal/i18n/gateway/rpc_client_test.go | 24 +++++++++++++++++++ .../orm/model/translation_term_base_model.ts | 7 +++--- ...anslation_term_base_model_coverage.test.ts | 2 ++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/internal/i18n/gateway/client.go b/internal/i18n/gateway/client.go index 2c5f2ffc..efb4e9e2 100644 --- a/internal/i18n/gateway/client.go +++ b/internal/i18n/gateway/client.go @@ -74,14 +74,26 @@ func fetchAppTranslations(ctx context.Context, runtimeScope scope.Scope, app, la if err != nil { return nil, fmt.Errorf("decode GetTranslations response: %w", err) } - // Response is TranslationTerm_GetTranslations_Resp{ Value result = 1 }. - payload, _ := out["result"].(map[string]any) - if payload == nil { - payload = out + 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 { result := &appTranslations{ Hash: strings.TrimSpace(fmt.Sprintf("%v", out["hash"])), diff --git a/internal/i18n/gateway/rpc_client_test.go b/internal/i18n/gateway/rpc_client_test.go index c30eb408..d91e9cb1 100644 --- a/internal/i18n/gateway/rpc_client_test.go +++ b/internal/i18n/gateway/rpc_client_test.go @@ -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": ""}) if empty.Hash != "" || len(empty.Terms) != 0 { diff --git a/modules/core/service/orm/model/translation_term_base_model.ts b/modules/core/service/orm/model/translation_term_base_model.ts index 93fba08c..1ac6e644 100644 --- a/modules/core/service/orm/model/translation_term_base_model.ts +++ b/modules/core/service/orm/model/translation_term_base_model.ts @@ -351,9 +351,10 @@ export default class TranslationTermBaseModel extends BaseModel { await ensureTermUniqueIndex(this); // Match Go TermStore: hash is language-wide; module_names only filters the payload. - // Gateway dials GetTranslations with internal identity (no userId). Without a - // RecordRule bypass, non-meta hosts return an empty read set — same pattern as - // FieldDefault.GetEffective (§7.3 authz bypass, no Model.sudo audit noise). + // Catalog-wide read for every caller (SPA language pack), not per-user scoped + // terms — same pattern as FieldDefault.GetEffective (§7.3). RecordRule-aware + // CRUD remains on Search/Create/Write; gateway internal identity without + // bypass would otherwise get an empty read set on non-meta hosts. const rows = (await withRepositoryAuthzRuleBypass(async () => (this as any).Search( { And: [['Lang', '=', lang]] }, diff --git a/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts b/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts index a7acca4d..5e18862c 100644 --- a/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts +++ b/modules/core/service/orm/model/translation_term_base_model_coverage.test.ts @@ -62,6 +62,8 @@ test('GetTranslations Search runs under authz rule bypass (gateway internal iden try { const out = await TtCovTerm.GetTranslations({ lang: 'zh_CN', module_names: ['auth'] }); expect(sawBypass).toBe(true); + expect(getRepositoryRecordRuleBypassDepth()).toBe(0); + expect(getRepositoryFieldRuleBypassDepth()).toBe(0); expect(out.terms_by_module).toEqual({ auth: { ui: { Hi: '你好' } } }); } finally { if (hadOwn) (globalThis as Record)[key] = previous; From 12c3cd60618e3c4310902f3194dbe88c3bd133cd Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 18:24:13 +0800 Subject: [PATCH 7/9] test: raise patch coverage to 100% for i18n web TranslationTerm changes - Cover malformed GetTranslations result unwrap via gateway RPC fixture. - Cover web application-mode dist validation and default-target dedupe. - Cover web application-mode scriptDistPath and empty loaderRegisterPath. - Cover ensureInjectedAppModelsForCodegen branches via testable builder hook. - Cover Ensure-only stub entry inject errors and core upgrade listInstalledApps. Co-authored-by: Cursor --- .../i18n/gateway/terms_rpc_fixture_test.go | 26 +- .../lifecycle/ensure_injected_codegen_test.go | 248 ++++++++++++++++++ .../lifecycle/module_index_sync_test.go | 52 +++- internal/module/lifecycle/modulemanager.go | 20 +- .../server/runplan/dist_validation_test.go | 76 ++++++ internal/service/service_test.go | 16 ++ 6 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 internal/module/lifecycle/ensure_injected_codegen_test.go diff --git a/internal/i18n/gateway/terms_rpc_fixture_test.go b/internal/i18n/gateway/terms_rpc_fixture_test.go index e1d90c21..38cba335 100644 --- a/internal/i18n/gateway/terms_rpc_fixture_test.go +++ b/internal/i18n/gateway/terms_rpc_fixture_test.go @@ -100,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 { @@ -147,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(map[string]any{"result": payload}, resp); err != nil { + if err := converter.MapToMessage(map[string]any{"result": result}, resp); err != nil { return err } default: @@ -335,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) { diff --git a/internal/module/lifecycle/ensure_injected_codegen_test.go b/internal/module/lifecycle/ensure_injected_codegen_test.go new file mode 100644 index 00000000..f77fe341 --- /dev/null +++ b/internal/module/lifecycle/ensure_injected_codegen_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/choysum-dev/choysum/internal/module/artifact/pipeline" + moduleresult "github.com/choysum-dev/choysum/internal/module/artifact/result" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" + "github.com/choysum-dev/choysum/pkg/jsexecutor" + "github.com/choysum-dev/choysum/pkg/meta" + "github.com/choysum-dev/choysum/pkg/scope" +) + +type codegenStubSplitBuilder struct { + entrySeen string + buildErr error + persistErr error + persistCalls int +} + +func (b *codegenStubSplitBuilder) Build() (*moduleresult.BuildResult, error) { + return &moduleresult.BuildResult{}, nil +} + +func (b *codegenStubSplitBuilder) BuildWithoutPersist() (*moduleresult.BuildResult, error) { + if b.buildErr != nil { + return nil, b.buildErr + } + return &moduleresult.BuildResult{}, nil +} + +func (b *codegenStubSplitBuilder) Persist(result *moduleresult.BuildResult) error { + b.persistCalls++ + return b.persistErr +} + +func TestEnsureInjectedAppModelsForCodegenEarlyReturns(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + + if err := (*ModuleManager)(nil).ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{}); err != nil { + t.Fatalf("nil manager: %v", err) + } + if err := manager.ensureInjectedAppModelsForCodegen(context.Background(), nil); err != nil { + t.Fatalf("nil mod: %v", err) + } + if err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ApplicationStr: "core"}); err != nil { + t.Fatalf("core app: %v", err) + } + if err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ApplicationStr: " "}); err != nil { + t.Fatalf("blank app: %v", err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if err := manager.ensureInjectedAppModelsForCodegen(canceled, &meta.Module{ApplicationStr: "auth"}); err == nil { + t.Fatal("expected canceled context error") + } +} + +func TestEnsureInjectedAppModelsForCodegenCountPaths(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + + if err := db.Create(&meta.Model{ + Name: "TranslationTerm", + Path: "/virtual/auth/TranslationTerm", + Application: "auth", + Abstract: false, + }).Error; err != nil { + t.Fatalf("seed TranslationTerm: %v", err) + } + if err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "auth", ApplicationStr: "auth", + }); err != nil { + t.Fatalf("count>0 early return: %v", err) + } + + if err := db.Migrator().DropTable(&meta.Model{}); err != nil { + t.Fatalf("drop meta_model: %v", err) + } + err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "auth", ApplicationStr: "auth", + }) + if err == nil || !strings.Contains(err.Error(), "count TranslationTerm") { + t.Fatalf("count error = %v", err) + } +} + +func TestEnsureInjectedAppModelsForCodegenBuilderBranches(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + modulesPath := t.TempDir() + runtimeScope := newModuleIndexSyncScope(modulesPath, db) + manager := NewModuleManager(runtimeScope, nil) + + prev := newCodegenModuleBuilderFn + t.Cleanup(func() { newCodegenModuleBuilderFn = prev }) + + t.Run("not split builder", func(t *testing.T) { + newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { + return struct{}{} + } + err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "auth", ApplicationStr: "auth", ServiceEntryPoint: "service/index.ts", + }) + if err == nil || !strings.Contains(err.Error(), "does not support BuildWithoutPersist") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("build error", func(t *testing.T) { + stub := &codegenStubSplitBuilder{buildErr: errors.New("build boom")} + newCodegenModuleBuilderFn = func(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, entry string) any { + stub.entrySeen = entry + return stub + } + err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "auth", ApplicationStr: "auth", ServiceEntryPoint: "service/index.ts", + }) + if err == nil || !strings.Contains(err.Error(), "rebuild module auth") { + t.Fatalf("err = %v", err) + } + want := filepath.Join(modulesPath, "auth", "service/index.ts") + if stub.entrySeen != want { + t.Fatalf("entry = %q, want %q", stub.entrySeen, want) + } + }) + + t.Run("persist error", func(t *testing.T) { + stub := &codegenStubSplitBuilder{persistErr: errors.New("persist boom")} + newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { + return stub + } + err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "web", ApplicationStr: "web", ServiceEntryPoint: "", + }) + if err == nil || !strings.Contains(err.Error(), "persist TranslationTerm inject") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + stub := &codegenStubSplitBuilder{} + newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { + return stub + } + if err := manager.ensureInjectedAppModelsForCodegen(context.Background(), &meta.Module{ + Name: "web", ApplicationStr: "web", ServiceEntryPoint: "/abs/entry.ts", + }); err != nil { + t.Fatalf("success path: %v", err) + } + if stub.persistCalls != 1 { + t.Fatalf("persistCalls = %d, want 1", stub.persistCalls) + } + }) +} + +func TestGenerateAppToDirsPropagatesEnsureInjectedError(t *testing.T) { + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + if err := db.Create(&meta.Module{ + Name: "auth", ApplicationStr: "auth", Status: meta.Installed, + }).Error; err != nil { + t.Fatalf("seed module: %v", err) + } + + prev := newCodegenModuleBuilderFn + newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { + return &codegenStubSplitBuilder{buildErr: errors.New("inject boom")} + } + t.Cleanup(func() { newCodegenModuleBuilderFn = prev }) + + runtimeScope := newModuleIndexSyncScope(t.TempDir(), db) + manager := NewModuleManager(runtimeScope, nil) + manager.bootstrapOnce.Do(func() {}) + + err := manager.generateAppToDirs(context.Background(), "auth", pipeline.ModulesAppTargets{}, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "rebuild module auth") { + t.Fatalf("err = %v, want ensureInjected rebuild error", err) + } +} + +func TestBuildBackendAppToDir_EnsureOnlyStubAndInjectError(t *testing.T) { + modulesPath := t.TempDir() + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + modPath := filepath.Join(modulesPath, "web") + if err := os.MkdirAll(modPath, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + baseModel := filepath.Join(modulesPath, "core", "service", "orm", "model", "translation_term_base_model.ts") + if err := os.MkdirAll(filepath.Dir(baseModel), 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + if err := os.WriteFile(baseModel, []byte("export default class TranslationTermBaseModel {}\n"), 0o644); err != nil { + t.Fatalf("write base: %v", err) + } + if err := db.Create(&meta.Module{ + Name: "web", ApplicationStr: "web", Status: meta.Installed, + ServiceEntryPoint: "", Path: modPath, + }).Error; err != nil { + t.Fatalf("seed module: %v", err) + } + + runtimeScope := newModuleIndexSyncScope(modulesPath, db) + manager := NewModuleManager(runtimeScope, nil) + manager.bootstrapOnce.Do(func() {}) + distAppDir := t.TempDir() + + if err := db.Migrator().DropTable("meta_raw_model"); err != nil { + t.Fatalf("drop meta_raw_model: %v", err) + } + err := manager.buildBackendAppToDir(context.Background(), "web", distAppDir) + if err == nil || !strings.Contains(err.Error(), "inject app models for bundles") { + t.Fatalf("expected inject error, got %v", err) + } + + entryRaw, readErr := os.ReadFile(filepath.Join(distAppDir, "__choysum_app_entry.ts")) + if readErr != nil { + t.Fatalf("read entry: %v", readErr) + } + if !strings.Contains(string(entryRaw), "export {};") { + t.Fatalf("expected stub export for Ensure-only web, got %q", entryRaw) + } +} diff --git a/internal/module/lifecycle/module_index_sync_test.go b/internal/module/lifecycle/module_index_sync_test.go index e4a0eff6..81961395 100644 --- a/internal/module/lifecycle/module_index_sync_test.go +++ b/internal/module/lifecycle/module_index_sync_test.go @@ -29,7 +29,6 @@ import ( "google.golang.org/grpc/status" "gorm.io/driver/sqlite" "gorm.io/gorm" - ) type moduleIndexSyncTestScope struct { @@ -1235,3 +1234,54 @@ func TestModuleManagerUpgradeRunsAppStageCallbacks(t *testing.T) { } } } + +func TestModuleManagerUpgradeCoreUsesListInstalledApps(t *testing.T) { + modulesPath := t.TempDir() + distPath := filepath.Join(t.TempDir(), "dist") + tmpPath := filepath.Join(t.TempDir(), "tmp") + defaultChoysumPath := filepath.Join(t.TempDir(), ".choysum") + + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate meta entities: %v", err) + } + + runtimeScope := newModuleIndexSyncScope(modulesPath, db) + runtimeScope.cfg.DistPath = distPath + runtimeScope.cfg.TmpPath = tmpPath + runtimeScope.cfg.DefaultChoysumPath = defaultChoysumPath + runtimeScope.cfg.Compile = &config.CompileConfig{BundleMode: string(config.BundleModeApplication)} + + locker := &moduleIndexSyncTestLocker{} + coordinator := &moduleManagerInstallOriginCoordinator{module: &meta.Module{ + Name: "core", + ApplicationStr: "core", + Version: "v1.2.0", + Path: filepath.Join(modulesPath, "core"), + }} + manager := NewModuleManager( + runtimeScope, + &moduleManagerNoopScriptExecutor{}, + WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker }), + WithOriginCoordinatorFactory(func(scope.Scope) OriginCoordinator { return coordinator }), + ) + manager.bootstrapOnce.Do(func() {}) + if err := os.MkdirAll(filepath.Join(modulesPath, "core"), 0o755); err != nil { + t.Fatalf("mkdir core module dir: %v", err) + } + + for _, row := range []meta.Module{ + {Name: "core", Status: meta.Installed, Version: "v1.0.0", ApplicationStr: "core", Path: filepath.Join(modulesPath, "core")}, + {Name: "web", Status: meta.Installed, Version: "v1.0.0", ApplicationStr: "web", Path: filepath.Join(modulesPath, "web")}, + } { + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed module %q: %v", row.Name, err) + } + } + + // Upgrade may fail later in staging for Ensure-only web; listInstalledApps must still run. + _ = manager.Upgrade(context.Background(), "core") + if locker.acquired != 1 { + t.Fatalf("locker.acquired = %d, want 1 (upgrade entered lease past listInstalledApps)", locker.acquired) + } +} diff --git a/internal/module/lifecycle/modulemanager.go b/internal/module/lifecycle/modulemanager.go index a9ff27d6..8ab001a2 100644 --- a/internal/module/lifecycle/modulemanager.go +++ b/internal/module/lifecycle/modulemanager.go @@ -118,13 +118,7 @@ func (m *ModuleManager) ensureInjectedAppModelsForCodegen(ctx context.Context, m if entry != "" && !filepath.IsAbs(entry) { entry = filepath.Join(runtimeOpts.modulesPath, mod.Name, entry) } - builder := internalbackendbuilder.NewModuleBuilder( - m.runtimeScope, - m.jsExecutor, - mod, - entry, - internalbackendbuilder.WithPublishDist(false), - ) + builder := newCodegenModuleBuilderFn(m.runtimeScope, m.jsExecutor, mod, entry) split, ok := builder.(module.SplitBuilder) if !ok { return xfmt.Errorf("builder does not support BuildWithoutPersist for module %s", mod.Name) @@ -139,6 +133,18 @@ func (m *ModuleManager) ensureInjectedAppModelsForCodegen(ctx context.Context, m return nil } +// newCodegenModuleBuilderFn builds the module builder used to Persist EnsureServiceEntry +// Specs before app codegen. Tests may override it to force SplitBuilder failures. +var newCodegenModuleBuilderFn = func(runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, mod *meta.Module, entry string) any { + return internalbackendbuilder.NewModuleBuilder( + runtimeScope, + jsExecutor, + mod, + entry, + internalbackendbuilder.WithPublishDist(false), + ) +} + func (m *ModuleManager) buildBackendAppToDir(ctx context.Context, appName string, distAppDir string) error { select { case <-ctx.Done(): diff --git a/internal/server/runplan/dist_validation_test.go b/internal/server/runplan/dist_validation_test.go index e1b8db8d..3e232ba7 100644 --- a/internal/server/runplan/dist_validation_test.go +++ b/internal/server/runplan/dist_validation_test.go @@ -187,6 +187,63 @@ func TestValidateDistForTargets_ApplicationMode_SucceedsWithAssetsAndWeb(t *test } } +func TestValidateDistForTargets_ApplicationMode_WebIndexMissing(t *testing.T) { + distRoot := t.TempDir() + for _, dir := range []string{ + filepath.Join(distRoot, "web"), + filepath.Join(distRoot, "apps", "web"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + err := ValidateDistForTargets("application", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "app index missing") { + t.Fatalf("expected app index missing for web, got %v", err) + } + + // index.js present as a directory also counts as missing. + if err := os.MkdirAll(filepath.Join(distRoot, "apps", "web", "index.js"), 0o755); err != nil { + t.Fatalf("mkdir index.js dir: %v", err) + } + err = ValidateDistForTargets("application", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "app index missing") { + t.Fatalf("expected app index missing when index.js is a dir, got %v", err) + } +} + +func TestValidateDistForTargets_ApplicationMode_WebProtoMissing(t *testing.T) { + distRoot := t.TempDir() + for _, dir := range []string{ + filepath.Join(distRoot, "web"), + filepath.Join(distRoot, "apps", "web"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(distRoot, "apps", "web", "index.js"), []byte("// web"), 0o644); err != nil { + t.Fatalf("write web index: %v", err) + } + err := ValidateDistForTargets("application", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "api proto assets missing") { + t.Fatalf("expected api proto missing for web, got %v", err) + } + + // Proto path present as a file is not a proto dir. + protoFile := config.APIAppProtoDir(distRoot, "web") + if err := os.MkdirAll(filepath.Dir(protoFile), 0o755); err != nil { + t.Fatalf("mkdir api parent: %v", err) + } + if err := os.WriteFile(protoFile, []byte("not-a-dir"), 0o644); err != nil { + t.Fatalf("write proto path as file: %v", err) + } + err = ValidateDistForTargets("application", distRoot, []string{"web"}) + if err == nil || !strings.Contains(err.Error(), "api proto assets missing") { + t.Fatalf("expected api proto missing when proto path is a file, got %v", err) + } +} + func TestValidateDistForTargets_InvalidBundleMode(t *testing.T) { err := ValidateDistForTargets("broken", t.TempDir(), []string{"auth"}) if err == nil { @@ -270,6 +327,25 @@ func TestResolveDefaultTargetsFromDist_ApplicationMode_EnumeratesAppsAndWeb(t *t } } +func TestResolveDefaultTargetsFromDist_ApplicationMode_DoesNotDuplicateWeb(t *testing.T) { + distRoot := t.TempDir() + for _, dir := range []string{ + filepath.Join(distRoot, "apps", "web"), + filepath.Join(distRoot, "web"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + targets, err := resolveDefaultTargetsFromDist("application", distRoot) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if len(targets) != 1 || targets[0] != "web" { + t.Fatalf("unexpected targets: %#v, want [web] once", targets) + } +} + func TestResolveDefaultTargetsFromDist_ApplicationMode_ReadDirError(t *testing.T) { distRoot := t.TempDir() if err := os.WriteFile(filepath.Join(distRoot, "apps"), []byte("not a dir"), 0o644); err != nil { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index ad61138c..6652b5ac 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -918,6 +918,16 @@ func TestNewApplicationServiceResolvesPaths(t *testing.T) { webSvc.protoImportPaths[0] != webAPIProtoDir { t.Fatalf("unexpected web service paths: %#v", webSvc) } + + webAppMode, err := NewApplicationService(runtimeScope, "web", nil, WithBundleMode("application")) + if err != nil { + t.Fatalf("NewApplicationService(web, application) error = %v", err) + } + if webAppMode.appDistPath != filepath.Join(distDir, "web") || + webAppMode.scriptDistPath != filepath.Join(distDir, "apps", "web") || + webAppMode.protoRootDir != webAPIProtoDir { + t.Fatalf("unexpected web application-mode paths: %#v", webAppMode) + } } func TestBundleMode_ServiceDescs_LoadsOnlyTargetAppProto(t *testing.T) { @@ -1807,6 +1817,12 @@ func TestLoaderRegisterPath(t *testing.T) { if got := loaderRegisterPath("", "x.proto"); got != "x.proto" { t.Fatalf("empty app = %q", got) } + if got := loaderRegisterPath("web", ""); got != "" { + t.Fatalf("empty rel = %q, want empty", got) + } + if got := loaderRegisterPath("web", "."); got != "" { + t.Fatalf("dot rel = %q, want empty", got) + } } func TestServiceDescsRegistersLoaderAndSkipsTaskWorkerForWeb(t *testing.T) { From 17df0b18b4cf39091a3d70e3c624d91f7e42855e Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 18:37:55 +0800 Subject: [PATCH 8/9] test: tighten core-upgrade and path assertions from review - Assert core upgrade plan apps include web via listInstalledApps, not lease entry. - Check protoImportPaths for application-mode web ApplicationService. - Reuse commitStubSplitBuilder for ensureInjected codegen tests. Co-authored-by: Cursor --- .../lifecycle/ensure_injected_codegen_test.go | 32 +++---------------- .../module/lifecycle/installer_commit_test.go | 5 +++ .../lifecycle/module_index_sync_test.go | 22 ++++++++++--- internal/service/service_test.go | 4 ++- 4 files changed, 29 insertions(+), 34 deletions(-) diff --git a/internal/module/lifecycle/ensure_injected_codegen_test.go b/internal/module/lifecycle/ensure_injected_codegen_test.go index f77fe341..0cdc8752 100644 --- a/internal/module/lifecycle/ensure_injected_codegen_test.go +++ b/internal/module/lifecycle/ensure_injected_codegen_test.go @@ -12,36 +12,12 @@ import ( "testing" "github.com/choysum-dev/choysum/internal/module/artifact/pipeline" - moduleresult "github.com/choysum-dev/choysum/internal/module/artifact/result" modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/jsexecutor" "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" ) -type codegenStubSplitBuilder struct { - entrySeen string - buildErr error - persistErr error - persistCalls int -} - -func (b *codegenStubSplitBuilder) Build() (*moduleresult.BuildResult, error) { - return &moduleresult.BuildResult{}, nil -} - -func (b *codegenStubSplitBuilder) BuildWithoutPersist() (*moduleresult.BuildResult, error) { - if b.buildErr != nil { - return nil, b.buildErr - } - return &moduleresult.BuildResult{}, nil -} - -func (b *codegenStubSplitBuilder) Persist(result *moduleresult.BuildResult) error { - b.persistCalls++ - return b.persistErr -} - func TestEnsureInjectedAppModelsForCodegenEarlyReturns(t *testing.T) { db := newModuleIndexSyncDB(t) if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { @@ -128,7 +104,7 @@ func TestEnsureInjectedAppModelsForCodegenBuilderBranches(t *testing.T) { }) t.Run("build error", func(t *testing.T) { - stub := &codegenStubSplitBuilder{buildErr: errors.New("build boom")} + stub := &commitStubSplitBuilder{buildErr: errors.New("build boom")} newCodegenModuleBuilderFn = func(_ scope.Scope, _ jsexecutor.ScriptExecutor, _ *meta.Module, entry string) any { stub.entrySeen = entry return stub @@ -146,7 +122,7 @@ func TestEnsureInjectedAppModelsForCodegenBuilderBranches(t *testing.T) { }) t.Run("persist error", func(t *testing.T) { - stub := &codegenStubSplitBuilder{persistErr: errors.New("persist boom")} + stub := &commitStubSplitBuilder{persistErr: errors.New("persist boom")} newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { return stub } @@ -159,7 +135,7 @@ func TestEnsureInjectedAppModelsForCodegenBuilderBranches(t *testing.T) { }) t.Run("success", func(t *testing.T) { - stub := &codegenStubSplitBuilder{} + stub := &commitStubSplitBuilder{} newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { return stub } @@ -187,7 +163,7 @@ func TestGenerateAppToDirsPropagatesEnsureInjectedError(t *testing.T) { prev := newCodegenModuleBuilderFn newCodegenModuleBuilderFn = func(scope.Scope, jsexecutor.ScriptExecutor, *meta.Module, string) any { - return &codegenStubSplitBuilder{buildErr: errors.New("inject boom")} + return &commitStubSplitBuilder{buildErr: errors.New("inject boom")} } t.Cleanup(func() { newCodegenModuleBuilderFn = prev }) diff --git a/internal/module/lifecycle/installer_commit_test.go b/internal/module/lifecycle/installer_commit_test.go index b8d81bc7..e71ec216 100644 --- a/internal/module/lifecycle/installer_commit_test.go +++ b/internal/module/lifecycle/installer_commit_test.go @@ -27,6 +27,8 @@ func (commitStubBuilder) Build() (*moduleresult.BuildResult, error) { } type commitStubSplitBuilder struct { + entrySeen string + buildErr error persistCalls int persistErr error } @@ -36,6 +38,9 @@ func (b *commitStubSplitBuilder) Build() (*moduleresult.BuildResult, error) { } func (b *commitStubSplitBuilder) BuildWithoutPersist() (*moduleresult.BuildResult, error) { + if b.buildErr != nil { + return nil, b.buildErr + } return &moduleresult.BuildResult{}, nil } diff --git a/internal/module/lifecycle/module_index_sync_test.go b/internal/module/lifecycle/module_index_sync_test.go index 81961395..0529219c 100644 --- a/internal/module/lifecycle/module_index_sync_test.go +++ b/internal/module/lifecycle/module_index_sync_test.go @@ -4,6 +4,7 @@ package lifecycle import ( + "bytes" "context" "database/sql" "encoding/json" @@ -1246,7 +1247,9 @@ func TestModuleManagerUpgradeCoreUsesListInstalledApps(t *testing.T) { t.Fatalf("auto migrate meta entities: %v", err) } + var logBuf bytes.Buffer runtimeScope := newModuleIndexSyncScope(modulesPath, db) + runtimeScope.logger = slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelInfo})) runtimeScope.cfg.DistPath = distPath runtimeScope.cfg.TmpPath = tmpPath runtimeScope.cfg.DefaultChoysumPath = defaultChoysumPath @@ -1266,8 +1269,10 @@ func TestModuleManagerUpgradeCoreUsesListInstalledApps(t *testing.T) { WithOriginCoordinatorFactory(func(scope.Scope) OriginCoordinator { return coordinator }), ) manager.bootstrapOnce.Do(func() {}) - if err := os.MkdirAll(filepath.Join(modulesPath, "core"), 0o755); err != nil { - t.Fatalf("mkdir core module dir: %v", err) + for _, name := range []string{"core", "web"} { + if err := os.MkdirAll(filepath.Join(modulesPath, name), 0o755); err != nil { + t.Fatalf("mkdir %s module dir: %v", name, err) + } } for _, row := range []meta.Module{ @@ -1279,9 +1284,16 @@ func TestModuleManagerUpgradeCoreUsesListInstalledApps(t *testing.T) { } } - // Upgrade may fail later in staging for Ensure-only web; listInstalledApps must still run. + // Staging may still fail for Ensure-only web; the plan log is emitted after + // listInstalledApps overrides AffectedApps for core upgrades. _ = manager.Upgrade(context.Background(), "core") - if locker.acquired != 1 { - t.Fatalf("locker.acquired = %d, want 1 (upgrade entered lease past listInstalledApps)", locker.acquired) + + logs := logBuf.String() + if !strings.Contains(logs, `"msg":"module operation plan"`) { + t.Fatalf("expected module operation plan log, got %q", logs) + } + // listInstalledApps sorts apps; web must be present (old NonWeb helper excluded it). + if !strings.Contains(logs, `"apps":["core","web"]`) && !strings.Contains(logs, `"apps":["web","core"]`) { + t.Fatalf("expected plan apps to include web via listInstalledApps, got %q", logs) } } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 6652b5ac..da4497cd 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -925,7 +925,9 @@ func TestNewApplicationServiceResolvesPaths(t *testing.T) { } if webAppMode.appDistPath != filepath.Join(distDir, "web") || webAppMode.scriptDistPath != filepath.Join(distDir, "apps", "web") || - webAppMode.protoRootDir != webAPIProtoDir { + webAppMode.protoRootDir != webAPIProtoDir || + len(webAppMode.protoImportPaths) != 1 || + webAppMode.protoImportPaths[0] != webAPIProtoDir { t.Fatalf("unexpected web application-mode paths: %#v", webAppMode) } } From 8879d4825fda3392a345cdfa5fb333bed48728d1 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 19:00:20 +0800 Subject: [PATCH 9/9] test: move shared SplitBuilder stub out of installer commit tests - Keep entrySeen/buildErr on the package stub used by ensureInjected coverage. - Avoid looking like dead fields inside installer_commit_test.go alone. Co-authored-by: Cursor --- .../module/lifecycle/installer_commit_test.go | 23 ------------- .../lifecycle/split_builder_stub_test.go | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 internal/module/lifecycle/split_builder_stub_test.go diff --git a/internal/module/lifecycle/installer_commit_test.go b/internal/module/lifecycle/installer_commit_test.go index e71ec216..ce2bc91a 100644 --- a/internal/module/lifecycle/installer_commit_test.go +++ b/internal/module/lifecycle/installer_commit_test.go @@ -26,29 +26,6 @@ func (commitStubBuilder) Build() (*moduleresult.BuildResult, error) { return &moduleresult.BuildResult{}, nil } -type commitStubSplitBuilder struct { - entrySeen string - buildErr error - persistCalls int - persistErr error -} - -func (b *commitStubSplitBuilder) Build() (*moduleresult.BuildResult, error) { - return &moduleresult.BuildResult{}, nil -} - -func (b *commitStubSplitBuilder) BuildWithoutPersist() (*moduleresult.BuildResult, error) { - if b.buildErr != nil { - return nil, b.buildErr - } - return &moduleresult.BuildResult{}, nil -} - -func (b *commitStubSplitBuilder) Persist(result *moduleresult.BuildResult) error { - b.persistCalls++ - return b.persistErr -} - func TestCommitInstallSoftDeleteRestoreAndSave(t *testing.T) { runtimeScope := newLifecycleCommitTestScope(t) modulePath := t.TempDir() diff --git a/internal/module/lifecycle/split_builder_stub_test.go b/internal/module/lifecycle/split_builder_stub_test.go new file mode 100644 index 00000000..bd8e4819 --- /dev/null +++ b/internal/module/lifecycle/split_builder_stub_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import moduleresult "github.com/choysum-dev/choysum/internal/module/artifact/result" + +// commitStubSplitBuilder is a shared SplitBuilder stub for package lifecycle tests. +// entrySeen/buildErr are exercised by ensureInjected codegen tests; persist* by +// installer commit tests. +type commitStubSplitBuilder struct { + entrySeen string + buildErr error + persistCalls int + persistErr error +} + +func (b *commitStubSplitBuilder) Build() (*moduleresult.BuildResult, error) { + return &moduleresult.BuildResult{}, nil +} + +func (b *commitStubSplitBuilder) BuildWithoutPersist() (*moduleresult.BuildResult, error) { + if b.buildErr != nil { + return nil, b.buildErr + } + return &moduleresult.BuildResult{}, nil +} + +func (b *commitStubSplitBuilder) Persist(result *moduleresult.BuildResult) error { + b.persistCalls++ + return b.persistErr +}