diff --git a/.cursor/rules/module-data-seeds.mdc b/.cursor/rules/module-data-seeds.mdc new file mode 100644 index 000000000..c1335cf63 --- /dev/null +++ b/.cursor/rules/module-data-seeds.mdc @@ -0,0 +1,24 @@ +--- +description: Ownership rules for module bootstrap/demo data seeds (xml_id, cross-app RR) +globs: modules/**/data/**/*.json,modules/**/demo/**/*.json,modules/**/package.json +alwaysApply: false +--- + +# Module Data Seed Ownership + +When adding or moving records in `modules/*/data/*.json` or `modules/*/demo/*.json`: + +1. **Master / business rows** belong in the module that owns the model (e.g. `base.Company` → `modules/base/data`). +2. **Domain-targeted authz** (`RoleRecordRule` / `RoleFieldRule` / `RoleMethodAccess` scoped to a **domain model** via `MetaModelId` / `modelRef`, or a **domain-owned** logical name) belongs in that **domain module**. Use `application: "auth"`; `module` / xml_id stay under the applying module (example: `web/data/bootstrap.json` → `web.rrr_…` for `web.SavedFilter`). +3. **Platform roles, global break-glass, and platform LogicalModel default packs** stay in `modules/auth/data`: `base.user` / `sys.admin` / terminology role, global RR/FR/RMA/RUI, auth User/Token/Session packs, and cross-app logical defaults for `FieldDefault` / `AppSetting` / `TranslationTerm` (same registry as core’s `registerLogicalModelName`). +4. Rule (2) requires the seeding module to install **after** auth: it must `depends` on `auth`, and `auth` must **not** depend on it. Modules that auth already depends on (`base`, `meta`) cannot own authz seeds without a cycle — leave those app-level gift packs in auth (or use a future late-apply mechanism). +5. **Do not** pile new **domain-model** RR/RFR/RMA into `auth/data`. Prefer the SavedFilter pattern under the domain module’s `data/bootstrap.json`. Platform logical defaults (rule 3) are the exception and belong in auth. + +Do **not** add `web` to domain `depends`. The install/upgrade planner auto-includes the web SPA shell when a module declares `entryPoints.web` (opt out with CLI `--no-web`). + +## Shape reminders + +- `record.module` must equal the applying module (or be omitted). +- `record.application` may target another app’s model (cross-app seeding); omit to default to the owner app. +- `record.model` is the **short** name only (not `app.Model`). +- Prefer `modelRef` / `ref` / `refBy` for stable links; do not hardcode row ids. diff --git a/AGENTS.md b/AGENTS.md index 1f5685146..2b5a12ee0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ Modules live in the local `./modules` dir (auto-detected because cwd contains `modules/`). Install them before running; install is idempotent: ```bash -./choysum install core web base auth meta task partner +./choysum install core base task meta auth document web partner ./choysum run --config config.yaml # serves http://localhost:9527 ( / redirects to /web/ ) ``` @@ -39,10 +39,9 @@ Gotchas: registered. Using `development`/`production` fails with `scope factory not registered`. The built-in default is already `default`, so running with no config works too; `config.yaml` here just enables `hotReload`. -- Do **not** use `--with-demo` for `base`: its demo record hits - `NOT NULL constraint failed: base_company.timezone` and aborts the install. - Install without `--with-demo`; the non-demo bootstrap seed (roles + `admin` - user + `base.company_main`) is applied regardless. +- Bootstrap seeds (roles + `admin` + `base.company_main`) apply without + `--with-demo`. Avoid `--with-demo` for `base`: `base.company_demo` currently + omits required `CurrencyId` and aborts the install. - Default DB is embedded SQLite at `.choysum/choysum.sqlite`; no external DB needed. Postgres/MySQL and S3 document storage are optional. diff --git a/cmd/cmd_install.go b/cmd/cmd_install.go index 1f80d5a32..e6dc2820d 100644 --- a/cmd/cmd_install.go +++ b/cmd/cmd_install.go @@ -26,6 +26,7 @@ import ( func newInstallCmd(envGetter func() scope.Scope) *cobra.Command { var withDemo bool + var noWeb bool var cliCompatVersion string cmd := &cobra.Command{ Use: "install [...]", @@ -119,8 +120,9 @@ func newInstallCmd(envGetter func() scope.Scope) *cobra.Command { installScope.Logger().Debug("module install started", "input", rootInput) if installErr := lifecycle.InstallModule(ctx, installScope, compilerExecutor, lifecycle.InstallModuleRequest{ - Input: rootInput, - WithDemo: withDemo, + Input: rootInput, + WithDemo: withDemo, + SkipWebShell: noWeb, }); installErr != nil { return xfmt.Errorf("error installing module %s: %w", rootInput, installErr) } @@ -141,6 +143,7 @@ func newInstallCmd(envGetter func() scope.Scope) *cobra.Command { }, } cmd.Flags().BoolVar(&withDemo, "with-demo", false, "Load demo data declared by package.json") + cmd.Flags().BoolVar(&noWeb, "no-web", false, "Skip auto-installing the web SPA shell when a module declares entryPoints.web") cmd.Flags().StringVar(&cliCompatVersion, "cli-compat-version", "", "override CLI compatibility version for module compatibility checks") return cmd } diff --git a/cmd/cmd_upgrade.go b/cmd/cmd_upgrade.go index 6f2c38927..21ac88d26 100644 --- a/cmd/cmd_upgrade.go +++ b/cmd/cmd_upgrade.go @@ -23,6 +23,7 @@ import ( func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command { var withDemo bool + var noWeb bool var cliCompatVersion string cmd := &cobra.Command{ Use: "upgrade [...]", @@ -163,7 +164,7 @@ func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command { for _, plan := range plans { currentInput = plan.requestedInput upgradeScope.Logger().Debug("module upgrade started", "input", plan.resolvedInput) - if err := moduleLifecycle.Upgrade(ctx, lifecycle.UpgradeRequest{Input: plan.resolvedInput, WithDemo: withDemo}); err != nil { + if err := moduleLifecycle.Upgrade(ctx, lifecycle.UpgradeRequest{Input: plan.resolvedInput, WithDemo: withDemo, SkipWebShell: noWeb}); err != nil { _ = compilerExecutor.Stop() exitUpgradeError(currentInput, xfmt.Errorf("error upgrading module %s: %w", plan.requestedInput, err)) } @@ -172,6 +173,7 @@ func newUpgradeCmd(envGetter func() scope.Scope) *cobra.Command { }, } cmd.Flags().BoolVar(&withDemo, "with-demo", false, "Load demo data declared by package.json") + cmd.Flags().BoolVar(&noWeb, "no-web", false, "Skip auto-installing a missing web SPA shell when upgrading a module with entryPoints.web") cmd.Flags().StringVar(&cliCompatVersion, "cli-compat-version", "", "override CLI compatibility version for module compatibility checks") return cmd } diff --git a/internal/bootstrap/service/coordinator.go b/internal/bootstrap/service/coordinator.go index 5e1608efe..a7d0934ee 100644 --- a/internal/bootstrap/service/coordinator.go +++ b/internal/bootstrap/service/coordinator.go @@ -498,6 +498,49 @@ func (c *coordinator) withInstallTransaction( return err } +// applyMinimalInstallFetchProgress updates bootstrap stage detail/spinner for registry fetch. +// Empty moduleName becomes "module" so messages stay readable when origin omits the name. +func applyMinimalInstallFetchProgress( + markDetail func(detail string), + setMessage func(message string), + stage origincontract.FetchProgressStage, + moduleName string, +) { + moduleName = strings.TrimSpace(moduleName) + if moduleName == "" { + moduleName = "module" + } + switch stage { + case origincontract.FetchProgressStageDownload: + markDetail("downloading module package: " + moduleName + "...") + setMessage(fmt.Sprintf("%s: downloading from registry...", moduleName)) + case origincontract.FetchProgressStageVerify: + markDetail("verifying module package integrity: " + moduleName + "...") + setMessage(fmt.Sprintf("%s: verifying package...", moduleName)) + case origincontract.FetchProgressStageExtract: + markDetail("extracting module package: " + moduleName + "...") + setMessage(fmt.Sprintf("%s: extracting package...", moduleName)) + default: + // Keep existing stage detail if unknown progress stage is received. + } +} + +// bindMinimalInstallFetchProgressReporter adapts stage detail/spinner setters to an origin fetch reporter. +func bindMinimalInstallFetchProgressReporter( + markDetail func(detail string), + setMessage func(message string), +) func(stage origincontract.FetchProgressStage, moduleName string) { + return func(stage origincontract.FetchProgressStage, moduleName string) { + applyMinimalInstallFetchProgress(markDetail, setMessage, stage, moduleName) + } +} + +// installMinimalModulesFn is the InstallModule entry used by bootstrap minimal install (overridable in tests). +var installMinimalModulesFn = lifecycle.InstallModule + +// newMinimalInstallExecutor builds the JS executor for bootstrap minimal install (overridable in tests). +var newMinimalInstallExecutor = jsexecutor.NewCompilerExecutor + func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operationID string) error { if c.runtimeScope == nil { return newBootstrapError(bootstrapErrCodeRuntimePrepare, "scope is not available", nil) @@ -523,35 +566,21 @@ func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operatio updateFetchProgressMessage := func(message string) { spinnerTicker.SetMessage(message) } + markFetchDetail := c.minimalInstallFetchDetailMarker(operationID) - installCtx = origincontract.WithFetchProgressReporter(installCtx, func(stage origincontract.FetchProgressStage, moduleName string) { - moduleName = strings.TrimSpace(moduleName) - if moduleName == "" { - moduleName = "core module" - } - switch stage { - case origincontract.FetchProgressStageDownload: - c.store.markStageDetail(operationID, "downloading module package: "+moduleName+"...") - updateFetchProgressMessage(fmt.Sprintf("%s: downloading from registry...", moduleName)) - case origincontract.FetchProgressStageVerify: - c.store.markStageDetail(operationID, "verifying module package integrity: "+moduleName+"...") - updateFetchProgressMessage(fmt.Sprintf("%s: verifying package...", moduleName)) - case origincontract.FetchProgressStageExtract: - c.store.markStageDetail(operationID, "extracting module package: "+moduleName+"...") - updateFetchProgressMessage(fmt.Sprintf("%s: extracting package...", moduleName)) - default: - // Keep existing stage detail if unknown progress stage is received. - } - }) + installCtx = origincontract.WithFetchProgressReporter( + installCtx, + bindMinimalInstallFetchProgressReporter(markFetchDetail, updateFetchProgressMessage), + ) - c.store.markStageDetail(operationID, "resolving core module installation plan...") - spinnerTicker.SetMessage("document: preparing metadata tables") + c.store.markStageDetail(operationID, "resolving meta module installation plan...") + spinnerTicker.SetMessage("meta: preparing module installation...") installScope := c.runtimeScope.WithContext(installCtx) if installScope == nil { installScope = c.runtimeScope } - executor, err := jsexecutor.NewCompilerExecutor(installScope) + executor, err := newMinimalInstallExecutor(installScope) if err != nil { return c.classifyModuleInstallError(progress, installTimeout, err) } @@ -560,28 +589,54 @@ func (c *coordinator) defaultInstallMinimalModules(ctx context.Context, operatio } defer executor.Stop() - installErr := lifecycle.InstallModule(installCtx, installScope, executor, lifecycle.InstallModuleRequest{ - Input: "document", + return runMinimalMetaModuleInstall( + installCtx, + installScope, + executor, + installMinimalModulesFn, + func() { c.store.markStageDetail(operationID, "meta module installation completed") }, + func(installErr error) error { + return c.classifyModuleInstallError(progress, installTimeout, installErr) + }, + ) +} + +// minimalInstallFetchDetailMarker returns the stage-detail setter used by fetch progress reporting. +func (c *coordinator) minimalInstallFetchDetailMarker(operationID string) func(detail string) { + return func(detail string) { + c.store.markStageDetail(operationID, detail) + } +} + +// runMinimalMetaModuleInstall installs meta and marks the completed stage (testable without full bootstrap). +func runMinimalMetaModuleInstall( + installCtx context.Context, + installScope scope.Scope, + executor jsexecutor.ScriptExecutor, + installFn func(context.Context, scope.Scope, jsexecutor.ScriptExecutor, lifecycle.InstallModuleRequest, ...lifecycle.Option) error, + markCompleted func(), + classify func(error) error, +) error { + installErr := installFn(installCtx, installScope, executor, lifecycle.InstallModuleRequest{ + Input: "meta", WithDemo: false, }) if installErr != nil { - return c.classifyModuleInstallError(progress, installTimeout, installErr) + return classify(installErr) } - - c.store.markStageDetail(operationID, "core module installation completed") - + markCompleted() return nil } func (c *coordinator) classifyModuleInstallError(progress *logger.ProgressLine, installTimeout time.Duration, installErr error) error { if progress != nil { - progress.Done("✗", "core module installation failed") + progress.Done("✗", "meta module installation failed") } if errors.Is(installErr, context.DeadlineExceeded) { return newBootstrapError( bootstrapErrCodeModuleInstallTimeout, "module installation timed out after "+installTimeout.String()+". "+ - "Check your network connection or place the required modules (document and its dependencies) in ModulesPath.", + "Check your network connection or place the required modules (meta, the web shell, and their dependencies) in ModulesPath.", installErr, ) } @@ -649,14 +704,12 @@ func (c *coordinator) defaultUpdateAdminAndMarker(ctx context.Context, input ini } var model meta.Model - lookedUp, err := modmeta.LookupEffectiveModel(txScope.Session().DB, "auth", "User") - if err != nil { - if modmeta.IsEffectiveModelNotFound(err) || errors.Is(err, gorm.ErrRecordNotFound) { + if err := txScope.Session().DB.Where("application = ? AND name = ?", "auth", "User").First(&model).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { return errBootstrapAdminModelNotFound } return err } - model = *lookedUp if strings.TrimSpace(model.ModelTable) == "" { return errBootstrapAdminModelTableMissing diff --git a/internal/bootstrap/service/coordinator_minimal_install_test.go b/internal/bootstrap/service/coordinator_minimal_install_test.go new file mode 100644 index 000000000..a88ccddf5 --- /dev/null +++ b/internal/bootstrap/service/coordinator_minimal_install_test.go @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package service + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/choysum-dev/choysum/internal/logger" + "github.com/choysum-dev/choysum/internal/module/lifecycle" + origincontract "github.com/choysum-dev/choysum/internal/module/origin/contract" + "github.com/choysum-dev/choysum/pkg/jsengine" + "github.com/choysum-dev/choysum/pkg/jsexecutor" + "github.com/choysum-dev/choysum/pkg/scope" +) + +func TestClassifyModuleInstallErrorMetaMessages(t *testing.T) { + c, _ := newFreshnessTestCoordinator(t) + progress := logger.NewProgressLine(io.Discard) + + err := c.classifyModuleInstallError(progress, time.Minute, context.DeadlineExceeded) + if err == nil { + t.Fatal("expected timeout error") + } + if got := bootstrapErrorCode(err); got != bootstrapErrCodeModuleInstallTimeout { + t.Fatalf("code=%q, want timeout", got) + } + if !strings.Contains(err.Error(), "meta, the web shell, and their dependencies") { + t.Fatalf("error=%q, want meta/web shell hint", err.Error()) + } + + err = c.classifyModuleInstallError(nil, time.Second, errors.New("boom")) + if err == nil || bootstrapErrorCode(err) != bootstrapErrCodeRuntimePrepare { + t.Fatalf("error=%v", err) + } +} + +func TestDefaultInstallMinimalModulesMarksMetaStage(t *testing.T) { + c, _ := newFreshnessTestCoordinator(t) + store := newMemoryStatusStore(time.Now) + c.store = store + opID := "op-meta-install" + store.beginOperation(opID, "", 0) + + prevExec := newMinimalInstallExecutor + prevInstall := installMinimalModulesFn + t.Cleanup(func() { + newMinimalInstallExecutor = prevExec + installMinimalModulesFn = prevInstall + }) + newMinimalInstallExecutor = func(runtimeScope scope.Scope, opts ...jsexecutor.Option) (jsexecutor.JsExecutor, error) { + return &noopMinimalInstallExecutor{}, nil + } + installMinimalModulesFn = func(ctx context.Context, runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, req lifecycle.InstallModuleRequest, opts ...lifecycle.Option) error { + return errors.New("forced meta install failure") + } + + err := c.defaultInstallMinimalModules(context.Background(), opID) + if err == nil { + t.Fatal("expected install failure from stub") + } + snap, ok := store.getOperation(opID) + if !ok { + t.Fatal("expected operation snapshot") + } + if !strings.Contains(snap.StageDetail, "meta module") { + t.Fatalf("StageDetail=%q, want meta module stage text", snap.StageDetail) + } +} + +func TestApplyMinimalInstallFetchProgressEmptyModuleName(t *testing.T) { + var details []string + var messages []string + markDetail := func(detail string) { details = append(details, detail) } + setMessage := func(message string) { messages = append(messages, message) } + + applyMinimalInstallFetchProgress(markDetail, setMessage, origincontract.FetchProgressStageDownload, " ") + applyMinimalInstallFetchProgress(markDetail, setMessage, origincontract.FetchProgressStageVerify, "") + applyMinimalInstallFetchProgress(markDetail, setMessage, origincontract.FetchProgressStageExtract, "partner") + applyMinimalInstallFetchProgress(markDetail, setMessage, origincontract.FetchProgressStage("other"), "x") + + if len(details) != 3 || len(messages) != 3 { + t.Fatalf("details=%v messages=%v, want 3 known stages", details, messages) + } + if !strings.Contains(details[0], "module") || strings.Contains(details[0], "core module") { + t.Fatalf("download detail=%q, want empty name → module", details[0]) + } + if !strings.HasPrefix(messages[0], "module:") { + t.Fatalf("download message=%q", messages[0]) + } + if !strings.Contains(details[1], "module") { + t.Fatalf("verify detail=%q", details[1]) + } + if !strings.Contains(details[2], "partner") || !strings.HasPrefix(messages[2], "partner:") { + t.Fatalf("extract detail=%q message=%q", details[2], messages[2]) + } +} + +func TestBindMinimalInstallFetchProgressReporter(t *testing.T) { + var details []string + var messages []string + reporter := bindMinimalInstallFetchProgressReporter( + func(detail string) { details = append(details, detail) }, + func(message string) { messages = append(messages, message) }, + ) + reporter(origincontract.FetchProgressStageDownload, "") + if len(details) != 1 || !strings.Contains(details[0], "module") { + t.Fatalf("details=%v", details) + } + if len(messages) != 1 || !strings.HasPrefix(messages[0], "module:") { + t.Fatalf("messages=%v", messages) + } +} + +func TestMinimalInstallFetchDetailMarker(t *testing.T) { + c, _ := newFreshnessTestCoordinator(t) + store := newMemoryStatusStore(time.Now) + c.store = store + opID := "op-fetch-detail" + store.beginOperation(opID, "", 0) + marker := c.minimalInstallFetchDetailMarker(opID) + marker("downloading module package: module...") + snap, ok := store.getOperation(opID) + if !ok || snap.StageDetail != "downloading module package: module..." { + t.Fatalf("snap=%+v ok=%v", snap, ok) + } +} + +func TestDefaultInstallMinimalModulesWithFakeExecutor(t *testing.T) { + c, _ := newFreshnessTestCoordinator(t) + store := newMemoryStatusStore(time.Now) + c.store = store + opID := "op-meta-ok" + store.beginOperation(opID, "", 0) + + prevExec := newMinimalInstallExecutor + prevInstall := installMinimalModulesFn + t.Cleanup(func() { + newMinimalInstallExecutor = prevExec + installMinimalModulesFn = prevInstall + }) + newMinimalInstallExecutor = func(runtimeScope scope.Scope, opts ...jsexecutor.Option) (jsexecutor.JsExecutor, error) { + return &noopMinimalInstallExecutor{}, nil + } + installMinimalModulesFn = func(ctx context.Context, runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, req lifecycle.InstallModuleRequest, opts ...lifecycle.Option) error { + return nil + } + + if err := c.defaultInstallMinimalModules(context.Background(), opID); err != nil { + t.Fatalf("defaultInstallMinimalModules: %v", err) + } + snap, ok := store.getOperation(opID) + if !ok || snap.StageDetail != "meta module installation completed" { + t.Fatalf("StageDetail=%q ok=%v", snap.StageDetail, ok) + } +} + +func TestDefaultInstallMinimalModulesClassifiesInstallError(t *testing.T) { + c, _ := newFreshnessTestCoordinator(t) + store := newMemoryStatusStore(time.Now) + c.store = store + opID := "op-meta-fail" + store.beginOperation(opID, "", 0) + + prevExec := newMinimalInstallExecutor + prevInstall := installMinimalModulesFn + t.Cleanup(func() { + newMinimalInstallExecutor = prevExec + installMinimalModulesFn = prevInstall + }) + newMinimalInstallExecutor = func(runtimeScope scope.Scope, opts ...jsexecutor.Option) (jsexecutor.JsExecutor, error) { + return &noopMinimalInstallExecutor{}, nil + } + installMinimalModulesFn = func(ctx context.Context, runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, req lifecycle.InstallModuleRequest, opts ...lifecycle.Option) error { + return errors.New("meta install exploded") + } + + err := c.defaultInstallMinimalModules(context.Background(), opID) + if err == nil || !strings.Contains(err.Error(), "failed to install required system components") { + t.Fatalf("err=%v", err) + } +} + +type noopMinimalInstallExecutor struct{} + +func (e *noopMinimalInstallExecutor) Execute(context.Context, *jsengine.JsRequest) (*jsengine.JsResponse, error) { + return &jsengine.JsResponse{}, nil +} +func (e *noopMinimalInstallExecutor) GetJsScripts() []*jsengine.JsScript { return nil } +func (e *noopMinimalInstallExecutor) SetJsScripts(scripts []*jsengine.JsScript) {} +func (e *noopMinimalInstallExecutor) AppendJsScripts(scripts ...*jsengine.JsScript) {} +func (e *noopMinimalInstallExecutor) Reload(scripts ...*jsengine.JsScript) error { return nil } +func (e *noopMinimalInstallExecutor) Start() error { return nil } +func (e *noopMinimalInstallExecutor) Stop() error { return nil } + +func TestRunMinimalMetaModuleInstall(t *testing.T) { + t.Run("success_marks_completed", func(t *testing.T) { + var completed bool + var sawReq lifecycle.InstallModuleRequest + err := runMinimalMetaModuleInstall( + context.Background(), + nil, + nil, + func(ctx context.Context, runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, req lifecycle.InstallModuleRequest, opts ...lifecycle.Option) error { + sawReq = req + return nil + }, + func() { completed = true }, + func(error) error { t.Fatal("classify should not run"); return nil }, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + if !completed { + t.Fatal("expected markCompleted") + } + if sawReq.Input != "meta" || sawReq.WithDemo { + t.Fatalf("req=%+v", sawReq) + } + }) + t.Run("failure_classifies", func(t *testing.T) { + err := runMinimalMetaModuleInstall( + context.Background(), + nil, + nil, + func(ctx context.Context, runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, req lifecycle.InstallModuleRequest, opts ...lifecycle.Option) error { + return errors.New("install boom") + }, + func() { t.Fatal("markCompleted should not run") }, + func(installErr error) error { + return errors.New("classified: " + installErr.Error()) + }, + ) + if err == nil || err.Error() != "classified: install boom" { + t.Fatalf("err=%v", err) + } + }) +} diff --git a/internal/module/artifact/pipeline/pipeline.go b/internal/module/artifact/pipeline/pipeline.go index 33b854903..5aeb90af7 100644 --- a/internal/module/artifact/pipeline/pipeline.go +++ b/internal/module/artifact/pipeline/pipeline.go @@ -1062,6 +1062,45 @@ func Execute(ctx context.Context, plan planner.Plan, root *meta.Module, cb Callb if cb.Upgrade == nil { return fmt.Errorf("Upgrade callback is required for upgrade") } + // EnsureOrder installs missing prerequisites (e.g. web shell) before upgrade. + if len(plan.EnsureOrder) > 0 { + if cb.ResolveInstallModuleFromOrigin == nil { + return fmt.Errorf("ResolveInstallModuleFromOrigin callback is required when plan.EnsureOrder is non-empty") + } + if cb.Install == nil { + return fmt.Errorf("Install callback is required when plan.EnsureOrder is non-empty") + } + ensureStarted := time.Now() + emitProgress(ProgressEvent{Stage: ProgressStageModuleInstallStarted, Module: "ensure", Total: len(plan.EnsureOrder)}) + for index, name := range plan.EnsureOrder { + if err := checkCtx(); err != nil { + return err + } + mod, err := cb.ResolveInstallModuleFromOrigin(ctx, name) + if err != nil { + return fmt.Errorf("resolve ensure module from origin %s: %w", name, err) + } + if mod == nil { + continue + } + moduleName := strings.TrimSpace(mod.Name) + if moduleName == "" { + moduleName = strings.TrimSpace(name) + } + installStarted := time.Now() + emitProgress(ProgressEvent{Stage: ProgressStageModuleInstallStarted, Current: index + 1, Total: len(plan.EnsureOrder), Module: moduleName}) + if err := cb.Install(mod); err != nil { + emitProgress(ProgressEvent{Stage: ProgressStageModuleInstallFailed, Current: index + 1, Total: len(plan.EnsureOrder), Module: moduleName, Duration: time.Since(installStarted), Err: err}) + return err + } + emitProgress(ProgressEvent{Stage: ProgressStageModuleInstallCompleted, Current: index + 1, Total: len(plan.EnsureOrder), Module: moduleName, Duration: time.Since(installStarted)}) + logStep(slog.LevelInfo, "ensure module installed", + "installed_module", mod.Name, + "duration_ms", time.Since(installStarted).Milliseconds(), + ) + } + logStep(slog.LevelInfo, "upgrade ensure order completed", "duration_ms", time.Since(ensureStarted).Milliseconds(), "count", len(plan.EnsureOrder)) + } moduleStageStarted := logModuleStageStarted() totalModules := len(plan.ModuleOrder) for index, name := range plan.ModuleOrder { diff --git a/internal/module/artifact/pipeline/pipeline_test.go b/internal/module/artifact/pipeline/pipeline_test.go index 15a40ad53..e5aa6b14b 100644 --- a/internal/module/artifact/pipeline/pipeline_test.go +++ b/internal/module/artifact/pipeline/pipeline_test.go @@ -2302,3 +2302,183 @@ func TestSummarizeInfoNames(t *testing.T) { } }) } + +func TestExecuteUpgradeRequiresResolveInstalledModule(t *testing.T) { + root := &meta.Module{Name: "partner"} + ctx := staging.WithTmpRoot(context.Background(), t.TempDir()) + err := Execute(ctx, planner.Plan{Op: planner.OpUpgrade, ModuleOrder: []string{"partner"}}, root, Callbacks{ + Upgrade: func(_ *meta.Module) error { return nil }, + }) + if err == nil || err.Error() != "ResolveInstalledModule callback is required for upgrade" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteUpgradeEnsureOrderRequiresCallbacks(t *testing.T) { + root := &meta.Module{Name: "partner"} + plan := planner.Plan{ + Op: planner.OpUpgrade, + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"web"}, + } + ctx := staging.WithTmpRoot(context.Background(), t.TempDir()) + resolveInstalled := func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil } + + if err := Execute(ctx, plan, root, Callbacks{ + ResolveInstalledModule: resolveInstalled, + Upgrade: func(_ *meta.Module) error { return nil }, + }); err == nil || err.Error() != "ResolveInstallModuleFromOrigin callback is required when plan.EnsureOrder is non-empty" { + t.Fatalf("unexpected error: %v", err) + } + + if err := Execute(ctx, plan, root, Callbacks{ + ResolveInstalledModule: resolveInstalled, + Upgrade: func(_ *meta.Module) error { return nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + }); err == nil || err.Error() != "Install callback is required when plan.EnsureOrder is non-empty" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteUpgradeEnsureOrderInstallsThenUpgrades(t *testing.T) { + root := &meta.Module{Name: "partner"} + plan := planner.Plan{ + Op: planner.OpUpgrade, + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"auth", "web", "skip-nil"}, + } + var installed []string + var upgraded []string + var stages []string + + err := Execute(staging.WithTmpRoot(context.Background(), t.TempDir()), plan, root, Callbacks{ + OnProgress: func(event ProgressEvent) { + stages = append(stages, string(event.Stage)+":"+event.Module) + }, + ResolveInstalledModule: func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, name string) (*meta.Module, error) { + if name == "skip-nil" { + return nil, nil + } + if name == "web" { + return &meta.Module{Name: ""}, nil // empty Name falls back to ensure key + } + return &meta.Module{Name: name}, nil + }, + Install: func(module *meta.Module) error { + name := strings.TrimSpace(module.Name) + if name == "" { + name = "web" + } + installed = append(installed, name) + return nil + }, + Upgrade: func(module *meta.Module) error { + upgraded = append(upgraded, module.Name) + return nil + }, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if len(installed) != 2 || installed[0] != "auth" || installed[1] != "web" { + t.Fatalf("installed=%v, want [auth web]", installed) + } + if len(upgraded) != 1 || upgraded[0] != "partner" { + t.Fatalf("upgraded=%v, want [partner]", upgraded) + } + joined := strings.Join(stages, ",") + if !strings.Contains(joined, string(ProgressStageModuleInstallStarted)+":ensure") { + t.Fatalf("expected ensure install started stage, got %v", stages) + } + if !strings.Contains(joined, string(ProgressStageModuleInstallCompleted)+":auth") { + t.Fatalf("expected auth install completed, got %v", stages) + } +} + +func TestExecuteUpgradeEnsureOrderResolveAndInstallErrors(t *testing.T) { + root := &meta.Module{Name: "partner"} + plan := planner.Plan{ + Op: planner.OpUpgrade, + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"web"}, + } + ctx := staging.WithTmpRoot(context.Background(), t.TempDir()) + + if err := Execute(ctx, plan, root, Callbacks{ + ResolveInstalledModule: func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + Upgrade: func(_ *meta.Module) error { return nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, _ string) (*meta.Module, error) { + return nil, errors.New("resolve failed") + }, + Install: func(_ *meta.Module) error { return nil }, + }); err == nil || !strings.Contains(err.Error(), "resolve ensure module from origin web") { + t.Fatalf("unexpected resolve error: %v", err) + } + + var sawFailed bool + if err := Execute(ctx, plan, root, Callbacks{ + OnProgress: func(event ProgressEvent) { + if event.Stage == ProgressStageModuleInstallFailed { + sawFailed = true + } + }, + ResolveInstalledModule: func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + Upgrade: func(_ *meta.Module) error { return nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, name string) (*meta.Module, error) { + return &meta.Module{Name: name}, nil + }, + Install: func(_ *meta.Module) error { return errors.New("install failed") }, + }); err == nil || err.Error() != "install failed" { + t.Fatalf("unexpected install error: %v", err) + } + if !sawFailed { + t.Fatal("expected ProgressStageModuleInstallFailed") + } +} + +func TestExecuteUpgradeEnsureOrderCanceled(t *testing.T) { + root := &meta.Module{Name: "partner"} + plan := planner.Plan{ + Op: planner.OpUpgrade, + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"web"}, + } + ctx, cancel := context.WithCancel(staging.WithTmpRoot(context.Background(), t.TempDir())) + cancel() + err := Execute(ctx, plan, root, Callbacks{ + ResolveInstalledModule: func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + Upgrade: func(_ *meta.Module) error { return nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, name string) (*meta.Module, error) { + return &meta.Module{Name: name}, nil + }, + Install: func(_ *meta.Module) error { return nil }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled, got %v", err) + } +} + +func TestExecuteUpgradeCanceledAfterEnsureOrder(t *testing.T) { + root := &meta.Module{Name: "partner"} + plan := planner.Plan{ + Op: planner.OpUpgrade, + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"web"}, + } + ctx, cancel := context.WithCancel(staging.WithTmpRoot(context.Background(), t.TempDir())) + err := Execute(ctx, plan, root, Callbacks{ + ResolveInstalledModule: func(name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, + Upgrade: func(_ *meta.Module) error { return nil }, + ResolveInstallModuleFromOrigin: func(_ context.Context, name string) (*meta.Module, error) { + return &meta.Module{Name: name}, nil + }, + Install: func(_ *meta.Module) error { + cancel() // cancel after ensure install, before ModuleOrder upgrade loop + return nil + }, + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected canceled after ensure, got %v", err) + } +} diff --git a/internal/module/evolution/data/loader.go b/internal/module/evolution/data/loader.go index 079fe9f67..40c8a6129 100644 --- a/internal/module/evolution/data/loader.go +++ b/internal/module/evolution/data/loader.go @@ -210,7 +210,8 @@ func dependencyClosure(tx *gorm.DB, ownerID string, idToName map[string]string) // normalizeRecordOwnership applies E12 defaults and ownership rules in place. // - Empty module → applying owner module; non-empty must equal owner (no foreign xml_id namespace). -// - Empty application → owner's application; non-empty must equal owner app (no cross-app seeding). +// - Empty application → owner's application; non-empty may target another app's model +// (cross-app seeding; xml_id stays under the applying module). func normalizeRecordOwnership(rules *moduleRules, filePath string, recordIndex int, rec *record) error { if rules == nil || rec == nil { return xfmt.Errorf("nil module rules or record") @@ -230,13 +231,8 @@ func normalizeRecordOwnership(rules *moduleRules, filePath string, recordIndex i app := strings.TrimSpace(rec.Application) if app == "" { rec.Application = rules.OwnerApp - } else if app != rules.OwnerApp { - return &LoadError{ - Kind: LoadErrorKindValidation, Code: LoadErrorCodeApplicationMismatch, - FilePath: filePath, RecordIndex: recordIndex, Module: strings.TrimSpace(rec.Module), Name: localName, - Application: app, Model: strings.TrimSpace(rec.Model), - Message: "record.application must equal applying module application (or be omitted); cross-app seeding is forbidden", - } + } else { + rec.Application = app } return nil } @@ -325,7 +321,6 @@ const ( LoadErrorCodeMissingModel = "missing_model" LoadErrorCodeInvalidModel = "invalid_model" LoadErrorCodeModuleNotOwner = "module_not_owner" - LoadErrorCodeApplicationMismatch = "application_mismatch" LoadErrorCodeMissingValues = "missing_values" LoadErrorCodeDuplicateNameInInput = "duplicate_name_in_input" LoadErrorCodeInvalidRef = "invalid_ref" @@ -1231,8 +1226,8 @@ func (l *Loader) applyRecord(tx *gorm.DB, filePath string, recordIndex int, rec return err } modelFull := app + "." + modelName - model, err := modmeta.LookupEffectiveModel(tx, app, modelName) - if err != nil { + model := &meta.Model{} + if err := tx.Where("application = ? AND name = ?", app, modelName).First(model).Error; err != nil { return wrapLoadErrorWithCode(xfmt.Errorf("resolve model %s: %w", modelFull, err), filePath, recordIndex, rec, LoadErrorKindDB, LoadErrorCodeDBResolveModel, "resolve model") } if strings.TrimSpace(model.ModelTable) == "" { @@ -1875,8 +1870,8 @@ func resolveSearchModel(tx *gorm.DB, modelFull string) (*meta.Model, string, err if err != nil { return nil, "", xfmt.Errorf("resolve search model %s: %w", modelFull, err) } - model, err := modmeta.LookupEffectiveModel(tx, app, modelName) - if err != nil { + model := &meta.Model{} + if err := tx.Where("application = ? AND name = ?", app, modelName).First(model).Error; err != nil { return nil, "", xfmt.Errorf("resolve search model %s: %w", modelFull, err) } tableName := strings.TrimSpace(model.ModelTable) @@ -2130,8 +2125,8 @@ func (l *Loader) detectFieldCardinality(tx *gorm.DB, modelFull string, fieldName l.mu.RUnlock() if !ok { - model, err := modmeta.LookupEffectiveModel(tx, app, modelName) - if err != nil { + var model meta.Model + if err := tx.Where("application = ? AND name = ?", app, modelName).First(&model).Error; err != nil { l.mu.Lock() l.fieldCardinalityCache[cacheKey] = refCardinalityManyToOne l.mu.Unlock() @@ -2207,8 +2202,8 @@ func (l *Loader) resolveModelRef(tx *gorm.DB, modelRef string) (string, error) { if err != nil { return "", xfmt.Errorf("resolve modelRef %s: %w", modelRef, err) } - model, err := modmeta.LookupEffectiveModel(tx, app, modelName) - if err != nil { + var model meta.Model + if err := tx.Where("application = ? AND name = ?", app, modelName).First(&model).Error; err != nil { return "", xfmt.Errorf("resolve modelRef %s: %w", modelRef, err) } id := strings.TrimSpace(model.Id.String) diff --git a/internal/module/evolution/data/loader_test.go b/internal/module/evolution/data/loader_test.go index 280692752..d0fbf3264 100644 --- a/internal/module/evolution/data/loader_test.go +++ b/internal/module/evolution/data/loader_test.go @@ -438,9 +438,9 @@ func seedLoaderTestSchema(t *testing.T, db *gorm.DB) { func loaderTestModelID(t *testing.T, db *gorm.DB, app, name string) string { t.Helper() - m, err := modmeta.LookupEffectiveModel(db, app, name) - if err != nil { - t.Fatalf("lookup effective meta_model %s.%s: %v", app, name, err) + var m meta.Model + if err := db.Where("application = ? AND name = ?", app, name).First(&m).Error; err != nil { + t.Fatalf("lookup meta_model %s.%s: %v", app, name, err) } if !m.Id.Valid || m.Id.String == "" { t.Fatalf("meta_model %s.%s has empty id", app, name) @@ -742,7 +742,6 @@ func TestPlanRecordOrder_GuardsAndValidationErrors(t *testing.T) { }{ {name: "missing name", rec: record{Module: "auth", Application: "auth", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeMissingName}, {name: "module not owner", rec: record{Module: "base", Name: "x", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeModuleNotOwner}, - {name: "application mismatch", rec: record{Name: "x", Application: "base", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeApplicationMismatch}, {name: "missing model", rec: record{Module: "auth", Name: "x", Application: "auth", Values: map[string]any{}}, code: LoadErrorCodeMissingModel}, {name: "missing values", rec: record{Module: "auth", Name: "x", Application: "auth", Model: "User"}, code: LoadErrorCodeMissingValues}, {name: "invalid model full name", rec: record{Module: "auth", Name: "x", Application: "auth", Model: "auth.User", Values: map[string]any{}}, code: LoadErrorCodeInvalidModel}, @@ -1428,32 +1427,27 @@ func TestApplyModule_ForeignModuleNamespaceIsRejected(t *testing.T) { } } -func TestApplyModule_CrossAppApplicationIsRejected(t *testing.T) { - l, _ := newTestLoader(t) +func TestApplyModule_CrossAppApplicationIsAllowed(t *testing.T) { + l, db := newTestLoader(t) dir := t.TempDir() writeDataFile(t, dir, map[string]any{ "records": []any{ - map[string]any{"name": "x", "application": "base", "model": "User", "values": map[string]any{}}, + // web-owned xml_id seeding into auth.User (cross-app application). + map[string]any{"name": "cross_app_user", "application": "auth", "model": "User", "values": map[string]any{}}, }, }) - mod := moduleWithDataFile(t, dir) + mod := moduleWithDataFileNamed(t, dir, "web") + mod.ApplicationStr = "web" - err := l.ApplyModule(context.Background(), mod, ApplyOptions{}) - if err == nil { - t.Fatalf("expected error") - } - var le *LoadError - if !errors.As(err, &le) { - t.Fatalf("expected LoadError, got %T: %v", err, err) - } - if le.Kind != LoadErrorKindValidation { - t.Fatalf("expected Kind=%q, got %q", LoadErrorKindValidation, le.Kind) + if err := l.ApplyModule(context.Background(), mod, ApplyOptions{}); err != nil { + t.Fatalf("ApplyModule() error = %v", err) } - if le.Code != LoadErrorCodeApplicationMismatch { - t.Fatalf("expected Code=%q, got %q", LoadErrorCodeApplicationMismatch, le.Code) + var mapping modmeta.ModelData + if err := db.Where("module = ? AND name = ?", "web", "cross_app_user").First(&mapping).Error; err != nil { + t.Fatalf("lookup model_data: %v", err) } - if le.RecordIndex != 0 { - t.Fatalf("expected RecordIndex=0, got %d", le.RecordIndex) + if mapping.Application != "auth" || mapping.ModelName != "User" { + t.Fatalf("expected application=auth model=User, got application=%q model=%q", mapping.Application, mapping.ModelName) } } @@ -3293,6 +3287,30 @@ func TestNormalizeRecordOwnership_NilGuardsAndDefaults(t *testing.T) { } } +func TestNormalizeRecordOwnership_CrossAppApplicationPreserved(t *testing.T) { + t.Parallel() + rules := &moduleRules{OwnerName: "web", OwnerApp: "web"} + rec := record{Name: "rrr_x", Application: "auth", Model: "RoleRecordRule", Values: map[string]any{}} + if err := normalizeRecordOwnership(rules, "/tmp/data.json", 0, &rec); err != nil { + t.Fatalf("normalizeRecordOwnership() error = %v", err) + } + if rec.Module != "web" || rec.Application != "auth" { + t.Fatalf("expected module=web application=auth, got module=%q application=%q", rec.Module, rec.Application) + } +} + +func TestNormalizeRecordOwnership_TrimsExplicitApplication(t *testing.T) { + t.Parallel() + rules := &moduleRules{OwnerName: "web", OwnerApp: "web"} + rec := record{Name: "rrr_x", Application: " auth ", Model: "RoleRecordRule", Values: map[string]any{}} + if err := normalizeRecordOwnership(rules, "/tmp/data.json", 0, &rec); err != nil { + t.Fatalf("normalizeRecordOwnership() error = %v", err) + } + if rec.Application != "auth" { + t.Fatalf("expected trimmed application=auth, got %q", rec.Application) + } +} + func TestLoadErrorModelDisplayAndErrorFormatting(t *testing.T) { t.Parallel() if got := loadErrorModelDisplay("auth", "User"); got != "auth.User" { @@ -3394,7 +3412,6 @@ func TestPlanBatchRecordOrder_ValidationAndRefErrors(t *testing.T) { }{ {name: "missing name", rec: record{Module: "auth", Application: "auth", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeMissingName}, {name: "module not owner", rec: record{Module: "base", Name: "x", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeModuleNotOwner}, - {name: "application mismatch", rec: record{Name: "x", Application: "base", Model: "User", Values: map[string]any{}}, code: LoadErrorCodeApplicationMismatch}, {name: "missing model", rec: record{Module: "auth", Name: "x", Application: "auth", Values: map[string]any{}}, code: LoadErrorCodeMissingModel}, {name: "missing values", rec: record{Module: "auth", Name: "x", Application: "auth", Model: "User"}, code: LoadErrorCodeMissingValues}, {name: "invalid model full name", rec: record{Module: "auth", Name: "x", Application: "auth", Model: "auth.User", Values: map[string]any{}}, code: LoadErrorCodeInvalidModel}, diff --git a/internal/module/evolution/data/translated_seed.go b/internal/module/evolution/data/translated_seed.go index 1b0440925..33581463d 100644 --- a/internal/module/evolution/data/translated_seed.go +++ b/internal/module/evolution/data/translated_seed.go @@ -8,7 +8,6 @@ import ( "fmt" "strings" - modmeta "github.com/choysum-dev/choysum/internal/module/meta" "github.com/choysum-dev/choysum/pkg/meta" "gorm.io/gorm" ) @@ -67,9 +66,9 @@ func (l *Loader) languageCodeExists(tx *gorm.DB, code string) (bool, error) { if code == "" { return false, nil } - model, err := modmeta.LookupEffectiveModel(tx, "base", "Language") - if err != nil { - if modmeta.IsEffectiveModelNotFound(err) { + var model meta.Model + if err := tx.Where("application = ? AND name = ?", "base", "Language").First(&model).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, err diff --git a/internal/module/lifecycle/install_module.go b/internal/module/lifecycle/install_module.go index 268de3c8c..763d9630c 100644 --- a/internal/module/lifecycle/install_module.go +++ b/internal/module/lifecycle/install_module.go @@ -14,9 +14,10 @@ import ( // InstallModuleRequest is the unified bootstrap / CLI install entry input. type InstallModuleRequest struct { - // Input is a local module name or registry ref (for example "document" or "pkg@1.2.3"). - Input string - WithDemo bool + // Input is a local module name or registry ref (for example "meta" or "pkg@1.2.3"). + Input string + WithDemo bool + SkipWebShell bool } // PrepareInstall materializes the install closure outside any install commit TX. @@ -38,7 +39,8 @@ func InstallModule(ctx context.Context, runtimeScope scope.Scope, jsExecutor jse return xfmt.Errorf("module name is empty") } - prepared, err := PrepareInstall(ctx, runtimeScope, input, opts...) + opCtx := WithOperationOptions(ctx, OperationOptions{WithDemo: req.WithDemo, SkipWebShell: req.SkipWebShell}) + prepared, err := PrepareInstall(opCtx, runtimeScope, input, opts...) if err != nil { return err } @@ -46,13 +48,14 @@ func InstallModule(ctx context.Context, runtimeScope scope.Scope, jsExecutor jse return xfmt.Errorf("prepared install root is empty") } - installCtx := WithPrefetchedInstallModules(ctx, prepared.Modules) + installCtx := WithPrefetchedInstallModules(opCtx, prepared.Modules) installScope := runtimeScope.WithContext(installCtx) if installScope == nil { installScope = runtimeScope } return NewService(installScope, jsExecutor, opts...).Install(installCtx, InstallRequest{ - Name: prepared.RootName, - WithDemo: req.WithDemo, + Name: prepared.RootName, + WithDemo: req.WithDemo, + SkipWebShell: req.SkipWebShell, }) } diff --git a/internal/module/lifecycle/install_module_test.go b/internal/module/lifecycle/install_module_test.go index 14a3ec928..58d872324 100644 --- a/internal/module/lifecycle/install_module_test.go +++ b/internal/module/lifecycle/install_module_test.go @@ -55,3 +55,52 @@ func TestPrepareInstallAliasesPrefetchInstallModules(t *testing.T) { t.Fatal("expected solo in prepared modules") } } + +func TestInstallModulePropagatesSkipWebShell(t *testing.T) { + modulesPath := t.TempDir() + db := newModuleIndexSyncDB(t) + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + dependsRaw, err := json.Marshal([]string{}) + if err != nil { + t.Fatalf("marshal depends: %v", err) + } + mod := &meta.Module{ + Name: "solo_skip_web", + Version: "1.0.0", + Path: filepath.Join(modulesPath, "solo_skip_web"), + DependsStr: dependsRaw, + WebEntryPoint: "web/index.ts", + ApplicationStr: "solo", + } + origin := &countingPrefetchOriginCoordinator{ + modules: map[string]*meta.Module{"solo_skip_web": mod}, + } + + runtimeScope := newModuleIndexSyncScope(modulesPath, db) + locker := &moduleIndexSyncTestLocker{} + opts := []Option{ + WithLockerFactory(func(scope.Scope) statepkg.Locker { return locker }), + WithOriginCoordinatorFactory(func(scope.Scope) OriginCoordinator { return origin }), + } + + // PrepareInstall runs BuildPlan with SkipWebShell; web must not be resolved. + // Install then fails without a JS executor (expected harness limit). + err = InstallModule(context.Background(), runtimeScope, nil, InstallModuleRequest{ + Input: "solo_skip_web", + SkipWebShell: true, + }, opts...) + if err == nil { + t.Fatal("expected InstallModule to fail without executor/full install") + } + for _, name := range origin.fetches { + if name == "web" { + t.Fatalf("SkipWebShell should not resolve web shell, fetches=%v", origin.fetches) + } + } + if len(origin.fetches) == 0 || origin.fetches[0] != "solo_skip_web" { + t.Fatalf("fetches=%v, want solo_skip_web first", origin.fetches) + } +} diff --git a/internal/module/lifecycle/install_prefetch.go b/internal/module/lifecycle/install_prefetch.go index 389bab4d7..533787a2e 100644 --- a/internal/module/lifecycle/install_prefetch.go +++ b/internal/module/lifecycle/install_prefetch.go @@ -100,7 +100,7 @@ func (m *ModuleManager) PrefetchInstallModules(ctx context.Context, input string return nil, xfmt.Errorf("resolved module name is empty") } - opPlan, err := plan.BuildPlan(ctx, plan.OpInstall, root, m) + opPlan, err := plan.BuildPlan(ctx, plan.OpInstall, root, m, planBuildOptionsFromContext(ctx)...) if err != nil { return nil, xfmt.Errorf("build install plan for prefetch: %w", err) } diff --git a/internal/module/lifecycle/merge_unique_module_names_test.go b/internal/module/lifecycle/merge_unique_module_names_test.go new file mode 100644 index 000000000..fbf71573d --- /dev/null +++ b/internal/module/lifecycle/merge_unique_module_names_test.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import ( + "testing" + + moduleplan "github.com/choysum-dev/choysum/internal/module/plan" +) + +func TestMergeUniqueModuleNames(t *testing.T) { + got := mergeUniqueModuleNames( + []string{"", " web ", "auth"}, + nil, + []string{"auth", "partner", " "}, + []string{"web"}, + ) + want := []string{"web", "auth", "partner"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } + if got := mergeUniqueModuleNames(); len(got) != 0 { + t.Fatalf("empty merge = %v", got) + } +} + +func TestModuleOperationPlanInfoAttrsIncludesEnsure(t *testing.T) { + attrs := attrsToMap(t, moduleOperationPlanInfoAttrs(moduleplan.Plan{ + ModuleOrder: []string{"partner"}, + EnsureOrder: []string{"auth", "web"}, + AffectedApps: []string{"partner"}, + NeedsGlobalWebBuild: true, + })) + if got := attrs["ensure_count"]; got != 2 { + t.Fatalf("ensure_count=%#v, want 2", got) + } + ensure, ok := attrs["ensure"].([]string) + if !ok || len(ensure) != 2 || ensure[0] != "auth" || ensure[1] != "web" { + t.Fatalf("ensure=%#v, want [auth web]", attrs["ensure"]) + } +} diff --git a/internal/module/lifecycle/module_index_sync_test.go b/internal/module/lifecycle/module_index_sync_test.go index 0529219c9..a40684c8d 100644 --- a/internal/module/lifecycle/module_index_sync_test.go +++ b/internal/module/lifecycle/module_index_sync_test.go @@ -1236,6 +1236,70 @@ func TestModuleManagerUpgradeRunsAppStageCallbacks(t *testing.T) { } } +func TestModuleManagerUpgradeRefreshModuleIndexError(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: "auth", + ApplicationStr: "crm", + Version: "v1.2.0", + Path: filepath.Join(modulesPath, "auth"), + }} + 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, "auth"), 0o750); err != nil { + t.Fatalf("mkdir auth module dir: %v", err) + } + if err := db.Create(&meta.Module{ + Name: "auth", + Status: meta.Installed, + Version: "v1.0.0", + ApplicationStr: "crm", + Path: filepath.Join(modulesPath, "auth"), + }).Error; err != nil { + t.Fatalf("seed installed module: %v", err) + } + + // Fail only finalize refreshModuleIndexForLocalModules Creates. + if err := db.Callback().Create().Before("gorm:create").Register("deny_module_index_refresh", func(tx *gorm.DB) { + if tx.Statement == nil || tx.Statement.Schema == nil { + return + } + if tx.Statement.Schema.Table != (&modmeta.ModuleIndex{}).TableName() { + return + } + _ = tx.AddError(errors.New("forced module index refresh failure")) + }); err != nil { + t.Fatalf("register callback: %v", err) + } + t.Cleanup(func() { _ = db.Callback().Create().Remove("deny_module_index_refresh") }) + + err := manager.Upgrade(context.Background(), "auth") + if err == nil || !strings.Contains(err.Error(), "refresh module index for auth") { + t.Fatalf("Upgrade() error = %v, want module index refresh failure", err) + } +} + func TestModuleManagerUpgradeCoreUsesListInstalledApps(t *testing.T) { modulesPath := t.TempDir() distPath := filepath.Join(t.TempDir(), "dist") diff --git a/internal/module/lifecycle/modulemanager.go b/internal/module/lifecycle/modulemanager.go index 8ab001a2e..88fc96206 100644 --- a/internal/module/lifecycle/modulemanager.go +++ b/internal/module/lifecycle/modulemanager.go @@ -577,15 +577,39 @@ func summarizeModuleOpInfoNames(values []string) []string { return compact } +// mergeUniqueModuleNames concatenates name lists in order, skipping blanks/duplicates. +func mergeUniqueModuleNames(parts ...[]string) []string { + seen := map[string]struct{}{} + out := make([]string, 0) + for _, names := range parts { + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + } + return out +} + func moduleOperationPlanInfoAttrs(opPlan plan.Plan) []any { attrs := []any{ "modules_count", len(opPlan.ModuleOrder), + "ensure_count", len(opPlan.EnsureOrder), "apps_count", len(opPlan.AffectedApps), "needs_global_web_build", opPlan.NeedsGlobalWebBuild, } if modules := summarizeModuleOpInfoNames(opPlan.ModuleOrder); len(modules) > 0 { attrs = append(attrs, "modules", modules) } + if ensure := summarizeModuleOpInfoNames(opPlan.EnsureOrder); len(ensure) > 0 { + attrs = append(attrs, "ensure", ensure) + } if apps := summarizeModuleOpInfoNames(opPlan.AffectedApps); len(apps) > 0 { attrs = append(attrs, "apps", apps) } @@ -607,6 +631,50 @@ func moduleOperationCompletedInfoAttrs(opPlan plan.Plan, duration time.Duration) return attrs } +// handleUpgradeEnsureProgress maps ensure/upgrade module progress events to spinner stages. +// Returns true when the event was handled (caller should skip shared pipeline progress). +func handleUpgradeEnsureProgress( + event pipeline.ProgressEvent, + setSpinnerStage func(stage, message string), +) bool { + moduleName := strings.TrimSpace(event.Module) + if moduleName == "" { + moduleName = "unknown" + } + switch event.Stage { + case pipeline.ProgressStageModuleInstallStarted: + if event.Total > 0 && event.Current > 0 { + setSpinnerStage("upgrading.ensure", fmt.Sprintf("%s: ensuring modules (%d/%d)", moduleName, event.Current, event.Total)) + return true + } + setSpinnerStage("upgrading.ensure", fmt.Sprintf("%s: ensuring module", moduleName)) + return true + case pipeline.ProgressStageModuleInstallFailed: + if event.Total > 0 && event.Current > 0 { + setSpinnerStage("upgrading.ensure", fmt.Sprintf("%s: failed ensuring module (%d/%d)", moduleName, event.Current, event.Total)) + return true + } + setSpinnerStage("upgrading.ensure", fmt.Sprintf("%s: failed ensuring module", moduleName)) + return true + case pipeline.ProgressStageModuleUpgradeStarted: + if event.Total > 0 && event.Current > 0 { + setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: upgrading modules (%d/%d)", moduleName, event.Current, event.Total)) + return true + } + setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: upgrading module", moduleName)) + return true + case pipeline.ProgressStageModuleUpgradeFailed: + if event.Total > 0 && event.Current > 0 { + setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: failed upgrading module (%d/%d)", moduleName, event.Current, event.Total)) + return true + } + setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: failed upgrading module", moduleName)) + return true + default: + return false + } +} + func handlePipelineSharedProgress( event pipeline.ProgressEvent, rootModuleName string, @@ -1031,7 +1099,7 @@ func (m *ModuleManager) Install(ctx context.Context, name string) error { installSpinnerState.mu.Unlock() setSpinnerMessage(planningSpinnerMessage(progress)) }) - opPlan, err := plan.BuildPlan(planningCtx, plan.OpInstall, rootModule, m) + opPlan, err := plan.BuildPlan(planningCtx, plan.OpInstall, rootModule, m, planBuildOptionsFromContext(ctx)...) clearSpinnerState() if err != nil { return err @@ -1252,7 +1320,7 @@ func (m *ModuleManager) Uninstall(ctx context.Context, name string) error { return err } planningStarted := time.Now() - plan, err := plan.BuildPlan(ctx, plan.OpUninstall, mod, m) + plan, err := plan.BuildPlan(ctx, plan.OpUninstall, mod, m, planBuildOptionsFromContext(ctx)...) if err != nil { return err } @@ -1454,7 +1522,7 @@ func (m *ModuleManager) Upgrade(ctx context.Context, name string) error { return rollbackUpgradeOrigin(err) } planningStarted := time.Now() - plan, err := plan.BuildPlan(ctx, plan.OpUpgrade, mod, m) + plan, err := plan.BuildPlan(ctx, plan.OpUpgrade, mod, m, planBuildOptionsFromContext(ctx)...) if err != nil { return rollbackUpgradeOrigin(err) } @@ -1515,32 +1583,19 @@ func (m *ModuleManager) Upgrade(ctx context.Context, name string) error { } } started := time.Now() + moduleOps := moduleOpCtxBinder{m: m, opCtx: opCtx} err = pipeline.Execute(stageCtx, plan, mod, pipeline.Callbacks{ Logger: logger, OnProgress: func(event pipeline.ProgressEvent) { - moduleName := strings.TrimSpace(event.Module) - if moduleName == "" { - moduleName = "unknown" - } - switch event.Stage { - case pipeline.ProgressStageModuleUpgradeStarted: - if event.Total > 0 && event.Current > 0 { - setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: upgrading modules (%d/%d)", moduleName, event.Current, event.Total)) - return - } - setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: upgrading module", moduleName)) - case pipeline.ProgressStageModuleUpgradeFailed: - if event.Total > 0 && event.Current > 0 { - setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: failed upgrading module (%d/%d)", moduleName, event.Current, event.Total)) - return - } - setSpinnerStage("upgrading.modules", fmt.Sprintf("%s: failed upgrading module", moduleName)) - default: - _ = handlePipelineSharedProgress(event, rootModuleName, len(plan.AffectedApps), setSpinnerStage) + if handleUpgradeEnsureProgress(event, setSpinnerStage) { + return } + _ = handlePipelineSharedProgress(event, rootModuleName, len(plan.AffectedApps), setSpinnerStage) }, - ResolveInstalledModule: m.Load, - Upgrade: moduleOpCtxBinder{m: m, opCtx: opCtx}.upgrade, + ResolveInstallModuleFromOrigin: m.resolveInstallModuleFromOrigin, + ResolveInstalledModule: m.Load, + Install: moduleOps.install, + Upgrade: moduleOps.upgrade, AppTargets: func(appName string) (string, pipeline.ModulesAppTargets, error) { distAppDir := "" if !isBundleMode { @@ -1583,7 +1638,10 @@ func (m *ModuleManager) Upgrade(ctx context.Context, name string) error { return rollbackUpgradeOrigin(err) } clearSpinnerState() - for _, moduleName := range plan.ModuleOrder { + // Include EnsureOrder so newly installed shell deps (e.g. web) also run + // phase-end hooks and receive module-index refresh after upgrade. + finalizeModules := mergeUniqueModuleNames(plan.EnsureOrder, plan.ModuleOrder) + for _, moduleName := range finalizeModules { mod, err := m.Load(moduleName) if err != nil { return rollbackUpgradeOrigin(err) @@ -1609,7 +1667,7 @@ func (m *ModuleManager) Upgrade(ctx context.Context, name string) error { ) } } - if err := m.refreshModuleIndexForLocalModules(ctx, plan.ModuleOrder); err != nil { + if err := m.refreshModuleIndexForLocalModules(ctx, finalizeModules); err != nil { return rollbackUpgradeOrigin(err) } if originSwitch != nil { diff --git a/internal/module/lifecycle/modulemanager_logging_test.go b/internal/module/lifecycle/modulemanager_logging_test.go index 2d623193b..c32ffc34c 100644 --- a/internal/module/lifecycle/modulemanager_logging_test.go +++ b/internal/module/lifecycle/modulemanager_logging_test.go @@ -507,6 +507,117 @@ func TestReleaseLeaseWithContextFallback_FallbackExpectedErrorsSkipWarn(t *testi } } +func TestHandleUpgradeEnsureProgress(t *testing.T) { + stages := make(map[string]string) + setSpinnerStage := func(stage, message string) { + stages[stage] = message + } + clearStages := func() { + for k := range stages { + delete(stages, k) + } + } + + t.Run("ensure started with progress", func(t *testing.T) { + clearStages() + ok := handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleInstallStarted, + Module: "web", + Current: 1, + Total: 2, + }, setSpinnerStage) + if !ok { + t.Fatal("expected handled") + } + if msg := stages["upgrading.ensure"]; msg != "web: ensuring modules (1/2)" { + t.Fatalf("message=%q", msg) + } + }) + + t.Run("ensure started without progress uses unknown module", func(t *testing.T) { + clearStages() + ok := handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleInstallStarted, + }, setSpinnerStage) + if !ok { + t.Fatal("expected handled") + } + if msg := stages["upgrading.ensure"]; msg != "unknown: ensuring module" { + t.Fatalf("message=%q", msg) + } + }) + + t.Run("ensure failed with and without progress", func(t *testing.T) { + clearStages() + ok := handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleInstallFailed, + Module: "auth", + Current: 2, + Total: 3, + }, setSpinnerStage) + if !ok || stages["upgrading.ensure"] != "auth: failed ensuring module (2/3)" { + t.Fatalf("ok=%v msg=%q", ok, stages["upgrading.ensure"]) + } + clearStages() + ok = handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleInstallFailed, + Module: "auth", + }, setSpinnerStage) + if !ok || stages["upgrading.ensure"] != "auth: failed ensuring module" { + t.Fatalf("ok=%v msg=%q", ok, stages["upgrading.ensure"]) + } + }) + + t.Run("upgrade started/failed branches", func(t *testing.T) { + clearStages() + _ = handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleUpgradeStarted, + Module: "partner", + Current: 1, + Total: 1, + }, setSpinnerStage) + if stages["upgrading.modules"] != "partner: upgrading modules (1/1)" { + t.Fatalf("message=%q", stages["upgrading.modules"]) + } + clearStages() + _ = handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleUpgradeStarted, + Module: "partner", + }, setSpinnerStage) + if stages["upgrading.modules"] != "partner: upgrading module" { + t.Fatalf("message=%q", stages["upgrading.modules"]) + } + clearStages() + _ = handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleUpgradeFailed, + Module: "partner", + Current: 1, + Total: 2, + }, setSpinnerStage) + if stages["upgrading.modules"] != "partner: failed upgrading module (1/2)" { + t.Fatalf("message=%q", stages["upgrading.modules"]) + } + clearStages() + _ = handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageModuleUpgradeFailed, + Module: "partner", + }, setSpinnerStage) + if stages["upgrading.modules"] != "partner: failed upgrading module" { + t.Fatalf("message=%q", stages["upgrading.modules"]) + } + }) + + t.Run("unknown stage returns false", func(t *testing.T) { + clearStages() + ok := handleUpgradeEnsureProgress(pipeline.ProgressEvent{ + Stage: pipeline.ProgressStageWebBuildStarted, + }, setSpinnerStage) + if ok || len(stages) != 0 { + t.Fatalf("ok=%v stages=%v", ok, stages) + } + }) +} + func TestHandlePipelineSharedProgress(t *testing.T) { stages := make(map[string]string) setSpinnerStage := func(stage, message string) { diff --git a/internal/module/lifecycle/operation_options.go b/internal/module/lifecycle/operation_options.go index afda5f937..ed8722ec8 100644 --- a/internal/module/lifecycle/operation_options.go +++ b/internal/module/lifecycle/operation_options.go @@ -1,9 +1,16 @@ package lifecycle -import "context" +import ( + "context" + + "github.com/choysum-dev/choysum/internal/module/plan" +) type OperationOptions struct { WithDemo bool + // SkipWebShell disables planner auto-include of the web SPA shell when a + // module declares entryPoints.web (CLI --no-web). + SkipWebShell bool } type operationOptionsKey struct{} @@ -26,3 +33,10 @@ func OperationOptionsFromContext(ctx context.Context) OperationOptions { } return OperationOptions{} } + +func planBuildOptionsFromContext(ctx context.Context) []plan.BuildOption { + if OperationOptionsFromContext(ctx).SkipWebShell { + return []plan.BuildOption{plan.WithSkipWebShell(true)} + } + return nil +} diff --git a/internal/module/lifecycle/operation_options_test.go b/internal/module/lifecycle/operation_options_test.go new file mode 100644 index 000000000..04ee226c0 --- /dev/null +++ b/internal/module/lifecycle/operation_options_test.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import ( + "context" + "testing" +) + +func TestOperationOptionsContextAndPlanBuildOptions(t *testing.T) { + // Intentionally pass nil: production helpers must treat a missing Context as empty options. + if got := OperationOptionsFromContext(nil); got.WithDemo || got.SkipWebShell { + t.Fatalf("nil ctx options = %#v", got) + } + if got := OperationOptionsFromContext(context.Background()); got.SkipWebShell { + t.Fatalf("empty ctx SkipWebShell = %v", got.SkipWebShell) + } + + ctx := WithOperationOptions(context.Background(), OperationOptions{WithDemo: true, SkipWebShell: true}) + got := OperationOptionsFromContext(ctx) + if !got.WithDemo || !got.SkipWebShell { + t.Fatalf("stored options = %#v", got) + } + + if opts := planBuildOptionsFromContext(context.Background()); opts != nil { + t.Fatalf("expected nil plan opts, got %#v", opts) + } + opts := planBuildOptionsFromContext(ctx) + if len(opts) != 1 || opts[0] == nil { + t.Fatalf("expected one SkipWebShell build option, got %#v", opts) + } +} diff --git a/internal/module/lifecycle/service.go b/internal/module/lifecycle/service.go index e8812560a..0db8f07e6 100644 --- a/internal/module/lifecycle/service.go +++ b/internal/module/lifecycle/service.go @@ -9,13 +9,15 @@ import ( ) type InstallRequest struct { - Name string - WithDemo bool + Name string + WithDemo bool + SkipWebShell bool } type UpgradeRequest struct { - Input string - WithDemo bool + Input string + WithDemo bool + SkipWebShell bool } type UninstallRequest struct { @@ -37,12 +39,12 @@ func NewService(runtimeScope scope.Scope, jsExecutor jsexecutor.ScriptExecutor, } func (s *service) Install(ctx context.Context, req InstallRequest) error { - ctx = WithOperationOptions(ctx, OperationOptions{WithDemo: req.WithDemo}) + ctx = WithOperationOptions(ctx, OperationOptions{WithDemo: req.WithDemo, SkipWebShell: req.SkipWebShell}) return s.manager.Install(ctx, strings.TrimSpace(req.Name)) } func (s *service) Upgrade(ctx context.Context, req UpgradeRequest) error { - ctx = WithOperationOptions(ctx, OperationOptions{WithDemo: req.WithDemo}) + ctx = WithOperationOptions(ctx, OperationOptions{WithDemo: req.WithDemo, SkipWebShell: req.SkipWebShell}) return s.manager.Upgrade(ctx, strings.TrimSpace(req.Input)) } diff --git a/internal/module/lifecycle/service_upgrade_test.go b/internal/module/lifecycle/service_upgrade_test.go new file mode 100644 index 000000000..40894abe7 --- /dev/null +++ b/internal/module/lifecycle/service_upgrade_test.go @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/choysum-dev/choysum/pkg/meta" + "github.com/choysum-dev/choysum/pkg/scope" +) + +type skipWebShellPeekOrigin struct { + skipWebShell bool + peeked bool +} + +func (o *skipWebShellPeekOrigin) Peek(ctx context.Context, _ string) (*meta.Module, error) { + o.peeked = true + o.skipWebShell = OperationOptionsFromContext(ctx).SkipWebShell + return nil, errors.New("stop after peek") +} + +func (*skipWebShellPeekOrigin) ResolveInstallModule(context.Context, string) (*meta.Module, error) { + return nil, errors.New("not implemented") +} + +func (*skipWebShellPeekOrigin) Fetch(context.Context, string) (*meta.Module, error) { + return nil, errors.New("not implemented") +} + +func (*skipWebShellPeekOrigin) Purge(context.Context, string) error { + return errors.New("not implemented") +} + +func TestServiceUpgradeAppliesSkipWebShellOptions(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + origin := &skipWebShellPeekOrigin{} + svc := NewService(runtimeScope, nil, WithOriginCoordinatorFactory(func(scope.Scope) OriginCoordinator { + return origin + })) + + // Registry Peek runs before lease and receives the operation context. + err := svc.Upgrade(context.Background(), UpgradeRequest{ + Input: "probe@1.0.0", + WithDemo: true, + SkipWebShell: true, + }) + if err == nil { + t.Fatal("expected Upgrade to fail after Peek") + } + if !origin.peeked { + t.Fatal("expected OriginCoordinator.Peek to observe the upgrade context") + } + if !origin.skipWebShell { + t.Fatal("expected OperationOptions.SkipWebShell=true on Peek context") + } +} diff --git a/internal/module/lifecycle/uninstaller.go b/internal/module/lifecycle/uninstaller.go index 60f0ae38c..0ae2b4117 100644 --- a/internal/module/lifecycle/uninstaller.go +++ b/internal/module/lifecycle/uninstaller.go @@ -7,6 +7,7 @@ import ( "context" "errors" "slices" + "strings" "time" "github.com/choysum-dev/choysum/internal/module/evolution/hooks" @@ -107,6 +108,86 @@ func (m *moduleUninstaller) cleanModels() error { } } + // SF7: hard-delete web.SavedFilter rows only when a logical model has no remaining + // live meta_model after this module's declarations were removed (IMD-safe). + return applySavedFilterPurge(db.DB, keys) +} + +// applySavedFilterPurge wraps purgeSavedFiltersForGoneModels so uninstall can surface purge errors. +func applySavedFilterPurge(db *gorm.DB, keys []modmeta.LogicalKey) error { + if err := purgeSavedFiltersForGoneModels(db, keys); err != nil { + return err + } + return nil +} + +const webSavedFilterTable = "web_saved_filter" + +// webSavedFilterTableExists reports whether the concrete favorites table is present. +// Missing-table errors are ok=false; other probe failures are returned. +func webSavedFilterTableExists(db *gorm.DB) (bool, error) { + if db == nil { + return false, nil + } + var n int64 + err := db.Raw("SELECT COUNT(1) FROM "+webSavedFilterTable+" WHERE 0").Scan(&n).Error + if err == nil { + return true, nil + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "no such table") || + strings.Contains(msg, "doesn't exist") || + strings.Contains(msg, "does not exist") || + strings.Contains(msg, "unknown table") { + return false, nil + } + return false, xfmt.Errorf("error checking %s existence: %w", webSavedFilterTable, err) +} + +// purgeSavedFiltersForGoneModels deletes Favorites for logical models that no longer +// have any live effective meta_model row. No-op when the table is missing. Never +// deletes by Application alone. +func purgeSavedFiltersForGoneModels(db *gorm.DB, keys []modmeta.LogicalKey) error { + if db == nil || len(keys) == 0 { + return nil + } + // Probe the concrete base table: missing table is a no-op; other DB errors must fail + // uninstall (HasTable alone discards lookup failures and would leave orphan favorites). + exists, err := webSavedFilterTableExists(db) + if err != nil { + return err + } + if !exists { + return nil + } + seen := map[string]struct{}{} + for _, key := range keys { + k := key.Normalized() + if !k.Valid() { + continue + } + id := k.Application + "\x00" + k.Name + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + var remaining int64 + if err := db.Model(&meta.Model{}). + Where("application = ? AND name = ?", k.Application, k.Name). + Count(&remaining).Error; err != nil { + return xfmt.Errorf("error counting surviving meta models for saved filter purge: %w", err) + } + if remaining > 0 { + continue + } + if err := db.Exec( + "DELETE FROM "+webSavedFilterTable+" WHERE application = ? AND model_name = ?", + k.Application, k.Name, + ).Error; err != nil { + return xfmt.Errorf("error deleting web saved filters for %s.%s: %w", k.Application, k.Name, err) + } + } return nil } diff --git a/internal/module/lifecycle/uninstaller_saved_filter_test.go b/internal/module/lifecycle/uninstaller_saved_filter_test.go new file mode 100644 index 000000000..d35b8c319 --- /dev/null +++ b/internal/module/lifecycle/uninstaller_saved_filter_test.go @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package lifecycle + +import ( + "database/sql" + "strings" + "testing" + + modmeta "github.com/choysum-dev/choysum/internal/module/meta" + "github.com/choysum-dev/choysum/pkg/meta" + "github.com/rs/xid" + "gorm.io/gorm" +) + +func ensureWebSavedFilterTable(t *testing.T, db *gorm.DB) { + t.Helper() + stmt := ` +CREATE TABLE IF NOT EXISTS web_saved_filter ( + id TEXT PRIMARY KEY, + application TEXT NOT NULL, + model_name TEXT NOT NULL, + name TEXT NOT NULL +)` + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("create web_saved_filter: %v", err) + } +} + +func TestModuleUninstallerPurgesSavedFilterWhenLastMetaModelGone(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) + } + ensureWebSavedFilterTable(t, db) + + mod := &meta.Module{Name: "demo_sf", Status: meta.Installed, Version: "1.0.0"} + mod.Id = sql.NullString{String: xid.New().String(), Valid: true} + if err := db.Create(mod).Error; err != nil { + t.Fatalf("create module: %v", err) + } + + rawID := xid.New().String() + if _, err := modmeta.ReplaceModuleDeclarations(db, mod.Id.String, []*meta.Model{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: rawID, Valid: true}}, + Name: "Item", + Path: "@/demo_sf/service/models/item.ts", + Application: "demo", + ModelTable: "demo_item", + ModuleId: mod.Id, + }}); err != nil { + t.Fatalf("create raw model: %v", err) + } + if err := modmeta.FlushEffective(db, []modmeta.LogicalKey{{Application: "demo", Name: "Item"}}); err != nil { + t.Fatalf("flush effective: %v", err) + } + + favID := xid.New().String() + if err := db.Exec( + `INSERT INTO web_saved_filter(id, application, model_name, name) VALUES (?, ?, ?, ?)`, + favID, "demo", "Item", "Active", + ).Error; err != nil { + t.Fatalf("insert saved filter: %v", err) + } + otherID := xid.New().String() + if err := db.Exec( + `INSERT INTO web_saved_filter(id, application, model_name, name) VALUES (?, ?, ?, ?)`, + otherID, "demo", "Other", "Keep", + ).Error; err != nil { + t.Fatalf("insert other saved filter: %v", err) + } + + uninstaller := &moduleUninstaller{ + runtimeScope: runtimeScope, + module: mod, + moduleManager: &ModuleManager{runtimeScope: runtimeScope}, + ctx: newOpContext(), + } + if err := uninstaller.cleanModels(); err != nil { + t.Fatalf("cleanModels() error = %v", err) + } + + var remaining int64 + if err := db.Raw(`SELECT COUNT(*) FROM web_saved_filter WHERE id = ?`, favID).Scan(&remaining).Error; err != nil { + t.Fatalf("count purged filter: %v", err) + } + if remaining != 0 { + t.Fatalf("saved filter for gone model remaining = %d, want 0", remaining) + } + var otherRemaining int64 + if err := db.Raw(`SELECT COUNT(*) FROM web_saved_filter WHERE id = ?`, otherID).Scan(&otherRemaining).Error; err != nil { + t.Fatalf("count other filter: %v", err) + } + if otherRemaining != 1 { + t.Fatalf("unrelated saved filter remaining = %d, want 1", otherRemaining) + } +} + +func TestModuleUninstallerKeepsSavedFilterWhenIMDSurvivorRemains(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) + } + ensureWebSavedFilterTable(t, db) + + baseMod := &meta.Module{Name: "partner", Status: meta.Installed, Version: "1.0.0"} + baseMod.Id = sql.NullString{String: xid.New().String(), Valid: true} + if err := db.Create(baseMod).Error; err != nil { + t.Fatalf("create base module: %v", err) + } + extMod := &meta.Module{Name: "partner_commercial", Status: meta.Installed, Version: "1.0.0"} + extMod.Id = sql.NullString{String: xid.New().String(), Valid: true} + if err := db.Create(extMod).Error; err != nil { + t.Fatalf("create ext module: %v", err) + } + + basePath := "@/partner/service/models/partner.ts" + if _, err := modmeta.ReplaceModuleDeclarations(db, baseMod.Id.String, []*meta.Model{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + Name: "Partner", + Path: basePath, + Application: "partner", + ModelTable: "partner_partner", + ModuleId: baseMod.Id, + }}); err != nil { + t.Fatalf("create base raw model: %v", err) + } + if _, err := modmeta.ReplaceModuleDeclarations(db, extMod.Id.String, []*meta.Model{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + Name: "Partner", + Path: "@/partner_commercial/service/models/partner.ts", + Application: "partner", + ModelTable: "partner_partner", + ModuleId: extMod.Id, + Extends: basePath, + }}); err != nil { + t.Fatalf("create ext raw model: %v", err) + } + if err := modmeta.FlushEffective(db, []modmeta.LogicalKey{{Application: "partner", Name: "Partner"}}); err != nil { + t.Fatalf("flush effective: %v", err) + } + + favID := xid.New().String() + if err := db.Exec( + `INSERT INTO web_saved_filter(id, application, model_name, name) VALUES (?, ?, ?, ?)`, + favID, "partner", "Partner", "Customers", + ).Error; err != nil { + t.Fatalf("insert saved filter: %v", err) + } + + uninstaller := &moduleUninstaller{ + runtimeScope: runtimeScope, + module: extMod, + moduleManager: &ModuleManager{runtimeScope: runtimeScope}, + ctx: newOpContext(), + } + if err := uninstaller.cleanModels(); err != nil { + t.Fatalf("cleanModels() error = %v", err) + } + + var remaining int64 + if err := db.Raw(`SELECT COUNT(*) FROM web_saved_filter WHERE id = ?`, favID).Scan(&remaining).Error; err != nil { + t.Fatalf("count saved filter: %v", err) + } + if remaining != 1 { + t.Fatalf("IMD survivor should keep saved filter, remaining = %d, want 1", remaining) + } +} + +func TestModuleUninstallerSavedFilterMissingTableNoOp(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate CatalogEntities: %v", err) + } + + mod := &meta.Module{Name: "demo_sf_missing", Status: meta.Installed, Version: "1.0.0"} + mod.Id = sql.NullString{String: xid.New().String(), Valid: true} + if err := db.Create(mod).Error; err != nil { + t.Fatalf("create module: %v", err) + } + if _, err := modmeta.ReplaceModuleDeclarations(db, mod.Id.String, []*meta.Model{{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + Name: "Item", + Path: "@/demo_sf_missing/service/models/item.ts", + Application: "demo", + ModelTable: "demo_item", + ModuleId: mod.Id, + }}); err != nil { + t.Fatalf("create raw model: %v", err) + } + + uninstaller := &moduleUninstaller{ + runtimeScope: runtimeScope, + module: mod, + moduleManager: &ModuleManager{runtimeScope: runtimeScope}, + ctx: newOpContext(), + } + if err := uninstaller.cleanModels(); err != nil { + t.Fatalf("cleanModels() with missing web_saved_filter should no-op, got %v", err) + } +} + +func TestWebSavedFilterTableExists(t *testing.T) { + if ok, err := webSavedFilterTableExists(nil); err != nil || ok { + t.Fatalf("nil db: ok=%v err=%v", ok, err) + } + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if ok, err := webSavedFilterTableExists(db); err != nil || ok { + t.Fatalf("missing table: ok=%v err=%v", ok, err) + } + ensureWebSavedFilterTable(t, db) + if ok, err := webSavedFilterTableExists(db); err != nil || !ok { + t.Fatalf("present table: ok=%v err=%v", ok, err) + } +} + +func TestWebSavedFilterTableExistsProbeError(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + ensureWebSavedFilterTable(t, db) + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB(): %v", err) + } + if err := sqlDB.Close(); err != nil { + t.Fatalf("close sql DB: %v", err) + } + + ok, probeErr := webSavedFilterTableExists(db) + if ok || probeErr == nil || !strings.Contains(probeErr.Error(), "error checking web_saved_filter existence") { + t.Fatalf("ok=%v err=%v, want probe wrap", ok, probeErr) + } + if purgeErr := purgeSavedFiltersForGoneModels(db, []modmeta.LogicalKey{{Application: "demo", Name: "Item"}}); purgeErr == nil || + !strings.Contains(purgeErr.Error(), "error checking web_saved_filter existence") { + t.Fatalf("purge error=%v, want probe failure propagated", purgeErr) + } +} + +func TestPurgeSavedFiltersForGoneModelsGuards(t *testing.T) { + if err := purgeSavedFiltersForGoneModels(nil, []modmeta.LogicalKey{{Application: "a", Name: "B"}}); err != nil { + t.Fatalf("nil db: %v", err) + } + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if err := purgeSavedFiltersForGoneModels(db, nil); err != nil { + t.Fatalf("empty keys: %v", err) + } + ensureWebSavedFilterTable(t, db) + if err := purgeSavedFiltersForGoneModels(db, []modmeta.LogicalKey{ + {}, + {Application: " ", Name: "Item"}, + {Application: "demo", Name: " "}, + {Application: "demo", Name: "Item"}, + {Application: "demo", Name: "Item"}, // duplicate + }); err != nil { + t.Fatalf("invalid/dup keys: %v", err) + } +} + +func TestPurgeSavedFiltersCountError(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + ensureWebSavedFilterTable(t, db) + // Keep web_saved_filter visible to HasTable, but break meta_model Count. + if err := db.Migrator().DropTable(&meta.Model{}); err != nil { + t.Fatalf("drop meta_model: %v", err) + } + err := purgeSavedFiltersForGoneModels(db, []modmeta.LogicalKey{{Application: "demo", Name: "Item"}}) + if err == nil || !strings.Contains(err.Error(), "error counting surviving meta models for saved filter purge") { + t.Fatalf("error=%v", err) + } +} + +func TestPurgeSavedFiltersDeleteError(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + if err := db.AutoMigrate(modmeta.CatalogEntities()...); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } + ensureWebSavedFilterTable(t, db) + if err := db.Exec(`INSERT INTO web_saved_filter(id, application, model_name, name) VALUES ('1','demo','Item','x')`).Error; err != nil { + t.Fatalf("insert: %v", err) + } + if err := db.Exec(`CREATE TRIGGER deny_sf_delete BEFORE DELETE ON web_saved_filter BEGIN SELECT RAISE(ABORT, 'deny delete'); END`).Error; err != nil { + t.Fatalf("create trigger: %v", err) + } + err := purgeSavedFiltersForGoneModels(db, []modmeta.LogicalKey{{Application: "demo", Name: "Item"}}) + if err == nil || !strings.Contains(err.Error(), "error deleting web saved filters") { + t.Fatalf("error=%v, want delete wrap", err) + } +} + +func TestApplySavedFilterPurgePropagatesError(t *testing.T) { + runtimeScope := newLifecycleCommitTestScope(t) + db := runtimeScope.Session().DB + ensureWebSavedFilterTable(t, db) + if err := db.Migrator().DropTable(&meta.Model{}); err != nil { + t.Fatalf("drop meta_model: %v", err) + } + err := applySavedFilterPurge(db, []modmeta.LogicalKey{{Application: "demo", Name: "Item"}}) + if err == nil || !strings.Contains(err.Error(), "error counting surviving meta models for saved filter purge") { + t.Fatalf("error=%v", err) + } +} + +func TestApplySavedFilterPurgeOK(t *testing.T) { + if err := applySavedFilterPurge(nil, nil); err != nil { + t.Fatalf("nil args: %v", err) + } +} diff --git a/internal/module/meta/lookup_effective.go b/internal/module/meta/lookup_effective.go deleted file mode 100644 index ffdc9c1dc..000000000 --- a/internal/module/meta/lookup_effective.go +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "errors" - "fmt" - "strings" - - pkgmeta "github.com/choysum-dev/choysum/pkg/meta" - "gorm.io/gorm" -) - -// LookupEffectiveModel returns the live effective meta_model row for (application, name). -// Prefer empty module_id (E2 projection) when legacy declaration shells still coexist. -// No tip Order — callers must not use created_at/id DESC to pick among same-name rows. -func LookupEffectiveModel(db *gorm.DB, application, name string) (*pkgmeta.Model, error) { - if db == nil { - return nil, fmt.Errorf("db is nil") - } - application = strings.TrimSpace(application) - name = strings.TrimSpace(name) - if application == "" || name == "" { - return nil, fmt.Errorf("lookup effective requires application and name") - } - - var rows []pkgmeta.Model - if err := db.Where("application = ? AND name = ?", application, name).Find(&rows).Error; err != nil { - return nil, fmt.Errorf("lookup effective %s.%s: %w", application, name, err) - } - if len(rows) == 0 { - return nil, gorm.ErrRecordNotFound - } - picked := pickEffectiveAmong(rows) - return &picked, nil -} - -func pickEffectiveAmong(rows []pkgmeta.Model) pkgmeta.Model { - if len(rows) == 1 { - return rows[0] - } - var best *pkgmeta.Model - for i := range rows { - row := &rows[i] - emptyModule := !row.ModuleId.Valid || strings.TrimSpace(row.ModuleId.String) == "" - if best == nil { - best = row - continue - } - bestEmpty := !best.ModuleId.Valid || strings.TrimSpace(best.ModuleId.String) == "" - if emptyModule && !bestEmpty { - best = row - continue - } - if emptyModule == bestEmpty { - if row.UpdatedAt.After(best.UpdatedAt) || - (row.UpdatedAt.Equal(best.UpdatedAt) && row.Id.String > best.Id.String) { - best = row - } - } - } - return *best -} - -// IsEffectiveModelNotFound reports whether err is a missing effective model. -func IsEffectiveModelNotFound(err error) bool { - return errors.Is(err, gorm.ErrRecordNotFound) -} diff --git a/internal/module/meta/lookup_effective_test.go b/internal/module/meta/lookup_effective_test.go deleted file mode 100644 index c08fad19f..000000000 --- a/internal/module/meta/lookup_effective_test.go +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: LGPL-3.0-or-later - -package meta - -import ( - "database/sql" - "strings" - "testing" - "time" - - pkgmeta "github.com/choysum-dev/choysum/pkg/meta" -) - -func TestLookupEffectiveModel_FindsLiveRow(t *testing.T) { - db := openDualStoreTestDB(t) - if err := ensureDualStoreTables(db); err != nil { - t.Fatalf("ensure dual store: %v", err) - } - ts := time.Now().UTC() - eff := &pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "eff", Valid: true}, CreatedAt: ts, UpdatedAt: ts}, - Name: "Partner", - Path: "/eff", - Application: "partner", - } - if err := db.Create(eff).Error; err != nil { - t.Fatalf("create eff: %v", err) - } - - got, err := LookupEffectiveModel(db, "partner", "Partner") - if err != nil { - t.Fatalf("LookupEffectiveModel: %v", err) - } - if got.Id.String != "eff" { - t.Fatalf("expected effective row, got %#v", got) - } - - if _, err := LookupEffectiveModel(db, "partner", "Missing"); !IsEffectiveModelNotFound(err) { - t.Fatalf("expected not found, got %v", err) - } - if _, err := LookupEffectiveModel(nil, "a", "b"); err == nil { - t.Fatal("expected nil db error") - } - if _, err := LookupEffectiveModel(db, "", "x"); err == nil { - t.Fatal("expected empty key error") - } -} - -func TestLookupEffectiveModel_FindErrorAndPickBranches(t *testing.T) { - t.Run("find_error_closed_db", func(t *testing.T) { - db := openDualStoreTestDB(t) - if err := ensureDualStoreTables(db); err != nil { - t.Fatalf("ensure: %v", err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatalf("db.DB: %v", err) - } - if err := sqlDB.Close(); err != nil { - t.Fatalf("close: %v", err) - } - if _, err := LookupEffectiveModel(db, "a", "B"); err == nil || !strings.Contains(err.Error(), "lookup effective") { - t.Fatalf("expected find error, got %v", err) - } - }) - - t.Run("single_row_and_tie_breaks", func(t *testing.T) { - ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - assertPick := func(want string, rows ...pkgmeta.Model) { - t.Helper() - if got := pickEffectiveAmong(rows); got.Id.String != want { - t.Fatalf("forward want %s, got %#v", want, got) - } - rev := make([]pkgmeta.Model, len(rows)) - for i := range rows { - rev[len(rows)-1-i] = rows[i] - } - if got := pickEffectiveAmong(rev); got.Id.String != want { - t.Fatalf("reversed want %s, got %#v", want, got) - } - } - - only := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "only", Valid: true}, UpdatedAt: ts}, - Name: "X", Application: "a", - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - assertPick("only", only) - - older := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "a-id", Valid: true}, UpdatedAt: ts}, - ModuleId: sql.NullString{String: "m1", Valid: true}, - } - newer := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "b-id", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, - ModuleId: sql.NullString{String: "m2", Valid: true}, - } - assertPick("b-id", older, newer) - - low := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "aaa", Valid: true}, UpdatedAt: ts}, - } - high := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "zzz", Valid: true}, UpdatedAt: ts}, - } - assertPick("zzz", low, high) - - // Whitespace ModuleId counts as empty; prefer it over shell. - shell := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "shell", Valid: true}, UpdatedAt: ts.Add(time.Hour)}, - ModuleId: sql.NullString{String: "mod", Valid: true}, - } - wsEmpty := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "ws", Valid: true}, UpdatedAt: ts}, - ModuleId: sql.NullString{String: " ", Valid: true}, - } - assertPick("ws", shell, wsEmpty) - - // Three-way fold must match all-at-once pick (map-iteration order independence). - mid := pkgmeta.Model{ - BaseModel: pkgmeta.BaseModel{Id: sql.NullString{String: "mid", Valid: true}, UpdatedAt: ts.Add(30 * time.Minute)}, - ModuleId: sql.NullString{String: "m3", Valid: true}, - } - all := []pkgmeta.Model{older, mid, newer} - want := pickEffectiveAmong(all).Id.String - fold := func(rows []pkgmeta.Model) string { - best := rows[0] - for i := 1; i < len(rows); i++ { - best = pickEffectiveAmong([]pkgmeta.Model{best, rows[i]}) - } - return best.Id.String - } - if got := fold(all); got != want { - t.Fatalf("forward fold %s != all-at-once %s", got, want) - } - rev := []pkgmeta.Model{newer, mid, older} - if got := fold(rev); got != want { - t.Fatalf("reverse fold %s != all-at-once %s", got, want) - } - }) -} diff --git a/internal/module/plan/options.go b/internal/module/plan/options.go new file mode 100644 index 000000000..67a65e1fb --- /dev/null +++ b/internal/module/plan/options.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package plan + +// BuildOptions configures BuildPlan behavior beyond the op/root/resolver inputs. +type BuildOptions struct { + // SkipWebShell disables auto-including the web shell when a planned module + // declares entryPoints.web (CLI --no-web). + SkipWebShell bool +} + +// BuildOption mutates BuildOptions. +type BuildOption func(*BuildOptions) + +// WithSkipWebShell sets SkipWebShell. +func WithSkipWebShell(skip bool) BuildOption { + return func(o *BuildOptions) { + if o == nil { + return + } + o.SkipWebShell = skip + } +} + +func applyBuildOptions(opts []BuildOption) BuildOptions { + out := BuildOptions{} + for _, opt := range opts { + if opt != nil { + opt(&out) + } + } + return out +} diff --git a/internal/module/plan/options_test.go b/internal/module/plan/options_test.go new file mode 100644 index 000000000..3b9c616ab --- /dev/null +++ b/internal/module/plan/options_test.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package plan + +import "testing" + +func TestApplyBuildOptionsNilAndSkipWebShell(t *testing.T) { + WithSkipWebShell(true)(nil) + + out := applyBuildOptions(nil) + if out.SkipWebShell { + t.Fatal("expected zero options") + } + out = applyBuildOptions([]BuildOption{nil, WithSkipWebShell(true)}) + if !out.SkipWebShell { + t.Fatal("expected SkipWebShell") + } +} diff --git a/internal/module/plan/plan.go b/internal/module/plan/plan.go index 17233f09d..cae90c327 100644 --- a/internal/module/plan/plan.go +++ b/internal/module/plan/plan.go @@ -18,7 +18,9 @@ type Plan struct { // For install: topo order; for uninstall: reverse topo. ModuleOrder []string - // EnsureOrder is reserved for future dependency resolution (currently unused). + // EnsureOrder lists modules that must be installed (if missing) before the + // primary ModuleOrder runs. Used by upgrade to pull in the web shell without + // upgrading web itself. EnsureOrder []string // AffectedApps contains application names impacted by the operation. diff --git a/internal/module/plan/planner.go b/internal/module/plan/planner.go index 6788544ed..233d53fa7 100644 --- a/internal/module/plan/planner.go +++ b/internal/module/plan/planner.go @@ -20,7 +20,7 @@ type Resolver interface { Load(name string) (*meta.Module, error) } -func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (Plan, error) { +func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver, opts ...BuildOption) (Plan, error) { if root == nil { return Plan{}, fmt.Errorf("root module is nil") } @@ -30,18 +30,19 @@ func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (P if ctx == nil { ctx = context.Background() } + buildOpts := applyBuildOptions(opts) plan := Plan{Op: op} apps := map[string]bool{} - needsGlobalWebBuild := false + needsWebShell := false addApp := func(mod *meta.Module) { if mod == nil { return } - if strings.EqualFold(strings.TrimSpace(mod.Name), "web") || strings.TrimSpace(mod.WebEntryPoint) != "" { - needsGlobalWebBuild = true + if moduleNeedsWebShell(mod) { + needsWebShell = true } name := strings.TrimSpace(mod.ApplicationStr) @@ -79,6 +80,7 @@ func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (P // If a web module is currently installed, keep global web build enabled. // This is intentionally conservative because dist/web aggregates imports across modules/apps. reportBuildPlanProgress(ctx, BuildPlanProgress{Step: "resolve_web_build"}) + needsGlobalWebBuild := needsWebShell if !needsGlobalWebBuild { webMod, err := r.Load("web") if err != nil { @@ -90,6 +92,14 @@ func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (P } plan.NeedsGlobalWebBuild = needsGlobalWebBuild + // Shell ensuring peeks/loads the web origin; only install/upgrade need it. + // Uninstall (and --no-web installs) must not fail when the shell is absent. + if !buildOpts.SkipWebShell && needsWebShell && (op == OpInstall || op == OpUpgrade) { + if err := ensureWebShell(ctx, op, &plan, r, addApp); err != nil { + return Plan{}, err + } + } + for app := range apps { plan.AffectedApps = append(plan.AffectedApps, app) } @@ -98,6 +108,136 @@ func BuildPlan(ctx context.Context, op OpType, root *meta.Module, r Resolver) (P return plan, nil } +func moduleNeedsWebShell(mod *meta.Module) bool { + if mod == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(mod.Name), "web") || strings.TrimSpace(mod.WebEntryPoint) != "" +} + +func moduleOrderContains(order []string, name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + for _, item := range order { + if strings.TrimSpace(item) == name { + return true + } + } + return false +} + +// filterEnsureModuleNames trims and drops blank entries before EnsureOrder Load checks. +func filterEnsureModuleNames(names []string) []string { + out := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + out = append(out, name) + } + return out +} + +func mergeModuleOrder(prefix, suffix []string) []string { + seen := map[string]bool{} + // Cap separately to avoid CodeQL size-overflow on len(a)+len(b) for make(). + out := make([]string, 0, len(prefix)) + appendUnique := func(names []string) { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + } + appendUnique(prefix) + appendUnique(suffix) + return out +} + +func resolveWebModule(ctx context.Context, r Resolver) (*meta.Module, error) { + webMod, err := r.Load("web") + if err != nil { + return nil, fmt.Errorf("load web module for shell plan: %w", err) + } + if webMod != nil { + return webMod, nil + } + webMod, err = r.Peek(ctx, "web") + if err != nil { + return nil, fmt.Errorf("peek web module for shell plan: %w", err) + } + if webMod == nil || strings.TrimSpace(webMod.Name) == "" { + return nil, fmt.Errorf("web shell required (entryPoints.web) but module web was not found") + } + return webMod, nil +} + +// ensureWebShell pulls the web SPA shell into the plan when a domain module +// declares entryPoints.web. Install merges web into ModuleOrder; upgrade only +// installs a missing shell via EnsureOrder (does not upgrade web). +func ensureWebShell(ctx context.Context, op OpType, plan *Plan, r Resolver, addApp func(*meta.Module)) error { + if plan == nil { + return fmt.Errorf("plan is nil") + } + reportBuildPlanProgress(ctx, BuildPlanProgress{Step: "resolve_web_shell", CurrentModule: "web"}) + + webMod, err := resolveWebModule(ctx, r) + if err != nil { + return err + } + webInstalled := webMod.Status == meta.Installed + + switch op { + case OpInstall: + if moduleOrderContains(plan.ModuleOrder, "web") { + return nil + } + if webInstalled { + // Shell already present; app-stage rebuild is enough. + plan.NeedsGlobalWebBuild = true + return nil + } + webOrder, err := topoByDependsStr(ctx, webMod, r, addApp) + if err != nil { + return fmt.Errorf("resolve web shell dependencies: %w", err) + } + plan.ModuleOrder = mergeModuleOrder(webOrder, plan.ModuleOrder) + plan.NeedsGlobalWebBuild = true + return nil + case OpUpgrade: + if webInstalled { + plan.NeedsGlobalWebBuild = true + return nil + } + webOrder, err := topoByDependsStr(ctx, webMod, r, addApp) + if err != nil { + return fmt.Errorf("resolve web shell dependencies: %w", err) + } + ensure := make([]string, 0, len(webOrder)) + for _, name := range filterEnsureModuleNames(webOrder) { + mod, loadErr := r.Load(name) + if loadErr != nil { + return fmt.Errorf("load module %s for web shell ensure: %w", name, loadErr) + } + if mod != nil && mod.Status == meta.Installed { + continue + } + ensure = append(ensure, name) + } + plan.EnsureOrder = ensure + plan.NeedsGlobalWebBuild = true + return nil + default: + return nil + } +} + func topoByDependsStr(ctx context.Context, root *meta.Module, r Resolver, addApp func(*meta.Module)) ([]string, error) { visited := map[string]bool{} stack := []string{} diff --git a/internal/module/plan/planner_test.go b/internal/module/plan/planner_test.go index 89e05ce1b..a6ea59d31 100644 --- a/internal/module/plan/planner_test.go +++ b/internal/module/plan/planner_test.go @@ -6,6 +6,7 @@ package plan import ( "context" "errors" + "fmt" "strings" "testing" @@ -61,7 +62,7 @@ func TestBuildPlanInstallErrorsAndAppCollection(t *testing.T) { peekCalls := 0 loadCalls := 0 r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { peekCalls++ switch name { case "dep": @@ -78,7 +79,7 @@ func TestBuildPlanInstallErrorsAndAppCollection(t *testing.T) { }, } - plan, err := BuildPlan(context.Background(), OpInstall, root, r) + plan, err := BuildPlan(context.Background(), OpInstall, root, r, WithSkipWebShell(true)) if err != nil { t.Fatalf("BuildPlan() error: %v", err) } @@ -99,6 +100,144 @@ func TestBuildPlanInstallErrorsAndAppCollection(t *testing.T) { } } +func TestBuildPlanInstallAutoIncludesWebShell(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + DependsStr: []byte(`["core"]`), + } + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + switch name { + case "core": + return &meta.Module{Name: "core", ApplicationStr: "core"}, nil + case "auth": + return &meta.Module{Name: "auth", ApplicationStr: "auth", DependsStr: []byte(`["core"]`)}, nil + case "web": + return &meta.Module{Name: "web", ApplicationStr: "web", WebEntryPoint: "web/index.ts", DependsStr: []byte(`["core","auth"]`)}, nil + default: + return nil, nil + } + }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, + } + + plan, err := BuildPlan(context.Background(), OpInstall, root, r) + if err != nil { + t.Fatalf("BuildPlan() error: %v", err) + } + if !plan.NeedsGlobalWebBuild { + t.Fatal("expected NeedsGlobalWebBuild") + } + want := []string{"core", "auth", "web", "partner"} + if len(plan.ModuleOrder) != len(want) { + t.Fatalf("module order=%v, want %v", plan.ModuleOrder, want) + } + for i, name := range want { + if plan.ModuleOrder[i] != name { + t.Fatalf("module order=%v, want %v", plan.ModuleOrder, want) + } + } +} + +func TestBuildPlanUpgradeEnsureOrderInstallsMissingWebShell(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + switch name { + case "core": + return &meta.Module{Name: "core", ApplicationStr: "core"}, nil + case "auth": + return &meta.Module{Name: "auth", ApplicationStr: "auth", DependsStr: []byte(`["core"]`)}, nil + case "web": + return &meta.Module{Name: "web", ApplicationStr: "web", WebEntryPoint: "web/index.ts", DependsStr: []byte(`["core","auth"]`)}, nil + default: + return nil, nil + } + }, + load: func(name string) (*meta.Module, error) { + if name == "core" { + return &meta.Module{Name: "core", Status: meta.Installed, ApplicationStr: "core"}, nil + } + return nil, nil + }, + } + + plan, err := BuildPlan(context.Background(), OpUpgrade, root, r) + if err != nil { + t.Fatalf("BuildPlan() error: %v", err) + } + if len(plan.ModuleOrder) != 1 || plan.ModuleOrder[0] != "partner" { + t.Fatalf("ModuleOrder=%v, want [partner]", plan.ModuleOrder) + } + wantEnsure := []string{"auth", "web"} + if len(plan.EnsureOrder) != len(wantEnsure) { + t.Fatalf("EnsureOrder=%v, want %v", plan.EnsureOrder, wantEnsure) + } + for i, name := range wantEnsure { + if plan.EnsureOrder[i] != name { + t.Fatalf("EnsureOrder=%v, want %v", plan.EnsureOrder, wantEnsure) + } + } + if !plan.NeedsGlobalWebBuild { + t.Fatal("expected NeedsGlobalWebBuild") + } +} + +func TestBuildPlanSkipWebShell(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + r := fakeResolver{ + load: func(_ string) (*meta.Module, error) { return nil, nil }, + } + plan, err := BuildPlan(context.Background(), OpInstall, root, r, WithSkipWebShell(true)) + if err != nil { + t.Fatalf("BuildPlan() error: %v", err) + } + if moduleOrderContains(plan.ModuleOrder, "web") { + t.Fatalf("expected no web in ModuleOrder with SkipWebShell, got %v", plan.ModuleOrder) + } + if len(plan.EnsureOrder) != 0 { + t.Fatalf("expected empty EnsureOrder, got %v", plan.EnsureOrder) + } +} + +func TestBuildPlanUninstallDoesNotRequireWebShellOrigin(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + Status: meta.Installed, + } + r := fakeResolver{ + load: func(name string) (*meta.Module, error) { + if name == "partner" { + return root, nil + } + // web origin intentionally unavailable (e.g. installed with --no-web). + return nil, nil + }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { + return nil, fmt.Errorf("web shell origin unavailable") + }, + } + plan, err := BuildPlan(context.Background(), OpUninstall, root, r) + if err != nil { + t.Fatalf("BuildPlan(uninstall) should not require web shell, got: %v", err) + } + if len(plan.EnsureOrder) != 0 { + t.Fatalf("expected empty EnsureOrder on uninstall, got %v", plan.EnsureOrder) + } +} + func TestBuildPlanInstallDependencyErrors(t *testing.T) { tests := []struct { name string @@ -129,7 +268,7 @@ func TestBuildPlanInstallDependencyErrors(t *testing.T) { name: "load dependency error", root: &meta.Module{Name: "auth", DependsStr: []byte(` ["dep"] `)}, res: fakeResolver{ - load: func(name string) (*meta.Module, error) { return nil, errors.New("load dep failed") }, + load: func(_ string) (*meta.Module, error) { return nil, errors.New("load dep failed") }, }, want: "load dependency dep: load dep failed", }, @@ -137,7 +276,7 @@ func TestBuildPlanInstallDependencyErrors(t *testing.T) { name: "peek dependency error", root: &meta.Module{Name: "auth", DependsStr: []byte(` ["dep"] `)}, res: fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, _ string) (*meta.Module, error) { return nil, errors.New("peek dep failed") }, }, @@ -147,7 +286,7 @@ func TestBuildPlanInstallDependencyErrors(t *testing.T) { name: "dependency cycle error", root: &meta.Module{Name: "auth", DependsStr: []byte(` ["dep"] `)}, res: fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { switch name { case "dep": return &meta.Module{Name: "dep", DependsStr: []byte(` ["auth"] `)}, nil @@ -175,7 +314,7 @@ func TestBuildPlanInstallContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) root := &meta.Module{Name: "auth", DependsStr: []byte(` ["dep"] `)} resolver := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name}, nil }, } @@ -202,10 +341,10 @@ func TestBuildPlanUninstallLoadError(t *testing.T) { func TestBuildPlan_NeedsGlobalWebBuildFalseWithoutWebModule(t *testing.T) { root := &meta.Module{Name: "auth", ApplicationStr: "auth"} r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name, ApplicationStr: name}, nil }, - load: func(name string) (*meta.Module, error) { return nil, nil }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, } plan, err := BuildPlan(context.Background(), OpInstall, root, r) @@ -220,10 +359,10 @@ func TestBuildPlan_NeedsGlobalWebBuildFalseWithoutWebModule(t *testing.T) { func TestBuildPlan_NeedsGlobalWebBuildTrueWhenRootIsWeb(t *testing.T) { root := &meta.Module{Name: "web", ApplicationStr: "web", WebEntryPoint: "web/index.ts"} r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name, ApplicationStr: name}, nil }, - load: func(name string) (*meta.Module, error) { return nil, nil }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, } plan, err := BuildPlan(context.Background(), OpInstall, root, r) @@ -238,7 +377,7 @@ func TestBuildPlan_NeedsGlobalWebBuildTrueWhenRootIsWeb(t *testing.T) { func TestBuildPlan_NeedsGlobalWebBuildTrueWhenWebInstalled(t *testing.T) { root := &meta.Module{Name: "auth", ApplicationStr: "auth"} r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name, ApplicationStr: name}, nil }, load: func(name string) (*meta.Module, error) { @@ -261,10 +400,10 @@ func TestBuildPlan_NeedsGlobalWebBuildTrueWhenWebInstalled(t *testing.T) { func TestBuildPlan_UpgradeUsesRootOnly(t *testing.T) { root := &meta.Module{Name: "auth", ApplicationStr: "auth"} r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { return &meta.Module{Name: name, ApplicationStr: name}, nil }, - load: func(name string) (*meta.Module, error) { return nil, nil }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, } plan, err := BuildPlan(context.Background(), OpUpgrade, root, r) @@ -287,7 +426,7 @@ func TestBuildPlan_UninstallOrdersDependentsFirst(t *testing.T) { "auth_addon": {Name: "auth_addon", ApplicationStr: "auth"}, } r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { return nil, nil }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { return nil, nil }, load: func(name string) (*meta.Module, error) { return modules[name], nil }, } @@ -306,8 +445,8 @@ func TestBuildPlan_UninstallOrdersDependentsFirst(t *testing.T) { func TestBuildPlan_UninstallTreatsMissingModuleAsNoOp(t *testing.T) { root := &meta.Module{Name: "missing", ApplicationStr: "auth"} r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { return nil, nil }, - load: func(name string) (*meta.Module, error) { return nil, nil }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { return nil, nil }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, } plan, err := BuildPlan(context.Background(), OpUninstall, root, r) @@ -329,7 +468,7 @@ func TestBuildPlan_UninstallDetectsDependentCycle(t *testing.T) { "auth": {Name: "auth", ApplicationStr: "auth", Dependents: []*meta.Module{{Name: "base"}}}, } r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { return nil, nil }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { return nil, nil }, load: func(name string) (*meta.Module, error) { return modules[name], nil }, } @@ -346,7 +485,7 @@ func TestBuildPlan_AffectedAppsSortedForStableLogs(t *testing.T) { DependsStr: []byte(` ["dep_b", "dep_a", "webmod"] `), } r := fakeResolver{ - peek: func(ctx context.Context, name string) (*meta.Module, error) { + peek: func(_ context.Context, name string) (*meta.Module, error) { switch name { case "dep_a": return &meta.Module{Name: "dep_a", ApplicationStr: "alpha"}, nil @@ -358,10 +497,10 @@ func TestBuildPlan_AffectedAppsSortedForStableLogs(t *testing.T) { return nil, nil } }, - load: func(name string) (*meta.Module, error) { return nil, nil }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, } - plan, err := BuildPlan(context.Background(), OpInstall, root, r) + plan, err := BuildPlan(context.Background(), OpInstall, root, r, WithSkipWebShell(true)) if err != nil { t.Fatalf("BuildPlan error: %v", err) } @@ -441,3 +580,302 @@ func TestReportBuildPlanProgress_WithReporter(t *testing.T) { t.Fatalf("steps = %v, want [resolve_dependencies, topological_sort]", steps) } } + +func TestWithSkipWebShellAndApplyBuildOptions(t *testing.T) { + WithSkipWebShell(true)(nil) // nil receiver is a no-op + + opts := applyBuildOptions([]BuildOption{nil, WithSkipWebShell(true), WithSkipWebShell(false)}) + if opts.SkipWebShell { + t.Fatal("last WithSkipWebShell(false) should win") + } + opts = applyBuildOptions([]BuildOption{WithSkipWebShell(true)}) + if !opts.SkipWebShell { + t.Fatal("expected SkipWebShell=true") + } +} + +func TestModuleNeedsWebShellHelpers(t *testing.T) { + if moduleNeedsWebShell(nil) { + t.Fatal("nil module should not need web shell") + } + if moduleNeedsWebShell(&meta.Module{Name: "partner"}) { + t.Fatal("plain module should not need web shell") + } + if !moduleNeedsWebShell(&meta.Module{Name: "Web"}) { + t.Fatal("name web should need shell") + } + if !moduleNeedsWebShell(&meta.Module{Name: "x", WebEntryPoint: "web/index.ts"}) { + t.Fatal("WebEntryPoint should need shell") + } +} + +func TestModuleOrderContainsAndMerge(t *testing.T) { + if moduleOrderContains(nil, "") || moduleOrderContains([]string{" a "}, " ") { + t.Fatal("empty name should not match") + } + if !moduleOrderContains([]string{"core", " web "}, "web") { + t.Fatal("expected trimmed match") + } + got := mergeModuleOrder([]string{"", "web", "auth", "web"}, []string{"auth", "partner", " "}) + want := []string{"web", "auth", "partner"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + +func TestEnsureWebShellNilPlan(t *testing.T) { + err := ensureWebShell(context.Background(), OpInstall, nil, fakeResolver{}, nil) + if err == nil || err.Error() != "plan is nil" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEnsureWebShellPropagatesResolveWebModuleError(t *testing.T) { + err := ensureWebShell(context.Background(), OpInstall, &Plan{}, fakeResolver{ + load: func(_ string) (*meta.Module, error) { return nil, errors.New("boom") }, + }, nil) + if err == nil || !strings.Contains(err.Error(), "load web module for shell plan") { + t.Fatalf("error=%v", err) + } +} + +func TestResolveWebModuleErrors(t *testing.T) { + t.Run("load_error", func(t *testing.T) { + _, err := resolveWebModule(context.Background(), fakeResolver{ + load: func(_ string) (*meta.Module, error) { return nil, errors.New("boom") }, + }) + if err == nil || !strings.Contains(err.Error(), "load web module for shell plan") { + t.Fatalf("error=%v", err) + } + }) + t.Run("peek_error", func(t *testing.T) { + _, err := resolveWebModule(context.Background(), fakeResolver{ + load: func(_ string) (*meta.Module, error) { return nil, nil }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { + return nil, errors.New("peek boom") + }, + }) + if err == nil || !strings.Contains(err.Error(), "peek web module for shell plan") { + t.Fatalf("error=%v", err) + } + }) + t.Run("not_found", func(t *testing.T) { + _, err := resolveWebModule(context.Background(), fakeResolver{ + load: func(_ string) (*meta.Module, error) { return nil, nil }, + peek: func(_ context.Context, _ string) (*meta.Module, error) { + return &meta.Module{Name: " "}, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "web shell required") { + t.Fatalf("error=%v", err) + } + }) + t.Run("load_hit", func(t *testing.T) { + got, err := resolveWebModule(context.Background(), fakeResolver{ + load: func(_ string) (*meta.Module, error) { + return &meta.Module{Name: "web", Status: meta.Installed}, nil + }, + }) + if err != nil || got == nil || got.Name != "web" { + t.Fatalf("got=%v err=%v", got, err) + } + }) +} + +func TestBuildPlanInstallWebAlreadyInOrder(t *testing.T) { + root := &meta.Module{ + Name: "web", + ApplicationStr: "web", + WebEntryPoint: "web/index.ts", + DependsStr: []byte(`["core"]`), + } + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + if name == "core" { + return &meta.Module{Name: "core"}, nil + } + return nil, nil + }, + load: func(name string) (*meta.Module, error) { + if name == "web" { + return root, nil + } + return nil, nil + }, + } + plan, err := BuildPlan(context.Background(), OpInstall, root, r) + if err != nil { + t.Fatalf("BuildPlan: %v", err) + } + if !moduleOrderContains(plan.ModuleOrder, "web") { + t.Fatalf("expected web in order: %v", plan.ModuleOrder) + } +} + +func TestBuildPlanInstallAlreadyInstalledWebShell(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + if name == "web" { + return &meta.Module{Name: "web", WebEntryPoint: "web/index.ts"}, nil + } + return nil, nil + }, + load: func(name string) (*meta.Module, error) { + if name == "web" { + return &meta.Module{Name: "web", Status: meta.Installed, WebEntryPoint: "web/index.ts"}, nil + } + return nil, nil + }, + } + plan, err := BuildPlan(context.Background(), OpInstall, root, r) + if err != nil { + t.Fatalf("BuildPlan: %v", err) + } + if moduleOrderContains(plan.ModuleOrder, "web") { + t.Fatalf("installed web should not be merged into ModuleOrder, got %v", plan.ModuleOrder) + } + if !plan.NeedsGlobalWebBuild { + t.Fatal("expected NeedsGlobalWebBuild") + } +} + +func TestBuildPlanInstallWebShellDependencyError(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + if name == "web" { + return &meta.Module{Name: "web", DependsStr: []byte(`["auth"]`), WebEntryPoint: "web/index.ts"}, nil + } + if name == "auth" { + return nil, errors.New("peek auth failed") + } + return nil, nil + }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, + } + _, err := BuildPlan(context.Background(), OpInstall, root, r) + if err == nil || !strings.Contains(err.Error(), "resolve web shell dependencies") { + t.Fatalf("error=%v", err) + } +} + +func TestBuildPlanUpgradeAlreadyInstalledWebShell(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + r := fakeResolver{ + load: func(name string) (*meta.Module, error) { + if name == "web" { + return &meta.Module{Name: "web", Status: meta.Installed, WebEntryPoint: "web/index.ts"}, nil + } + return nil, nil + }, + } + plan, err := BuildPlan(context.Background(), OpUpgrade, root, r) + if err != nil { + t.Fatalf("BuildPlan: %v", err) + } + if len(plan.EnsureOrder) != 0 { + t.Fatalf("EnsureOrder=%v, want empty", plan.EnsureOrder) + } + if !plan.NeedsGlobalWebBuild { + t.Fatal("expected NeedsGlobalWebBuild") + } +} + +func TestBuildPlanUpgradeEnsureOrderSkipsEmptyNamesAndLoadError(t *testing.T) { + root := &meta.Module{ + Name: "partner", + ApplicationStr: "partner", + WebEntryPoint: "web/index.ts", + } + t.Run("load_ensure_error", func(t *testing.T) { + // resolveWebModule: Load(nil) → Peek(web). Ensure loop: Load(web) after Peek. + webPeeked := false + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + if name == "web" { + webPeeked = true + return &meta.Module{Name: "web", WebEntryPoint: "web/index.ts", DependsStr: []byte(`["", "auth"]`)}, nil + } + if name == "auth" { + return &meta.Module{Name: "auth"}, nil + } + return nil, nil + }, + load: func(name string) (*meta.Module, error) { + if name == "web" { + if webPeeked { + return nil, errors.New("load web failed") + } + return nil, nil + } + // topo uses Load→nil then Peek for auth; ensure Load(auth) succeeds as installed skip. + if name == "auth" { + return &meta.Module{Name: "auth", Status: meta.Installed}, nil + } + return nil, nil + }, + } + _, err := BuildPlan(context.Background(), OpUpgrade, root, r) + if err == nil || !strings.Contains(err.Error(), "load module web for web shell ensure") { + t.Fatalf("error=%v", err) + } + }) + t.Run("topo_error", func(t *testing.T) { + r := fakeResolver{ + peek: func(_ context.Context, name string) (*meta.Module, error) { + if name == "web" { + return &meta.Module{Name: "web", DependsStr: []byte(`["auth"]`), WebEntryPoint: "web/index.ts"}, nil + } + return nil, errors.New("peek failed") + }, + load: func(_ string) (*meta.Module, error) { return nil, nil }, + } + _, err := BuildPlan(context.Background(), OpUpgrade, root, r) + if err == nil || !strings.Contains(err.Error(), "resolve web shell dependencies") { + t.Fatalf("error=%v", err) + } + }) +} + +func TestFilterEnsureModuleNames(t *testing.T) { + got := filterEnsureModuleNames([]string{"", " ", "auth", " auth ", "web"}) + want := []string{"auth", "auth", "web"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + +func TestEnsureWebShellDefaultOpNoOp(t *testing.T) { + plan := &Plan{ModuleOrder: []string{"partner"}} + err := ensureWebShell(context.Background(), OpUninstall, plan, fakeResolver{ + load: func(_ string) (*meta.Module, error) { + return &meta.Module{Name: "web"}, nil + }, + }, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/testing/backend/backend.go b/internal/testing/backend/backend.go index e5da07d78..fb99567f0 100644 --- a/internal/testing/backend/backend.go +++ b/internal/testing/backend/backend.go @@ -498,6 +498,68 @@ type junitFailure struct { Body string `xml:",chardata"` } +// shouldSkipWebShellForUnitApp skips SPA shell install for non-auth unit shards. +func shouldSkipWebShellForUnitApp(app string) bool { + return !strings.EqualFold(strings.TrimSpace(app), "auth") +} + +// shouldInstallMetaForUnitApp installs meta for auth (gRPC) and web (SavedFilter ModelId). +func shouldInstallMetaForUnitApp(app string) bool { + app = strings.TrimSpace(app) + return strings.EqualFold(app, "auth") || strings.EqualFold(app, "web") +} + +type unitAppInstaller interface { + Install(ctx context.Context, req lifecycle.InstallRequest) error +} + +// installUnitAppModules installs the unit shard (optionally skipping the web shell) then meta when needed. +func installUnitAppModules(ctx context.Context, installer unitAppInstaller, app string) error { + if err := installer.Install(ctx, lifecycle.InstallRequest{ + Name: app, + SkipWebShell: shouldSkipWebShellForUnitApp(app), + }); err != nil { + return err + } + return ensureMetaInstalledForUnitApp(ctx, installer, app) +} + +// ensureMetaInstalledForUnitApp installs meta when the shard needs MetaModel/gRPC services. +func ensureMetaInstalledForUnitApp(ctx context.Context, installer unitAppInstaller, app string) error { + if !shouldInstallMetaForUnitApp(app) { + return nil + } + return installer.Install(ctx, lifecycle.InstallRequest{Name: "meta", SkipWebShell: true}) +} + +// jsContextWithUnitTestIdentity seeds bootstrap admin into JsRequest.Context when auth is installed. +func jsContextWithUnitTestIdentity(ctx context.Context, testScope scope.Scope) (map[string]interface{}, error) { + jsCtx := map[string]interface{}{} + identity, ok, idErr := resolveUnitTestDefaultIdentity(ctx, testScope) + if idErr != nil { + return nil, xfmt.Errorf("resolve unit test default identity: %w", idErr) + } + if ok { + // When auth is in the install closure, seed bootstrap admin so domain + // fixtures are not anonymous-denied by record rules. choysumtest + // re-applies this before each case (tests may clear identity). + jsCtx = unitTestJsRequestContext(identity) + } + return jsCtx, nil +} + +// unitTestIdentityContextFn resolves JsRequest.Context for backend unit runs (overridable in tests). +var unitTestIdentityContextFn = jsContextWithUnitTestIdentity + +// loadUnitAppTestContext resolves JsRequest.Context, surfacing identity resolution failures. +func loadUnitAppTestContext(ctx context.Context, testScope scope.Scope) (map[string]interface{}, error) { + jsCtx, idErr := unitTestIdentityContextFn(ctx, testScope) + if idErr != nil { + return nil, idErr + } + return jsCtx, nil +} + func RunOneAppBackendTests( ctx context.Context, baseScope scope.Scope, @@ -580,17 +642,16 @@ func RunOneAppBackendTests( // Let module installation manage its own transactional/lease lifecycle. // The outer test transaction is only needed for bundle/test execution state. + // + // Skip the SPA shell for most domain shards: entryPoints.web would otherwise + // pull web→document→auth into e.g. base. Auth is the exception — its BE + // suite needs global web build to persist declared MetaUiResource rows + // (PermissionState smoke uses auth.route.token_list, etc.). moduleLifecycle := lifecycle.NewService(testScope, jsExec) - if err := moduleLifecycle.Install(ctx, lifecycle.InstallRequest{Name: app}); err != nil { - return false, err - } - // Auth backend tests rely on meta gRPC services (Model/Application). - // Ensure meta is installed so bundle/app dist assets include meta services. - if strings.EqualFold(strings.TrimSpace(app), "auth") { - if err := moduleLifecycle.Install(ctx, lifecycle.InstallRequest{Name: "meta"}); err != nil { - return false, err - } + // Web SavedFilter tests dial meta.MetaModel for effective ModelId (SF12). + if err := installUnitAppModules(ctx, moduleLifecycle, app); err != nil { + return false, err } txCtx := testScope.Context() @@ -728,6 +789,10 @@ func RunOneAppBackendTests( return true, err } + jsCtx, err := loadUnitAppTestContext(ctx, testScope) + if err != nil { + return false, err + } req := &jsengine.JsRequest{ Id: fmt.Sprintf("test-%s-%d", app, time.Now().UnixNano()), Service: "__tests__.Run", @@ -735,7 +800,7 @@ func RunOneAppBackendTests( "pattern": pattern, "failFast": failFast, }}, - Context: map[string]interface{}{}, + Context: jsCtx, } // Execute tests within a DB session context so $choysum.db bridges work. diff --git a/internal/testing/backend/backend_injected_test.go b/internal/testing/backend/backend_injected_test.go index 52a71b569..584901e40 100644 --- a/internal/testing/backend/backend_injected_test.go +++ b/internal/testing/backend/backend_injected_test.go @@ -619,6 +619,55 @@ func TestRunOneAppBackendTestsWithInjectedHooks(t *testing.T) { } }) + t.Run("default execute branch surfaces unit identity context errors", func(t *testing.T) { + repoRoot := t.TempDir() + distRoot := filepath.Join(t.TempDir(), "dist") + appDistDir := filepath.Join(distRoot, "apps", "auth") + if err := os.MkdirAll(appDistDir, 0o755); err != nil { + t.Fatalf("mkdir app dist dir: %v", err) + } + if err := os.WriteFile(filepath.Join(appDistDir, "index.js"), []byte("// app"), 0o644); err != nil { + t.Fatalf("write app index: %v", err) + } + if err := os.WriteFile(filepath.Join(appDistDir, "tests.js"), []byte("// tests"), 0o644); err != nil { + t.Fatalf("write app tests: %v", err) + } + + engineName := testEngineName(t, "default-execute-identity-err") + registerTestJsEngineFactory(engineName, func() (jsengine.JsEngine, error) { + return &testReportJsEngine{result: map[string]any{"total": 0, "passed": 0, "failed": 0, "cases": []any{}}}, nil + }) + + runtimeScope := &testStubScope{ctx: context.Background(), cfg: &config.Config{ + ModulesPath: t.TempDir(), + DistPath: distRoot, + Compile: &config.CompileConfig{BundleMode: "application"}, + Server: &config.ServerConfig{JsEngineFactory: engineName}, + }} + makeTestScopeHook = func(ctx context.Context, base scope.Scope, app string, dbDialect string, dbFile string, dbDSN string, keep bool) (scope.Scope, func(), error) { + return runtimeScope, func() {}, nil + } + newCompilerExecutorHook = func(runtimeScope scope.Scope) (jsexecutor.JsExecutor, error) { return nil, nil } + prepareBackendHook = func(ctx context.Context, testRuntimeScope scope.Scope, repoRoot string, app string, coverage bool, jsExec jsexecutor.JsExecutor) (func(), error) { + return func() {}, nil + } + executeBackendHook = nil + startInProcessGrpcHarnessHook = func(ctx context.Context, runtimeScope scope.Scope) (*inProcessGrpcHarness, error) { + return &inProcessGrpcHarness{}, nil + } + prevIdentity := unitTestIdentityContextFn + t.Cleanup(func() { unitTestIdentityContextFn = prevIdentity }) + unitTestIdentityContextFn = func(ctx context.Context, testScope scope.Scope) (map[string]interface{}, error) { + return nil, errors.New("identity context boom") + } + + failed, err := RunOneAppBackendTests(context.Background(), runtimeScope, "auth", repoRoot, "sqlite", "", "", false, "", "", false, false) + // loadUnitAppTestContext failures return (false, err) before the test run is marked failed. + if failed || err == nil || !strings.Contains(err.Error(), "identity context boom") { + t.Fatalf("expected identity context failure, failed=%v err=%v", failed, err) + } + }) + t.Run("default execute branch wraps harness startup and execute errors", func(t *testing.T) { repoRoot := t.TempDir() distRoot := filepath.Join(t.TempDir(), "dist") diff --git a/internal/testing/backend/unit_identity.go b/internal/testing/backend/unit_identity.go new file mode 100644 index 000000000..2bec1b8fe --- /dev/null +++ b/internal/testing/backend/unit_identity.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package backend + +import ( + "context" + "errors" + "strings" + + modmeta "github.com/choysum-dev/choysum/internal/module/meta" + "github.com/choysum-dev/choysum/pkg/meta" + "github.com/choysum-dev/choysum/pkg/scope" + xfmt "golang.org/x/exp/errors/fmt" + "gorm.io/gorm" +) + +// unitTestDefaultIdentity is the bootstrap admin principal injected into +// backend unit-test JsRequest.Context when the auth module is installed. +type unitTestDefaultIdentity struct { + UserID string + CompanyID string +} + +// resolveUnitTestDefaultIdentity returns auth.user_admin (+ company) when auth +// is installed in the test DB. Missing auth / seeds yield ok=false (no inject). +// Operational DB errors are returned so callers do not run anonymously by accident. +func resolveUnitTestDefaultIdentity(ctx context.Context, runtimeScope scope.Scope) (unitTestDefaultIdentity, bool, error) { + var out unitTestDefaultIdentity + if runtimeScope == nil { + return out, false, nil + } + if ctx == nil { + ctx = context.Background() + } + + txRoot := runtimeScope.WithContext(ctx) + if txRoot == nil { + txRoot = runtimeScope + } + err := txRoot.Transactor().Required(ctx, func(txScope scope.Scope, _ scope.Transaction) error { + session := txScope.Session() + if session == nil || session.DB == nil { + return nil + } + if !session.Migrator().HasTable((&meta.Module{}).TableName()) { + return nil + } + + var authMod meta.Module + if err := session.Select("id", "status").Where("name = ?", "auth").Take(&authMod).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return xfmt.Errorf("load auth module for unit identity: %w", err) + } + if authMod.Status != meta.Installed { + return nil + } + + if !session.Migrator().HasTable((&modmeta.ModelData{}).TableName()) { + return nil + } + var userData modmeta.ModelData + if err := session.Select("res_id").Where("module = ? AND name = ?", "auth", "user_admin").Take(&userData).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return xfmt.Errorf("load auth.user_admin mapping for unit identity: %w", err) + } + userID := strings.TrimSpace(userData.ResID) + if userID == "" { + return nil + } + + var userModel meta.Model + if lookupErr := session.DB.Where("application = ? AND name = ?", "auth", "User").First(&userModel).Error; lookupErr != nil { + if errors.Is(lookupErr, gorm.ErrRecordNotFound) { + // Auth installed but User seed/projection missing — treat as no inject. + return nil + } + return xfmt.Errorf("lookup auth.User for unit identity: %w", lookupErr) + } + if strings.TrimSpace(userModel.ModelTable) == "" { + return nil + } + + var row struct { + CompanyID string `gorm:"column:company_id"` + } + qErr := session.Table(userModel.ModelTable).Select("company_id").Where("id = ?", userID).Take(&row).Error + if qErr != nil { + if errors.Is(qErr, gorm.ErrRecordNotFound) { + // Mapping exists but the user row does not — fail closed (no phantom admin). + return nil + } + return xfmt.Errorf("load auth.user_admin row for unit identity: %w", qErr) + } + + companyID := strings.TrimSpace(row.CompanyID) + if companyID == "" { + var companyData modmeta.ModelData + if err := session.Select("res_id").Where("module = ? AND name = ?", "base", "company_main").Take(&companyData).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return xfmt.Errorf("load base.company_main mapping for unit identity: %w", err) + } + companyID = strings.TrimSpace(companyData.ResID) + } + if companyID == "" { + return nil + } + + out = unitTestDefaultIdentity{UserID: userID, CompanyID: companyID} + return nil + }) + if err != nil { + return unitTestDefaultIdentity{}, false, err + } + return out, out.UserID != "" && out.CompanyID != "", nil +} + +func unitTestJsRequestContext(identity unitTestDefaultIdentity) map[string]interface{} { + return map[string]interface{}{ + "identity": map[string]interface{}{ + "userId": identity.UserID, + }, + "ctx": map[string]interface{}{ + "activeCompanyId": identity.CompanyID, + "enabledCompanyIds": []string{identity.CompanyID}, + }, + "req": map[string]interface{}{ + "depth": 0, + }, + } +} diff --git a/internal/testing/backend/unit_identity_test.go b/internal/testing/backend/unit_identity_test.go new file mode 100644 index 000000000..973a6b588 --- /dev/null +++ b/internal/testing/backend/unit_identity_test.go @@ -0,0 +1,743 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package backend + +import ( + "context" + "database/sql" + "errors" + "io" + "log/slog" + "path/filepath" + "strings" + "testing" + + "github.com/choysum-dev/choysum/internal/module/lifecycle" + modmeta "github.com/choysum-dev/choysum/internal/module/meta" + "github.com/choysum-dev/choysum/internal/testing/scopetest" + "github.com/choysum-dev/choysum/pkg/config" + "github.com/choysum-dev/choysum/pkg/meta" + "github.com/choysum-dev/choysum/pkg/scope" + "github.com/rs/xid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + gormlogger "gorm.io/gorm/logger" +) + +// gormWhereVarsContain reports whether any WHERE clause expression var equals want. +// Statement.Vars is still empty in Before("gorm:query"); values live on clause.Expr. +func gormWhereVarsContain(tx *gorm.DB, want string) bool { + if tx == nil || tx.Statement == nil { + return false + } + where, ok := tx.Statement.Clauses["WHERE"] + if !ok { + return false + } + w, ok := where.Expression.(clause.Where) + if !ok { + return false + } + for _, expr := range w.Exprs { + e, ok := expr.(clause.Expr) + if !ok { + continue + } + for _, v := range e.Vars { + if v == want { + return true + } + } + } + return false +} + +type identityTestScope struct { + ctx context.Context + session *scope.Session + cfg *config.Config + logger *slog.Logger +} + +func (s *identityTestScope) Run(fn func(scope.Scope) error) error { return fn(s) } +func (s *identityTestScope) Transactor() scope.Transactor { + return scopetest.NewPassthroughTransactor(s) +} +func (s *identityTestScope) Session() *scope.Session { return s.session } +func (s *identityTestScope) WithContext(ctx context.Context) scope.Scope { + clone := *s + clone.ctx = ctx + return &clone +} +func (s *identityTestScope) Context() context.Context { + if s != nil && s.ctx != nil { + return s.ctx + } + return context.Background() +} +func (s *identityTestScope) Logger() *slog.Logger { + if s != nil && s.logger != nil { + return s.logger + } + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} +func (s *identityTestScope) Config() *config.Config { + if s != nil && s.cfg != nil { + return s.cfg + } + return &config.Config{} +} +func (s *identityTestScope) FactoryInput() scope.FactoryInput { + return scopetest.FactoryInputFromConfig(s.Config()) +} + +type nilWithContextScope struct { + *identityTestScope +} + +func (s *nilWithContextScope) WithContext(context.Context) scope.Scope { return nil } + +func openIdentityTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := filepath.Join(t.TempDir(), "unit-identity.sqlite") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + return db +} + +func newIdentityTestScope(t *testing.T, db *gorm.DB) *identityTestScope { + t.Helper() + return &identityTestScope{ + ctx: context.Background(), + session: &scope.Session{DB: db}, + cfg: &config.Config{Db: &config.DbConfig{Dialect: "sqlite"}}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +func migrateIdentityTables(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.AutoMigrate(&meta.Module{}, &modmeta.ModelData{}, &meta.Model{}); err != nil { + t.Fatalf("AutoMigrate: %v", err) + } +} + +func seedAuthInstalled(t *testing.T, db *gorm.DB) { + t.Helper() + mod := &meta.Module{Name: "auth", Status: meta.Installed, Version: "1.0.0"} + if err := db.Create(mod).Error; err != nil { + t.Fatalf("create auth module: %v", err) + } +} + +func seedUserAdminMapping(t *testing.T, db *gorm.DB, resID string) { + t.Helper() + row := &modmeta.ModelData{ + Module: "auth", + Name: "user_admin", + Application: "auth", + ModelName: "User", + ModelId: xid.New().String(), + ResID: resID, + } + if err := db.Create(row).Error; err != nil { + t.Fatalf("create user_admin mapping: %v", err) + } +} + +func seedAuthUserModel(t *testing.T, db *gorm.DB, table string) { + t.Helper() + m := &meta.Model{ + BaseModel: meta.BaseModel{Id: sql.NullString{String: xid.New().String(), Valid: true}}, + Name: "User", + Application: "auth", + ModelTable: table, + Path: "@/auth/service/models/user.ts", + } + if err := db.Create(m).Error; err != nil { + t.Fatalf("create auth.User model: %v", err) + } +} + +func createUserTable(t *testing.T, db *gorm.DB, table string) { + t.Helper() + stmt := `CREATE TABLE IF NOT EXISTS "` + table + `" (id TEXT PRIMARY KEY, company_id TEXT)` + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("create user table: %v", err) + } +} + +func TestResolveUnitTestDefaultIdentityNilScope(t *testing.T) { + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected ok=false for nil scope") + } +} + +func TestResolveUnitTestDefaultIdentityNilContextUsesBackground(t *testing.T) { + db := openIdentityTestDB(t) + runtimeScope := newIdentityTestScope(t, db) + // Empty DB (no meta_module) → ok=false, but nil ctx must not panic. + _, ok, err := resolveUnitTestDefaultIdentity(nil, runtimeScope) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected ok=false without meta_module") + } +} + +func TestResolveUnitTestDefaultIdentityNilWithContextFallsBack(t *testing.T) { + db := openIdentityTestDB(t) + base := newIdentityTestScope(t, db) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), &nilWithContextScope{identityTestScope: base}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Fatal("expected ok=false without tables") + } +} + +func TestResolveUnitTestDefaultIdentityNilSessionOrDB(t *testing.T) { + t.Run("nil_session", func(t *testing.T) { + s := &identityTestScope{ctx: context.Background(), session: nil} + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), s) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false nil err", ok, err) + } + }) + t.Run("nil_db", func(t *testing.T) { + s := &identityTestScope{ctx: context.Background(), session: &scope.Session{DB: nil}} + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), s) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false nil err", ok, err) + } + }) +} + +func TestResolveUnitTestDefaultIdentityNoMetaModuleTable(t *testing.T) { + db := openIdentityTestDB(t) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityAuthNotFound(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false when auth missing", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityAuthNotInstalled(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + if err := db.Create(&meta.Module{Name: "auth", Status: meta.ToInstall, Version: "1.0.0"}).Error; err != nil { + t.Fatalf("create auth: %v", err) + } + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false when auth not installed", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityModelDataMissing(t *testing.T) { + db := openIdentityTestDB(t) + if err := db.AutoMigrate(&meta.Module{}); err != nil { + t.Fatalf("migrate module only: %v", err) + } + seedAuthInstalled(t, db) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false without model_data table", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityUserAdminMissing(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false when user_admin missing", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityEmptyResID(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + seedUserAdminMapping(t, db, " ") + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false for empty res_id", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityMetaModelMissing(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + seedUserAdminMapping(t, db, xid.New().String()) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + // Missing auth.User projection is a seed gap (ok=false), not an operational abort. + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false nil err when auth.User missing", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityEmptyModelTable(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + seedUserAdminMapping(t, db, xid.New().String()) + seedAuthUserModel(t, db, " ") + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false for empty ModelTable", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityUserRowNotFound(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false when user row missing", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityCompanyMainFallback(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + companyID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, "").Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := db.Create(&modmeta.ModelData{ + Module: "base", Name: "company_main", Application: "base", + ModelName: "Company", ModelId: xid.New().String(), ResID: companyID, + }).Error; err != nil { + t.Fatalf("create company_main: %v", err) + } + + got, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected ok=true with company_main fallback") + } + if got.UserID != userID || got.CompanyID != companyID { + t.Fatalf("got %#v, want user=%s company=%s", got, userID, companyID) + } +} + +func TestResolveUnitTestDefaultIdentityCompanyMissing(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, "").Error; err != nil { + t.Fatalf("insert user: %v", err) + } + + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false when company_main missing", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentityCompanyEmptyResID(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, "").Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := db.Create(&modmeta.ModelData{ + Module: "base", Name: "company_main", Application: "base", + ModelName: "Company", ModelId: xid.New().String(), ResID: " ", + }).Error; err != nil { + t.Fatalf("create company_main: %v", err) + } + + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || ok { + t.Fatalf("ok=%v err=%v, want ok=false for empty company res_id", ok, err) + } +} + +func TestResolveUnitTestDefaultIdentitySuccessWithCompanyOnUser(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + companyID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, companyID).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + + got, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if got.UserID != userID || got.CompanyID != companyID { + t.Fatalf("got %#v", got) + } +} + +func TestResolveUnitTestDefaultIdentityOperationalDBErrors(t *testing.T) { + t.Run("auth_module_query", func(t *testing.T) { + db := openIdentityTestDB(t) + // HasTable passes, but Select(id,status) fails on incomplete schema. + if err := db.Exec(`CREATE TABLE meta_module (id TEXT)`).Error; err != nil { + t.Fatalf("create broken meta_module: %v", err) + } + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "load auth module for unit identity") { + t.Fatalf("error=%v, want auth module load wrap", err) + } + if ok { + t.Fatal("expected ok=false") + } + }) + + t.Run("user_admin_mapping_query", func(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + if err := db.Exec(`ALTER TABLE meta_model_data RENAME TO meta_model_data_hidden`).Error; err != nil { + t.Fatalf("rename: %v", err) + } + if err := db.Exec(`CREATE TABLE meta_model_data (broken INTEGER)`).Error; err != nil { + t.Fatalf("create broken table: %v", err) + } + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "load auth.user_admin mapping for unit identity") { + t.Fatalf("error=%v, want user_admin mapping wrap", err) + } + if ok { + t.Fatal("expected ok=false") + } + }) + + t.Run("user_model_lookup_query", func(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + // Break meta_model so First returns a non-NotFound error. + if err := db.Exec(`ALTER TABLE meta_model RENAME TO meta_model_hidden`).Error; err != nil { + t.Fatalf("rename meta_model: %v", err) + } + if err := db.Exec(`CREATE TABLE meta_model (broken INTEGER)`).Error; err != nil { + t.Fatalf("create broken meta_model: %v", err) + } + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "lookup auth.User for unit identity") { + t.Fatalf("error=%v, want auth.User lookup wrap", err) + } + if ok { + t.Fatal("expected ok=false") + } + }) + + t.Run("user_row_query", func(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + if err := db.Model(&meta.Model{}).Where("application = ? AND name = ?", "auth", "User"). + Update("model_table", "missing_user_table_xyz").Error; err != nil { + t.Fatalf("update model_table: %v", err) + } + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "load auth.user_admin row for unit identity") { + t.Fatalf("error=%v, want user row load wrap", err) + } + if ok { + t.Fatal("expected ok=false") + } + }) + + t.Run("company_main_mapping_query", func(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, "").Error; err != nil { + t.Fatalf("insert user: %v", err) + } + if err := db.Create(&modmeta.ModelData{ + Module: "base", Name: "company_main", Application: "base", + ModelName: "Company", ModelId: xid.New().String(), ResID: xid.New().String(), + }).Error; err != nil { + t.Fatalf("create company_main: %v", err) + } + // Fail only the company_main mapping Select (keyed by WHERE clause vars, not ordinal). + if err := db.Callback().Query().Before("gorm:query").Register("identity_fail_company_main", func(tx *gorm.DB) { + if tx.Statement == nil || tx.Statement.Schema == nil { + return + } + if tx.Statement.Schema.Table != (&modmeta.ModelData{}).TableName() { + return + } + if !gormWhereVarsContain(tx, "company_main") { + return + } + _ = tx.AddError(errors.New("forced company_main query failure")) + }); err != nil { + t.Fatalf("register callback: %v", err) + } + t.Cleanup(func() { + _ = db.Callback().Query().Remove("identity_fail_company_main") + }) + _, ok, err := resolveUnitTestDefaultIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "load base.company_main mapping for unit identity") { + t.Fatalf("error=%v, want company_main mapping wrap", err) + } + if ok { + t.Fatal("expected ok=false") + } + }) +} + +func TestUnitTestJsRequestContextShape(t *testing.T) { + ctx := unitTestJsRequestContext(unitTestDefaultIdentity{UserID: "u1", CompanyID: "c1"}) + identity, _ := ctx["identity"].(map[string]interface{}) + if identity["userId"] != "u1" { + t.Fatalf("userId=%v", identity["userId"]) + } + biz, _ := ctx["ctx"].(map[string]interface{}) + if biz["activeCompanyId"] != "c1" { + t.Fatalf("activeCompanyId=%v", biz["activeCompanyId"]) + } + ids, _ := biz["enabledCompanyIds"].([]string) + if len(ids) != 1 || ids[0] != "c1" { + t.Fatalf("enabledCompanyIds=%v", biz["enabledCompanyIds"]) + } + req, _ := ctx["req"].(map[string]interface{}) + if req["depth"] != 0 { + t.Fatalf("depth=%v", req["depth"]) + } +} + +func TestShouldSkipWebShellForUnitApp(t *testing.T) { + cases := []struct { + app string + want bool + }{ + {"auth", false}, + {" AUTH ", false}, + {"web", true}, + {"base", true}, + {"", true}, + } + for _, tc := range cases { + if got := shouldSkipWebShellForUnitApp(tc.app); got != tc.want { + t.Fatalf("shouldSkipWebShellForUnitApp(%q)=%v, want %v", tc.app, got, tc.want) + } + } +} + +type recordingInstaller struct { + calls []lifecycle.InstallRequest + err error +} + +func (r *recordingInstaller) Install(ctx context.Context, req lifecycle.InstallRequest) error { + r.calls = append(r.calls, req) + return r.err +} + +func TestEnsureMetaInstalledForUnitApp(t *testing.T) { + t.Run("skip_base", func(t *testing.T) { + inst := &recordingInstaller{} + if err := ensureMetaInstalledForUnitApp(context.Background(), inst, "base"); err != nil { + t.Fatalf("err=%v", err) + } + if len(inst.calls) != 0 { + t.Fatalf("calls=%v", inst.calls) + } + }) + t.Run("install_web", func(t *testing.T) { + inst := &recordingInstaller{} + if err := ensureMetaInstalledForUnitApp(context.Background(), inst, "web"); err != nil { + t.Fatalf("err=%v", err) + } + if len(inst.calls) != 1 || inst.calls[0].Name != "meta" || !inst.calls[0].SkipWebShell { + t.Fatalf("calls=%+v", inst.calls) + } + }) + t.Run("install_error", func(t *testing.T) { + inst := &recordingInstaller{err: errors.New("install meta failed")} + err := ensureMetaInstalledForUnitApp(context.Background(), inst, "auth") + if err == nil || !strings.Contains(err.Error(), "install meta failed") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestInstallUnitAppModules(t *testing.T) { + t.Run("base_skips_meta", func(t *testing.T) { + inst := &recordingInstaller{} + if err := installUnitAppModules(context.Background(), inst, "base"); err != nil { + t.Fatalf("err=%v", err) + } + if len(inst.calls) != 1 || inst.calls[0].Name != "base" || !inst.calls[0].SkipWebShell { + t.Fatalf("calls=%+v", inst.calls) + } + }) + t.Run("auth_installs_meta", func(t *testing.T) { + inst := &recordingInstaller{} + if err := installUnitAppModules(context.Background(), inst, "auth"); err != nil { + t.Fatalf("err=%v", err) + } + if len(inst.calls) != 2 || inst.calls[0].Name != "auth" || inst.calls[0].SkipWebShell || inst.calls[1].Name != "meta" { + t.Fatalf("calls=%+v", inst.calls) + } + }) + t.Run("app_install_error", func(t *testing.T) { + inst := &recordingInstaller{err: errors.New("app install failed")} + err := installUnitAppModules(context.Background(), inst, "web") + if err == nil || !strings.Contains(err.Error(), "app install failed") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestLoadUnitAppTestContext(t *testing.T) { + t.Run("ok", func(t *testing.T) { + prev := unitTestIdentityContextFn + t.Cleanup(func() { unitTestIdentityContextFn = prev }) + unitTestIdentityContextFn = func(ctx context.Context, testScope scope.Scope) (map[string]interface{}, error) { + return map[string]interface{}{"ok": true}, nil + } + jsCtx, err := loadUnitAppTestContext(context.Background(), nil) + if err != nil || jsCtx["ok"] != true { + t.Fatalf("jsCtx=%v err=%v", jsCtx, err) + } + }) + t.Run("error", func(t *testing.T) { + prev := unitTestIdentityContextFn + t.Cleanup(func() { unitTestIdentityContextFn = prev }) + unitTestIdentityContextFn = func(ctx context.Context, testScope scope.Scope) (map[string]interface{}, error) { + return nil, errors.New("identity boom") + } + _, err := loadUnitAppTestContext(context.Background(), nil) + if err == nil || err.Error() != "identity boom" { + t.Fatalf("err=%v", err) + } + }) +} + +func TestJsContextWithUnitTestIdentity(t *testing.T) { + t.Run("nil_scope", func(t *testing.T) { + jsCtx, err := jsContextWithUnitTestIdentity(context.Background(), nil) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(jsCtx) != 0 { + t.Fatalf("jsCtx=%v", jsCtx) + } + }) + t.Run("identity_error", func(t *testing.T) { + db := openIdentityTestDB(t) + if err := db.Exec(`CREATE TABLE meta_module (id TEXT)`).Error; err != nil { + t.Fatalf("create broken meta_module: %v", err) + } + _, err := jsContextWithUnitTestIdentity(context.Background(), newIdentityTestScope(t, db)) + if err == nil || !strings.Contains(err.Error(), "resolve unit test default identity") { + t.Fatalf("err=%v", err) + } + }) + t.Run("ok_inject", func(t *testing.T) { + db := openIdentityTestDB(t) + migrateIdentityTables(t, db) + seedAuthInstalled(t, db) + userID := xid.New().String() + companyID := xid.New().String() + seedUserAdminMapping(t, db, userID) + table := "auth_user_identity" + seedAuthUserModel(t, db, table) + createUserTable(t, db, table) + if err := db.Exec(`INSERT INTO "`+table+`" (id, company_id) VALUES (?, ?)`, userID, companyID).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + jsCtx, err := jsContextWithUnitTestIdentity(context.Background(), newIdentityTestScope(t, db)) + if err != nil { + t.Fatalf("err=%v", err) + } + identity, _ := jsCtx["identity"].(map[string]interface{}) + if identity["userId"] != userID { + t.Fatalf("userId=%v", identity["userId"]) + } + }) +} + +func TestShouldInstallMetaForUnitApp(t *testing.T) { + cases := []struct { + app string + want bool + }{ + {"auth", true}, + {" AUTH ", true}, + {"web", true}, + {" Web ", true}, + {"base", false}, + {"", false}, + } + for _, tc := range cases { + if got := shouldInstallMetaForUnitApp(tc.app); got != tc.want { + t.Fatalf("shouldInstallMetaForUnitApp(%q)=%v, want %v", tc.app, got, tc.want) + } + } +} diff --git a/internal/testing/e2e/runner.go b/internal/testing/e2e/runner.go index 7b4fe3ff3..3645613aa 100644 --- a/internal/testing/e2e/runner.go +++ b/internal/testing/e2e/runner.go @@ -539,11 +539,12 @@ compile: return err } } - // Apply fixtures for closure (each module may contribute its own fixtures for this scenario). fixtureClosure := append([]string{}, closure...) if authEnabled { - fixtureClosure = append(fixtureClosure, "auth") + // auth e2e/smoke.json refs base.e2e_company_child; meta's package depends omit base, + // so ensure base fixtures are applied before auth when auth is force-included. + fixtureClosure = append(fixtureClosure, "base", "auth") } uniqueFixtures := make([]string, 0, len(fixtureClosure)) seen := map[string]bool{} @@ -827,7 +828,8 @@ module.exports = { workers: 1, timeout: 60_000, expect: { timeout: 10_000 }, - retries: 0, + // One retry absorbs intermittent sqlite "database is locked" under WAL. + retries: 1, use: { trace: 'retain-on-failure' }, }; `, specsDir, filepath.Join(runDir, ".playwright", "test-results")) diff --git a/modules/README.md b/modules/README.md index 2fed1a654..90a1a3741 100644 --- a/modules/README.md +++ b/modules/README.md @@ -1 +1,23 @@ -- this directory contains the modules written by ts +# Modules + +TypeScript modules for the Choysum ERP platform (service + web entry points). + +## Data seed ownership + +Preset rows live in each module’s `data/` (install) and optional `demo/` (`--with-demo`). + +1. **Master / business rows** → module that owns the model (e.g. `base.Company` → `base/data`). +2. **Domain-targeted authz** → the **domain** module that owns the model (or domain-owned logical name). Seed `auth.Role*` with `application: "auth"`; xml_id stays under the applying module (example: `web` bootstrap → `web.SavedFilter` record rules). +3. **Platform roles, global break-glass, and platform LogicalModel defaults** → `auth/data` only (`base.user`, `sys.admin`, global grants, auth User/Token/Session packs, and `FieldDefault` / `AppSetting` / `TranslationTerm` logical RMA/RFR). +4. Domain modules may seed into auth models only if they install **after** auth (`depends: ["auth", …]` and auth does not depend on them). `base` / `meta` install before auth — their app-level gift packs remain in auth until a late-apply path exists. +5. **Do not** add new **domain-model** RR/RFR/RMA into `auth/data`; follow the web SavedFilter pattern. Platform logical defaults (item 3) stay in auth. + +### Web SPA shell + +Do **not** list `web` in domain `depends`. The web shell itself depends on `document` (binary/image attachment pipeline). The planner pulls the shell when any module in the install/upgrade plan declares `entryPoints.web`: + +- **Install:** merges `web` (and its depends) into `ModuleOrder` if web is not already installed. +- **Upgrade:** if web is missing, installs it via `EnsureOrder` (does not upgrade an already-installed web); always rebuilds `dist/web` when needed. +- **`--no-web`:** skip that auto-include (headless / API-only installs). + +Loader notes: `module` must be the applying module; `application` may be cross-app; `model` is a short name; use `ref` / `modelRef` / `refBy` for links. diff --git a/modules/auth/e2e/switch_company_scope.spec.ts b/modules/auth/e2e/switch_company_scope.spec.ts index 32e0a2937..025790ceb 100644 --- a/modules/auth/e2e/switch_company_scope.spec.ts +++ b/modules/auth/e2e/switch_company_scope.spec.ts @@ -76,17 +76,21 @@ test('auth: switch company → new TokenPair → refresh PermissionState → hea await expect.poll(async () => await options.count(), { timeout: 10_000 }).toBeGreaterThan(1); const count = await options.count(); expect(count, 'need at least 2 companies in selector').toBeGreaterThan(1); + let picked = false; for (let i = 0; i < count; i++) { const opt = options.nth(i); const selected = await opt.getAttribute('aria-selected'); if (selected === 'true') continue; await opt.click(); + picked = true; break; } + expect(picked, 'expected an unselected company option to click').toBe(true); - // Apply + // Apply (poll so a late open-sync refresh cannot race past a one-shot toBeEnabled). const applyButton = page.getByTestId('company-switch-apply'); - await expect(applyButton).toBeEnabled(); + await expect.poll(async () => await applyButton.isEnabled(), { timeout: 15_000 }).toBe(true); + await expect(page.getByTestId('company-switch-hint')).toHaveCount(0); // Hard assertions: switching should call the RPC(s) successfully. const switchOk = waitForGrpcWebUnaryOk(page, '/auth.User/SwitchCompanyScope', { timeoutMs: 30_000 }); diff --git a/modules/auth/e2e/switch_company_scope_acceptance.spec.ts b/modules/auth/e2e/switch_company_scope_acceptance.spec.ts index d38c0ff3f..71266fe68 100644 --- a/modules/auth/e2e/switch_company_scope_acceptance.spec.ts +++ b/modules/auth/e2e/switch_company_scope_acceptance.spec.ts @@ -122,8 +122,8 @@ function extractCompanyScopeFromToken(accessToken: string): { activeCompanyId: s return { active: (node as any).activeCompanyId, enabled: (node as any).enabledCompanyIds }; } - // Common nesting patterns - for (const key of ['metadata', 'identity', 'claims', 'data']) { + // Common nesting patterns. Access tokens use `meta` (see auth store extractIdentity). + for (const key of ['meta', 'metadata', 'identity', 'claims', 'data']) { if (node && typeof node[key] === 'object') { const hit = search(node[key]); if (hit.active !== undefined || hit.enabled !== undefined) return hit; @@ -289,7 +289,8 @@ async function switchCompanyViaUI(page: any): Promise { } const applyButton = page.getByTestId('company-switch-apply'); - await expect(applyButton).toBeEnabled(); + await expect.poll(async () => await applyButton.isEnabled(), { timeout: 15_000 }).toBe(true); + await expect(page.getByTestId('company-switch-hint')).toHaveCount(0); await applyButton.click(); } @@ -304,15 +305,19 @@ async function discoverTwoCompanyIdsByUISwitch(page: any): Promise<{ a: string; await switchCompanyViaUI(page); + // Opening the switcher refreshes the access token; wait for active company change, + // not merely a new token string. await expect .poll( async () => { const after = await readAuthTokens(page); - return after.accessToken; + const next = extractCompanyScopeFromToken(after.accessToken).activeCompanyId; + // Ignore empty IDs from mid-refresh token reads. + return next && next !== scopeA.activeCompanyId ? next : scopeA.activeCompanyId; }, { timeout: 30_000 } ) - .not.toBe(before.accessToken); + .not.toBe(scopeA.activeCompanyId); const after = await readAuthTokens(page); if (!after.accessToken) return null; diff --git a/modules/auth/package.json b/modules/auth/package.json index 2a0c2a335..7c8a8bf32 100644 --- a/modules/auth/package.json +++ b/modules/auth/package.json @@ -22,8 +22,7 @@ "depends": [ "core", "base", - "meta", - "web" + "meta" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/auth/service/models/_resolve_effective_model.ts b/modules/auth/service/models/_resolve_effective_model.ts deleted file mode 100644 index 995147378..000000000 --- a/modules/auth/service/models/_resolve_effective_model.ts +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: Apache-2.0 - -import { createServiceByModel } from '@/core/service/rpc'; -import type MetaApplicationModel from '@/meta/service/models/application'; -import type MetaModelModel from '@/meta/service/models/model'; - -const MetaApplication = createServiceByModel('meta.MetaApplication'); -const MetaModel = createServiceByModel('meta.MetaModel'); - -function moduleIdEmpty(row: any): boolean { - const raw = row.ModuleId ?? row.module_id ?? row.ModuleID; - if (raw == null) return true; - if (raw === '') return true; - if (typeof raw === 'object') { - const id = (raw as any).Id ?? (raw as any).id; - if (id == null) return true; - return String(id).trim() === ''; - } - return String(raw).trim() === ''; -} - -function rowId(row: any): string { - if (row == null) return ''; - return String(row.Id ?? row.id ?? '').trim(); -} - -function rowUpdatedAt(row: any): number { - let raw: any = row.UpdatedAt; - if (raw == null) { - raw = row.updated_at; - } - if (raw == null) return 0; - if (raw === '') return 0; - if (typeof raw === 'number') return raw; - const ms = Date.parse(String(raw)); - if (!Number.isFinite(ms)) return 0; - return ms; -} - -/** Align with Go pickEffectiveAmong: empty ModuleId first, then newest UpdatedAt, then larger Id. */ -function pickEffectiveAmong(rows: any[]): any { - let best = rows[0]; - for (let i = 1; i < rows.length; i++) { - const row = rows[i]; - const empty = moduleIdEmpty(row); - const bestEmpty = moduleIdEmpty(best); - if (empty && !bestEmpty) { - best = row; - continue; - } - if (empty === bestEmpty) { - const rowTs = rowUpdatedAt(row); - const bestTs = rowUpdatedAt(best); - if (rowTs > bestTs) { - best = row; - } else if (rowTs === bestTs) { - if (rowId(row) > rowId(best)) { - best = row; - } - } - } - } - return best; -} - -/** - * Resolve the single effective MetaModel id for (application, name). - * Prefers empty ModuleId (E2 projection) over legacy declaration shells. - */ -export async function resolveEffectiveModelId(appName: string, modelName: string): Promise { - const hit = await resolveEffectiveModelRow(appName, modelName, ['Id', 'ModuleId', 'UpdatedAt']); - return String(hit?.Id || '').trim(); -} - -/** - * Resolve the effective MetaModel row (optional extra fields). - * Always fetches Id/ModuleId/UpdatedAt so shell vs effective selection stays correct - * even when callers omit those columns from `fields`. - */ -export async function resolveEffectiveModelRow( - appName: string, - modelName: string, - fields: string[] = ['Id', 'ModuleId', 'UpdatedAt', 'CompanyField'] -): Promise { - const selectedFields = Array.from(new Set(['Id', 'ModuleId', 'UpdatedAt', ...fields])); - const pageSize = 500; - const models: any[] = []; - for (let offset = 0; ; offset += pageSize) { - const page = await MetaModel.Search( - { - And: [ - ['Name', '=', modelName], - ['Application', '=', appName], - ], - } as any, - { - fields: selectedFields, - orderBy: { field: 'UpdatedAt', order: 'desc' }, - limit: pageSize, - offset, - } as any - ); - const batch = (page as any[]) || []; - models.push(...batch); - if (batch.length < pageSize) break; - } - const rows = models.filter((m: any) => rowId(m)); - if (rows.length === 0) return undefined; - if (rows.length === 1) return rows[0]; - return pickEffectiveAmong(rows); -} - -/** - * Resolve meta.MetaApplication id by name (single row). - */ -export async function resolveEffectiveApplicationId(appName: string): Promise { - const apps = await MetaApplication.Search(['Name', '=', appName] as any, { - fields: ['Id', 'UpdatedAt'], - orderBy: { field: 'UpdatedAt', order: 'desc' }, - limit: 1, - } as any); - return String((apps as any)?.[0]?.Id || '').trim(); -} diff --git a/modules/auth/service/models/_user_field_rule_eval.ts b/modules/auth/service/models/_user_field_rule_eval.ts index ed1fcad41..5586feedb 100644 --- a/modules/auth/service/models/_user_field_rule_eval.ts +++ b/modules/auth/service/models/_user_field_rule_eval.ts @@ -6,11 +6,14 @@ import { getCurrentReq, getOrInitReqServiceState, memoizeInReqState } from '@/co import { newAuthError, AuthErrCode, GrpcCode } from '../error'; import { _t } from '../i18n'; import RoleFieldRule from './role_field_rule'; +import type MetaApplicationModel from '@/meta/service/models/application'; import type MetaFieldModel from '@/meta/service/models/field'; +import type MetaModelModel from '@/meta/service/models/model'; import { normalizeRefId } from '@/core/service/utils/normalization'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from './_resolve_effective_model'; +const MetaApplication = createServiceByModel('meta.MetaApplication'); const MetaField = createServiceByModel('meta.MetaField'); +const MetaModel = createServiceByModel('meta.MetaModel'); function normalizeFieldPerm(v: any): 'allow' | 'deny' | null { if (v == null) return null; @@ -71,21 +74,33 @@ function buildFieldRuleMetaCacheKey(type: 'app' | 'model', appName: string, mode } /** - * Resolve meta application id by name (single effective row). + * Resolve meta application id by name (unique live row). */ async function resolveApplicationId(appName: string): Promise { const state = getFieldRuleReqState(); const key = buildFieldRuleMetaCacheKey('app', appName); - return await memoizeInReqState(state, key, async () => resolveEffectiveApplicationId(appName)); + return await memoizeInReqState(state, key, async () => { + const rows = await MetaApplication.Search(['Name', '=', appName] as any, { + fields: ['Id'], + limit: 1, + } as any); + return String(rows?.[0]?.Id || '').trim(); + }); } /** - * Resolve the single effective meta model id for (application, name). + * Resolve the unique live meta model id for (application, name). */ async function resolveModelId(appName: string, modelName: string): Promise { const state = getFieldRuleReqState(); const key = buildFieldRuleMetaCacheKey('model', appName, modelName); - return await memoizeInReqState(state, key, async () => resolveEffectiveModelId(appName, modelName)); + return await memoizeInReqState(state, key, async () => { + const rows = await MetaModel.Search( + { And: [['Application', '=', appName], ['Name', '=', modelName]] } as any, + { fields: ['Id'], limit: 1 } as any + ); + return String(rows?.[0]?.Id || '').trim(); + }); } function denyAllNonSystemFields(fieldNames: string[], reason: string, hitRuleIds?: string[]): FieldRuleEvalResult { diff --git a/modules/auth/service/models/_user_method_access.ts b/modules/auth/service/models/_user_method_access.ts index 0e7b94214..5c983151b 100644 --- a/modules/auth/service/models/_user_method_access.ts +++ b/modules/auth/service/models/_user_method_access.ts @@ -3,6 +3,8 @@ import { getCurrentReq, getOrInitReqServiceState, memoizeInReqState } from '@/core/service/api/context'; import { createServiceByModel } from '@/core/service/rpc'; +import type MetaApplicationModel from '@/meta/service/models/application'; +import type MetaModelModel from '@/meta/service/models/model'; import type MetaServiceModel from '@/meta/service/models/service'; import MetaUiResource from '@/meta/service/models/ui_resource'; import { uniqStrings } from '@/core/service/utils/normalization'; @@ -11,10 +13,27 @@ import RoleMethodAccess from './role_method_access'; import RoleUiResource from './role_ui_resource'; import { normalizeScopeRefId, normalizeUiResourceId, parseJsonStringArray, requireMatchesMethod, sortStrings } from './_user_authz_shared'; import { logicalMethodsAllow } from './_logical_model_registry'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from './_resolve_effective_model'; +const MetaApplication = createServiceByModel('meta.MetaApplication'); +const MetaModel = createServiceByModel('meta.MetaModel'); const MetaService = createServiceByModel('meta.MetaService'); +async function metaModelId(appName: string, modelName: string): Promise { + const rows = await MetaModel.Search( + { And: [['Application', '=', appName], ['Name', '=', modelName]] } as any, + { fields: ['Id'], limit: 1 } as any + ); + return String(rows?.[0]?.Id || '').trim(); +} + +async function metaApplicationId(appName: string): Promise { + const rows = await MetaApplication.Search(['Name', '=', appName] as any, { + fields: ['Id'], + limit: 1, + } as any); + return String(rows?.[0]?.Id || '').trim(); +} + export type UiGrantExpansion = { resources: any[]; hasGlobalAllow: boolean; @@ -64,7 +83,7 @@ export async function resolveMethodAccessMeta( const state = getMethodAccessReqState(); const key = buildMethodAccessMetaCacheKey(appName, modelName, methodName); return await memoizeInReqState(state, key, async () => { - const modelId = await resolveEffectiveModelId(appName, modelName); + const modelId = await metaModelId(appName, modelName); if (!modelId) return undefined; const serviceRows = await MetaService.Search({ And: [['ModelId', '=', modelId]] } as any, { fields: ['Id', 'Name'], limit: 5000 } as any); @@ -80,7 +99,7 @@ export async function resolveMethodAccessMeta( const irServiceId = String(matched?.Id || '').trim(); if (!irServiceId) return undefined; - const irApplicationId = await resolveEffectiveApplicationId(appName); + const irApplicationId = await metaApplicationId(appName); const scopeOr: any[] = [ { And: [ diff --git a/modules/auth/service/models/_user_record_rule_eval.ts b/modules/auth/service/models/_user_record_rule_eval.ts index bc812a796..06f3dc5ff 100644 --- a/modules/auth/service/models/_user_record_rule_eval.ts +++ b/modules/auth/service/models/_user_record_rule_eval.ts @@ -4,13 +4,16 @@ import { getCurrentReq, getOrInitReqServiceState, memoizeInReqState } from '@/core/service/api/context'; import { createServiceByModel } from '@/core/service/rpc'; import type { ConditionEnvelope, RecordRuleOp } from '@/core/service/api/authz'; +import type MetaApplicationModel from '@/meta/service/models/application'; import type MetaFieldModel from '@/meta/service/models/field'; +import type MetaModelModel from '@/meta/service/models/model'; import RoleRecordRule from './role_record_rule'; import type { RoleRecordRuleKind } from './role_record_rule'; import { maybeId, withPermissionGraphBypass } from './_user_authz_shared'; -import { resolveEffectiveApplicationId, resolveEffectiveModelRow } from './_resolve_effective_model'; +const MetaApplication = createServiceByModel('meta.MetaApplication'); const MetaField = createServiceByModel('meta.MetaField'); +const MetaModel = createServiceByModel('meta.MetaModel'); type RoleScope = { global: boolean; companies: string[] }; @@ -43,10 +46,15 @@ async function resolveRecordRuleMetaCached(appName: string, modelName: string): const state = getRecordRuleReqState(); const key = buildRecordRuleMetaCacheKey(appName, modelName); return await memoizeInReqState(state, key, async () => { - const [irApplicationId, modelHit] = await Promise.all([ - resolveEffectiveApplicationId(appName), - resolveEffectiveModelRow(appName, modelName, ['Id', 'CompanyField', 'ModuleId', 'UpdatedAt']), + const [appRows, modelRows] = await Promise.all([ + MetaApplication.Search(['Name', '=', appName] as any, { fields: ['Id'], limit: 1 } as any), + MetaModel.Search( + { And: [['Application', '=', appName], ['Name', '=', modelName]] } as any, + { fields: ['Id', 'CompanyField'], limit: 1 } as any + ), ]); + const irApplicationId = String(appRows?.[0]?.Id || '').trim(); + const modelHit = modelRows?.[0]; const modelId = String(modelHit?.Id || '').trim(); return { irApplicationId, modelHit, modelId }; }); diff --git a/modules/auth/service/tests/_meta_ids.ts b/modules/auth/service/tests/_meta_ids.ts new file mode 100644 index 000000000..46288292d --- /dev/null +++ b/modules/auth/service/tests/_meta_ids.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { createServiceByModel } from '@/core/service/rpc'; +import type MetaApplicationModel from '@/meta/service/models/application'; +import type MetaModelModel from '@/meta/service/models/model'; + +const MetaApplication = createServiceByModel('meta.MetaApplication'); +const MetaModel = createServiceByModel('meta.MetaModel'); + +/** Test helper: unique live meta_model id for (application, name). */ +export async function metaModelId(appName: string, modelName: string): Promise { + const rows = await MetaModel.Search( + { And: [['Application', '=', appName], ['Name', '=', modelName]] } as any, + { fields: ['Id'], limit: 1 } as any + ); + return String(rows?.[0]?.Id || '').trim(); +} + +/** Test helper: unique live meta_application id by name. */ +export async function metaApplicationId(appName: string): Promise { + const rows = await MetaApplication.Search(['Name', '=', appName] as any, { + fields: ['Id'], + limit: 1, + } as any); + return String(rows?.[0]?.Id || '').trim(); +} diff --git a/modules/auth/service/tests/authz_context_memoization.test.ts b/modules/auth/service/tests/authz_context_memoization.test.ts index 6c6b0d53c..90f325193 100644 --- a/modules/auth/service/tests/authz_context_memoization.test.ts +++ b/modules/auth/service/tests/authz_context_memoization.test.ts @@ -11,7 +11,7 @@ import RoleInheritance from '@/auth/service/models/role_inheritance'; import { evaluateRecordRuleCondition } from '@/auth/service/models/_user_record_rule_eval'; import { evaluateFieldRules } from '@/auth/service/models/_user_field_rule_eval'; import { resolveMethodAccessMeta } from '@/auth/service/models/_user_method_access'; -import { resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaModelId } from './_meta_ids'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaApplicationModel from '@/meta/service/models/application'; import type MetaModelModel from '@/meta/service/models/model'; @@ -167,7 +167,7 @@ async function createRole(codePrefix: string): Promise<{ id: string; code: strin } async function resolveModelId(app: string, name: string): Promise { - const id = await resolveEffectiveModelId(app, name); + const id = await metaModelId(app, name); if (!id) throw new Error(`meta model not found: ${app}.${name}`); return id; } diff --git a/modules/auth/service/tests/authz_mutation_crud_coverage.test.ts b/modules/auth/service/tests/authz_mutation_crud_coverage.test.ts index 5372cb331..f5689fa19 100644 --- a/modules/auth/service/tests/authz_mutation_crud_coverage.test.ts +++ b/modules/auth/service/tests/authz_mutation_crud_coverage.test.ts @@ -12,7 +12,7 @@ import RoleFieldRule from '@/auth/service/models/role_field_rule'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaServiceModel from '@/meta/service/models/service'; import type MetaFieldModel from '@/meta/service/models/field'; -import { resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaModelId } from './_meta_ids'; import { ensureRequestContext, resetRequestContext, uid } from '@/auth/service/tests/_request_context_fixtures'; const MetaService = createServiceByModel('meta.MetaService'); @@ -133,7 +133,7 @@ async function createRole(codePrefix: string): Promise<{ id: string }> { } async function resolveModelId(app: string, name: string): Promise { - const id = await resolveEffectiveModelId(app, name); + const id = await metaModelId(app, name); if (!id) throw new Error(`meta model not found: ${app}.${name}`); return id; } diff --git a/modules/auth/service/tests/bootstrap_gift_pack.test.ts b/modules/auth/service/tests/bootstrap_gift_pack.test.ts index 3c22634da..9a02ddda4 100644 --- a/modules/auth/service/tests/bootstrap_gift_pack.test.ts +++ b/modules/auth/service/tests/bootstrap_gift_pack.test.ts @@ -9,9 +9,11 @@ import RoleRecordRule from '@/auth/service/models/role_record_rule'; import RoleFieldRule from '@/auth/service/models/role_field_rule'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaFieldModel from '@/meta/service/models/field'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import type MetaModelDataModel from '@/meta/service/models/model_data'; +import { metaApplicationId, metaModelId } from './_meta_ids'; const MetaField = createServiceByModel('meta.MetaField'); +const MetaModelData = createServiceByModel('meta.MetaModelData'); const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); const FR_CACHE_KEY = Symbol.for('choysum.fieldrule.cache'); @@ -136,7 +138,7 @@ async function resolveUserByUsername(username: string): Promise<{ id: string; co } async function resolveApplicationId(name: string): Promise { - const id = await resolveEffectiveApplicationId(name); + const id = await metaApplicationId(name); if (!id) throw new Error(`MetaApplication not found: ${name}`); return id; } @@ -157,6 +159,30 @@ async function createBareUser(companyId: string): Promise { return String((created as any)?.Id || '').trim(); } +test('auth bootstrap seeds platform FieldDefault/AppSetting logical packs', async () => { + resetRequestContext(); + const expected: Array<{ name: string; model: string }> = [ + { name: 'rma_base_user_field_default_logical', model: 'RoleMethodAccess' }, + { name: 'rfr_base_user_field_default_logical', model: 'RoleFieldRule' }, + { name: 'rma_sys_admin_app_setting_logical', model: 'RoleMethodAccess' }, + { name: 'rfr_sys_admin_app_setting_logical', model: 'RoleFieldRule' }, + ]; + for (const { name, model } of expected) { + const rows = await MetaModelData.Search( + { + And: [ + ['Module', '=', 'auth'], + ['Name', '=', name], + ], + } as any, + { fields: ['Id', 'Application', 'ModelName'], limit: 1 } as any + ); + expect(Array.isArray(rows) && rows.length === 1, `missing auth.${name}`).toBe(true); + expect(String((rows as any)[0].Application)).toBe('auth'); + expect(String((rows as any)[0].ModelName)).toBe(model); + } +}); + test('PR-C-2 gift pack: bootstrap seeds sys.admin global RR+FR and base.user app packs', async () => { resetRequestContext(); setupAllowlistForFixtures(); @@ -203,7 +229,7 @@ test('PR-C-2 gift pack: bootstrap seeds sys.admin global RR+FR and base.user app ); expect((sysAdminFr || []).length > 0).toBe(true); - const userModelId = await resolveEffectiveModelId('auth', 'User'); + const userModelId = await metaModelId('auth', 'User'); expect(Boolean(userModelId)).toBe(true); const passwordFieldRows = await MetaField.Search( { @@ -265,7 +291,7 @@ test('PR-C-2 gift pack: bootstrap seeds sys.admin global RR+FR and base.user app // Token/Session self-service grants must be owner-scoped (not TRUE). for (const modelName of ['Token', 'Session']) { - const modelId = await resolveEffectiveModelId('auth', modelName); + const modelId = await metaModelId('auth', modelName); expect(Boolean(modelId)).toBe(true); const ownerRr = await RoleRecordRule.Search( { diff --git a/modules/auth/service/tests/check_method_access_company_scope.test.ts b/modules/auth/service/tests/check_method_access_company_scope.test.ts index e3ae14c59..c9f0af33a 100644 --- a/modules/auth/service/tests/check_method_access_company_scope.test.ts +++ b/modules/auth/service/tests/check_method_access_company_scope.test.ts @@ -9,7 +9,7 @@ import UserRole from '@/auth/service/models/user_role'; import RoleMethodAccess from '@/auth/service/models/role_method_access'; import RoleUiResource from '@/auth/service/models/role_ui_resource'; import { evaluateUiDerivedMethodDecision } from '@/auth/service/models/_user_method_access'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaApplicationId, metaModelId } from './_meta_ids'; import MetaUiResource from '@/meta/service/models/ui_resource'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaServiceModel from '@/meta/service/models/service'; @@ -139,7 +139,7 @@ function disableAllowlist(): void { } async function resolveModelId(app: string, name: string): Promise { - const id = await resolveEffectiveModelId(app, name); + const id = await metaModelId(app, name); if (!id) throw new Error(`meta model not found: ${app}.${name}`); return id; } @@ -171,7 +171,7 @@ async function resolveService(modelId: string, serviceName: string): Promise<{ i } async function resolveApplicationId(appName: string): Promise { - const id = await resolveEffectiveApplicationId(appName); + const id = await metaApplicationId(appName); if (!id) throw new Error(`meta application not found: ${appName}`); return id; } diff --git a/modules/auth/service/tests/check_method_access_diagnostics.test.ts b/modules/auth/service/tests/check_method_access_diagnostics.test.ts index 3f08a9966..5d7194b4e 100644 --- a/modules/auth/service/tests/check_method_access_diagnostics.test.ts +++ b/modules/auth/service/tests/check_method_access_diagnostics.test.ts @@ -9,7 +9,7 @@ import RoleMethodAccess from '@/auth/service/models/role_method_access'; import RoleUiResource from '@/auth/service/models/role_ui_resource'; import { evaluateUiDerivedMethodDecision } from '@/auth/service/models/_user_method_access'; import { buildMethodAccessCacheKey } from '@/auth/service/models/_request_cache_invalidation'; -import { resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaModelId } from './_meta_ids'; import MetaUiResource from '@/meta/service/models/ui_resource'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaServiceModel from '@/meta/service/models/service'; @@ -155,7 +155,7 @@ async function createRole(): Promise { } async function resolveBrowse(): Promise<{ name: string; modelId: string; serviceId: string }> { - const modelId = await resolveEffectiveModelId('auth', 'User'); + const modelId = await metaModelId('auth', 'User'); const services = await MetaService.Search({ And: [['ModelId', '=', modelId]] } as any, { fields: ['Id', 'Name'], limit: 5000, diff --git a/modules/auth/service/tests/field_rule.test.ts b/modules/auth/service/tests/field_rule.test.ts index aecdbf19e..92d84a4da 100644 --- a/modules/auth/service/tests/field_rule.test.ts +++ b/modules/auth/service/tests/field_rule.test.ts @@ -10,7 +10,7 @@ import Role from '@/auth/service/models/role'; import UserRole from '@/auth/service/models/user_role'; import RoleFieldRule from '@/auth/service/models/role_field_rule'; import { evaluateFieldRules } from '@/auth/service/models/_user_field_rule_eval'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaApplicationId, metaModelId } from './_meta_ids'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaFieldModel from '@/meta/service/models/field'; const MetaField = createServiceByModel('meta.MetaField'); @@ -205,13 +205,13 @@ function toChoysumErrorLike(err: any): { domain?: string; code?: string; message } async function resolveModelId(appName: string, modelName: string): Promise { - const id = await resolveEffectiveModelId(appName, modelName); + const id = await metaModelId(appName, modelName); if (!id) throw new Error(`meta model not found: app=${appName} model=${modelName}`); return id; } async function resolveApplicationId(appName: string): Promise { - const id = await resolveEffectiveApplicationId(appName); + const id = await metaApplicationId(appName); if (!id) throw new Error(`meta application not found: name=${appName}`); return id; } diff --git a/modules/auth/service/tests/method_access_meta_lookup.test.ts b/modules/auth/service/tests/method_access_meta_lookup.test.ts new file mode 100644 index 000000000..0fbae4b90 --- /dev/null +++ b/modules/auth/service/tests/method_access_meta_lookup.test.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { resolveMethodAccessMeta } from '@/auth/service/models/_user_method_access'; +import { createServiceByModel } from '@/core/service/rpc'; +import type MetaApplicationModel from '@/meta/service/models/application'; +import type MetaModelModel from '@/meta/service/models/model'; +import type MetaServiceModel from '@/meta/service/models/service'; + +const MetaModel = createServiceByModel('meta.MetaModel'); +const MetaApplication = createServiceByModel('meta.MetaApplication'); +const MetaService = createServiceByModel('meta.MetaService'); + +test('resolveMethodAccessMeta returns undefined when MetaModel has no id', async () => { + const orig = (MetaModel as any).Search; + try { + (MetaModel as any).Search = async () => []; + expect(await resolveMethodAccessMeta('auth', 'User', 'Browse')).toBeUndefined(); + + (MetaModel as any).Search = async () => [{}]; + expect(await resolveMethodAccessMeta('auth', 'User', 'Browse')).toBeUndefined(); + + (MetaModel as any).Search = async () => null; + expect(await resolveMethodAccessMeta('auth', 'User', 'Browse')).toBeUndefined(); + } finally { + (MetaModel as any).Search = orig; + } +}); + +test('resolveMethodAccessMeta skips application scope when MetaApplication id empty', async () => { + const origModel = (MetaModel as any).Search; + const origApp = (MetaApplication as any).Search; + const origService = (MetaService as any).Search; + try { + (MetaModel as any).Search = async () => [{ Id: 'm1' }]; + (MetaService as any).Search = async () => [{ Id: 's1', Name: 'Browse' }]; + (MetaApplication as any).Search = async () => []; + + const meta = await resolveMethodAccessMeta('auth', 'User', 'Browse'); + expect(meta).toMatchObject({ modelId: 'm1', irServiceId: 's1', irApplicationId: '' }); + expect(JSON.stringify(meta!.scopeOr).includes('"MetaApplicationId","=","')).toBe(false); + + (MetaApplication as any).Search = async () => [{ Id: '' }]; + const metaEmpty = await resolveMethodAccessMeta('demo', 'Widget', 'Browse'); + expect(metaEmpty?.irApplicationId).toBe(''); + } finally { + (MetaModel as any).Search = origModel; + (MetaApplication as any).Search = origApp; + (MetaService as any).Search = origService; + } +}); + +test('resolveMethodAccessMeta inserts application scope when MetaApplication id present', async () => { + const origModel = (MetaModel as any).Search; + const origApp = (MetaApplication as any).Search; + const origService = (MetaService as any).Search; + try { + (MetaModel as any).Search = async () => [{ Id: 'm1' }]; + (MetaService as any).Search = async () => [{ Id: 's1', Name: 'Browse' }]; + (MetaApplication as any).Search = async () => [{ Id: 'a1' }]; + + const meta = await resolveMethodAccessMeta('auth', 'User', 'Browse'); + expect(meta?.irApplicationId).toBe('a1'); + expect(meta!.scopeOr.some((c: any) => Array.isArray(c.And) && c.And.some((t: any) => t[0] === 'MetaApplicationId' && t[2] === 'a1'))).toBe( + true + ); + } finally { + (MetaModel as any).Search = origModel; + (MetaApplication as any).Search = origApp; + (MetaService as any).Search = origService; + } +}); diff --git a/modules/auth/service/tests/permission_state.test.ts b/modules/auth/service/tests/permission_state.test.ts index 88687fa8e..40d3269e8 100644 --- a/modules/auth/service/tests/permission_state.test.ts +++ b/modules/auth/service/tests/permission_state.test.ts @@ -14,7 +14,7 @@ import MetaUiResourceMenuRoute from '@/meta/service/models/ui_resource_menu_rout import MetaUiResourceRouteAction from '@/meta/service/models/ui_resource_route_action'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaServiceModel from '@/meta/service/models/service'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaApplicationId, metaModelId } from './_meta_ids'; const MetaService = createServiceByModel('meta.MetaService'); const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); @@ -148,7 +148,7 @@ function busyWait(ms: number): void { } async function resolveModelId(appName: string, modelName: string): Promise { - const id = await resolveEffectiveModelId(appName, modelName); + const id = await metaModelId(appName, modelName); if (!id) throw new Error(`meta model not found: ${appName}.${modelName}`); return id; } @@ -200,7 +200,7 @@ async function resolveService(modelId: string, serviceName: string): Promise<{ i } async function resolveApplicationId(appName: string): Promise { - const id = await resolveEffectiveApplicationId(appName); + const id = await metaApplicationId(appName); if (!id) throw new Error(`meta application not found: ${appName}`); return id; } diff --git a/modules/auth/service/tests/record_rule.test.ts b/modules/auth/service/tests/record_rule.test.ts index c1da90ab8..23516234d 100644 --- a/modules/auth/service/tests/record_rule.test.ts +++ b/modules/auth/service/tests/record_rule.test.ts @@ -9,7 +9,7 @@ import User from '@/auth/service/models/user'; import Role from '@/auth/service/models/role'; import UserRole from '@/auth/service/models/user_role'; import RoleRecordRule from '@/auth/service/models/role_record_rule'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaApplicationId, metaModelId } from './_meta_ids'; const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); const FR_CACHE_KEY = Symbol.for('choysum.fieldrule.cache'); @@ -136,13 +136,13 @@ function toChoysumErrorLike(err: any): { domain?: string; code?: string; message } async function resolveModelId(appName: string, modelName: string): Promise { - const id = await resolveEffectiveModelId(appName, modelName); + const id = await metaModelId(appName, modelName); if (!id) throw new Error(`meta model not found: ${appName}.${modelName}`); return id; } async function resolveApplicationId(appName: string): Promise { - const id = await resolveEffectiveApplicationId(appName); + const id = await metaApplicationId(appName); if (!id) throw new Error(`meta application not found: ${appName}`); return id; } diff --git a/modules/auth/service/tests/record_rule_eval_edges.test.ts b/modules/auth/service/tests/record_rule_eval_edges.test.ts index b7b781533..607fc76d3 100644 --- a/modules/auth/service/tests/record_rule_eval_edges.test.ts +++ b/modules/auth/service/tests/record_rule_eval_edges.test.ts @@ -3,7 +3,7 @@ import RoleRecordRule from '@/auth/service/models/role_record_rule'; import { buildCompanyGateExpr, evaluateRecordRuleCondition } from '@/auth/service/models/_user_record_rule_eval'; -import { resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaModelId } from './_meta_ids'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaApplicationModel from '@/meta/service/models/application'; import type MetaFieldModel from '@/meta/service/models/field'; @@ -46,7 +46,7 @@ function uid(prefix: string): string { } async function resolveModelId(appName: string, modelName: string): Promise { - const id = await resolveEffectiveModelId(appName, modelName); + const id = await metaModelId(appName, modelName); if (!id) throw new Error(`meta model not found: ${appName}.${modelName}`); return id; } diff --git a/modules/auth/service/tests/resolve_effective_model.test.ts b/modules/auth/service/tests/resolve_effective_model.test.ts deleted file mode 100644 index e0000ba63..000000000 --- a/modules/auth/service/tests/resolve_effective_model.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-FileCopyrightText: 2026-present Brian Wang -// SPDX-License-Identifier: Apache-2.0 - -import { createServiceByModel } from '@/core/service/rpc'; -import type MetaApplicationModel from '@/meta/service/models/application'; -import type MetaModelModel from '@/meta/service/models/model'; -import { - resolveEffectiveApplicationId, - resolveEffectiveModelId, - resolveEffectiveModelRow, -} from '../models/_resolve_effective_model'; - -const MetaModel = createServiceByModel('meta.MetaModel'); -const MetaApplication = createServiceByModel('meta.MetaApplication'); - -test('resolveEffectiveModelRow prefers empty ModuleId over newer shell', async () => { - const orig = (MetaModel as any).Search; - try { - (MetaModel as any).Search = async () => [ - { - Id: 'shell', - ModuleId: 'mod-1', - UpdatedAt: '2026-08-05T12:00:00.000Z', - Name: 'Partner', - }, - { - Id: 'eff', - ModuleId: null, - UpdatedAt: '2026-08-05T10:00:00.000Z', - Name: 'Partner', - }, - ]; - const row = await resolveEffectiveModelRow('partner', 'Partner', ['Name']); - expect(row?.Id).toBe('eff'); - expect(await resolveEffectiveModelId('partner', 'Partner')).toBe('eff'); - } finally { - (MetaModel as any).Search = orig; - } -}); - -test('resolveEffectiveModelRow ModuleId object / whitespace / UpdatedAt / Id tie-break', async () => { - const orig = (MetaModel as any).Search; - try { - // Object ModuleId with empty Id counts as empty. - (MetaModel as any).Search = async () => [ - { Id: 'shell', ModuleId: { Id: 'm1' }, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'eff-obj', ModuleId: { Id: '' }, UpdatedAt: '2026-08-05T09:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('eff-obj'); - - // Whitespace ModuleId string counts as empty. - (MetaModel as any).Search = async () => [ - { Id: 'shell', module_id: 'mod', updated_at: '2026-08-05T12:00:00.000Z' }, - { Id: 'ws', module_id: ' ', updated_at: '2026-08-05T08:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('ws'); - - // Both empty ModuleId → prefer newer UpdatedAt. - (MetaModel as any).Search = async () => [ - { Id: 'old', ModuleId: null, UpdatedAt: '2026-08-05T08:00:00.000Z' }, - { Id: 'new', ModuleId: '', UpdatedAt: '2026-08-05T12:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('new'); - - // Equal UpdatedAt → prefer larger Id. - (MetaModel as any).Search = async () => [ - { Id: 'aaa', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'zzz', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('zzz'); - - // Number UpdatedAt + invalid date parse on empty-ModuleId rows (hits Number.isFinite false). - (MetaModel as any).Search = async () => [ - { Id: 'n1', ModuleId: null, UpdatedAt: 100 }, - { Id: 'n2', ModuleId: null, UpdatedAt: 200 }, - { Id: 'bad', ModuleId: null, UpdatedAt: 'not-a-date' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('n2'); - - // Single row short-circuit; rows without Id filtered out. - (MetaModel as any).Search = async () => [{ Id: 'solo', ModuleId: 'm', UpdatedAt: null }]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('solo'); - - (MetaModel as any).Search = async () => [{ Name: 'no-id' }, null]; - expect(await resolveEffectiveModelRow('a', 'M')).toBeUndefined(); - expect(await resolveEffectiveModelId('a', 'M')).toBe(''); - - (MetaModel as any).Search = async () => null; - expect(await resolveEffectiveModelRow('a', 'M')).toBeUndefined(); - } finally { - (MetaModel as any).Search = orig; - } -}); - -test('resolveEffectiveModelRow covers remaining ModuleId/Id/UpdatedAt branches', async () => { - const orig = (MetaModel as any).Search; - try { - // ModuleID key + object ModuleId.id (lowercase) empty. - (MetaModel as any).Search = async () => [ - { Id: 'shell', ModuleID: 'mod-x', UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'eff-id-key', ModuleId: { id: null }, UpdatedAt: '2026-08-05T09:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('eff-id-key'); - - // Object ModuleId with lowercase id whitespace-only counts as empty; row id via lowercase `id`. - (MetaModel as any).Search = async () => [ - { Id: 'shell', ModuleId: { Id: 'm' }, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { id: 'eff-ws-obj', ModuleId: { id: ' ' }, UpdatedAt: '2026-08-05T08:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.id).toBe('eff-ws-obj'); - - // Empty UpdatedAt string → 0; older candidate must not beat newer best. - (MetaModel as any).Search = async () => [ - { Id: 'newer', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'older', ModuleId: null, UpdatedAt: '' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('newer'); - - // UpdatedAt null with no updated_at → raw == null path in rowUpdatedAt. - (MetaModel as any).Search = async () => [ - { Id: 'keep', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'null-ts', ModuleId: null, UpdatedAt: null }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('keep'); - - // UpdatedAt null falls through to updated_at. - (MetaModel as any).Search = async () => [ - { Id: 'via-snake', ModuleId: null, UpdatedAt: null, updated_at: '2026-08-05T13:00:00.000Z' }, - { Id: 'older-iso', ModuleId: null, UpdatedAt: '2026-08-05T10:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('via-snake'); - - // Equal UpdatedAt but smaller Id must not replace best. - (MetaModel as any).Search = async () => [ - { Id: 'zzz', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - { Id: 'aaa', ModuleId: null, UpdatedAt: '2026-08-05T12:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('zzz'); - - // Shell after effective already selected must not steal (empty=false, bestEmpty=true). - (MetaModel as any).Search = async () => [ - { Id: 'eff-first', ModuleId: null, UpdatedAt: '2026-08-05T08:00:00.000Z' }, - { Id: 'shell-later', ModuleId: 'mod', UpdatedAt: '2026-08-05T12:00:00.000Z' }, - ]; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('eff-first'); - - // Default fields argument (omit third param). - (MetaModel as any).Search = async (_d: any, opts: any) => { - expect(opts.fields.includes('CompanyField')).toBe(true); - return [{ Id: 'solo-default', ModuleId: null, UpdatedAt: 1 }]; - }; - expect((await resolveEffectiveModelRow('a', 'M'))?.Id).toBe('solo-default'); - } finally { - (MetaModel as any).Search = orig; - } -}); - -test('resolveEffectiveModelRow pages past limit to keep older effective row', async () => { - const orig = (MetaModel as any).Search; - try { - const calls: Array<{ limit?: number; offset?: number }> = []; - (MetaModel as any).Search = async (_domain: any, opts: any) => { - calls.push({ limit: opts?.limit, offset: opts?.offset }); - const offset = Number(opts?.offset || 0); - const limit = Number(opts?.limit || 0); - // First page: 500 newer shells; second page: older effective row. - if (offset === 0) { - return Array.from({ length: limit }, (_, i) => ({ - Id: `shell-${i}`, - ModuleId: `mod-${i}`, - UpdatedAt: `2026-08-05T12:${String(i % 60).padStart(2, '0')}:00.000Z`, - })); - } - if (offset === limit) { - return [ - { - Id: 'eff-old', - ModuleId: null, - UpdatedAt: '2026-08-05T01:00:00.000Z', - }, - ]; - } - return []; - }; - const row = await resolveEffectiveModelRow('partner', 'Partner'); - expect(row?.Id).toBe('eff-old'); - expect(calls.length).toBeGreaterThanOrEqual(2); - expect(calls[0].offset).toBe(0); - expect(calls[1].offset).toBe(calls[0].limit); - } finally { - (MetaModel as any).Search = orig; - } -}); - -test('resolveEffectiveApplicationId returns tip Id or empty', async () => { - const orig = (MetaApplication as any).Search; - try { - (MetaApplication as any).Search = async () => [{ Id: 'app-1' }]; - expect(await resolveEffectiveApplicationId('auth')).toBe('app-1'); - - (MetaApplication as any).Search = async () => []; - expect(await resolveEffectiveApplicationId('missing')).toBe(''); - - (MetaApplication as any).Search = async () => null; - expect(await resolveEffectiveApplicationId('missing')).toBe(''); - } finally { - (MetaApplication as any).Search = orig; - } -}); diff --git a/modules/auth/service/tests/role_ui_resource_sync.test.ts b/modules/auth/service/tests/role_ui_resource_sync.test.ts index b1f5aed64..62c538a79 100644 --- a/modules/auth/service/tests/role_ui_resource_sync.test.ts +++ b/modules/auth/service/tests/role_ui_resource_sync.test.ts @@ -9,7 +9,7 @@ import RoleUiResource from '@/auth/service/models/role_ui_resource'; import MetaUiResource from '@/meta/service/models/ui_resource'; import { createServiceByModel } from '@/core/service/rpc'; import type MetaServiceModel from '@/meta/service/models/service'; -import { resolveEffectiveApplicationId, resolveEffectiveModelId } from '../models/_resolve_effective_model'; +import { metaApplicationId, metaModelId } from './_meta_ids'; const MetaService = createServiceByModel('meta.MetaService'); const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); @@ -105,13 +105,13 @@ function setupAllowlistForFixtures(): void { } async function resolveApplicationId(applicationName: string): Promise { - const id = await resolveEffectiveApplicationId(applicationName); + const id = await metaApplicationId(applicationName); if (!id) throw new Error(`meta application not found: ${applicationName}`); return id; } async function resolveModelId(appName: string, modelName: string): Promise { - const id = await resolveEffectiveModelId(appName, modelName); + const id = await metaModelId(appName, modelName); if (!id) throw new Error(`meta model not found: ${appName}.${modelName}`); return id; } diff --git a/modules/auth/web/components/layout/OSwitchCompany.test.ts b/modules/auth/web/components/layout/OSwitchCompany.test.ts new file mode 100644 index 000000000..746ee7a21 --- /dev/null +++ b/modules/auth/web/components/layout/OSwitchCompany.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { mount, flushPromises } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { authBag } = vi.hoisted(() => ({ + authBag: { state: null as null | Record }, +})); + +vi.mock('@/auth/web/stores/auth', async () => { + const { reactive } = await import('vue'); + authBag.state = reactive({ + identity: { + metadata: { + activeCompanyId: 'c1', + enabledCompanyIds: ['c1'], + allowedCompanyIds: ['c1', 'c2'], + }, + }, + refreshToken: vi.fn(async () => {}), + switchCompanyScope: vi.fn(async () => {}), + }); + return { + useAuthStore: () => authBag.state!, + }; +}); + +vi.mock('@/web/web/stores/registry', () => ({ + createStoreByModel: () => ({ + Search: vi.fn(async () => [ + { Id: 'c1', DisplayName: 'One' }, + { Id: 'c2', DisplayName: 'Two' }, + ]), + }), +})); + +vi.mock('@/web/web/i18n', () => ({ + createTranslate: () => ({ _t: (msg: string) => msg }), +})); + +import OSwitchCompany from './OSwitchCompany.vue'; + +describe('OSwitchCompany open-panel draft guard', () => { + beforeEach(() => { + authBag.state!.identity = { + metadata: { + activeCompanyId: 'c1', + enabledCompanyIds: ['c1'], + allowedCompanyIds: ['c1', 'c2'], + }, + }; + authBag.state!.refreshToken.mockClear(); + authBag.state!.switchCompanyScope.mockClear(); + }); + + it('does not reset drafts from JWT watch while the popover is open', async () => { + const wrapper = mount(OSwitchCompany as any, { + global: { + stubs: { + 'el-popover': { + props: ['visible'], + emits: ['update:visible'], + template: `
+
`, + }, + 'el-button': { + template: ``, + }, + 'el-form': { template: `
` }, + 'el-form-item': { template: `
` }, + 'el-select': { + props: ['modelValue'], + emits: ['update:modelValue', 'change', 'remove-tag'], + template: `
`, + }, + 'el-option': true, + }, + }, + }); + await flushPromises(); + + await wrapper.find('button.open').trigger('click'); + await flushPromises(); + await nextTick(); + + // Token refresh updates JWT metadata while the panel stays open. + authBag.state!.identity = { + metadata: { + activeCompanyId: 'c2', + enabledCompanyIds: ['c2'], + allowedCompanyIds: ['c1', 'c2'], + }, + }; + await nextTick(); + await flushPromises(); + + // Drafts remain on the open-panel seed (c1) → dirty vs JWT c2 → Apply enabled. + const apply = wrapper.find('[data-testid="company-switch-apply"]'); + expect(apply.exists()).toBe(true); + expect((apply.element as HTMLButtonElement).disabled).toBe(false); + }); +}); diff --git a/modules/auth/web/components/layout/OSwitchCompany.vue b/modules/auth/web/components/layout/OSwitchCompany.vue index d1c427e81..502b16422 100644 --- a/modules/auth/web/components/layout/OSwitchCompany.vue +++ b/modules/auth/web/components/layout/OSwitchCompany.vue @@ -135,6 +135,10 @@ function setEq(a: string[], b: string[]): boolean { watch( [currentActiveCompanyId, currentEnabledCompanyIds], ([active, enabled]) => { + // Panel-open drafts are user-owned (seeded in the visible watcher). A token + // refresh while the popover is open must not clobber an in-progress selection, + // or isDirty/canApply flip back to "No changes to apply". + if (visible.value) return; draftActiveCompanyId.value = active; draftEnabledCompanyIds.value = uniq(enabled); ensureActiveInEnabled(); diff --git a/modules/base/demo/demo.json b/modules/base/demo/demo.json index 1be74bb5e..cc9cb9694 100644 --- a/modules/base/demo/demo.json +++ b/modules/base/demo/demo.json @@ -9,7 +9,8 @@ "en_US": "Demo Company", "zh_CN": "演示公司" }, - "Code": "DEMO" + "Code": "DEMO", + "Timezone": "Asia/Shanghai" } } ] diff --git a/modules/base/package.json b/modules/base/package.json index dd420e703..f522e566e 100644 --- a/modules/base/package.json +++ b/modules/base/package.json @@ -17,8 +17,7 @@ "application": "base", "category": "Base", "depends": [ - "core", - "web" + "core" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/core/service/orm/repository/validation/bridge.ts b/modules/core/service/orm/repository/validation/bridge.ts index 09fb240d6..580591749 100644 --- a/modules/core/service/orm/repository/validation/bridge.ts +++ b/modules/core/service/orm/repository/validation/bridge.ts @@ -17,7 +17,7 @@ import { resolveRepositoryPlatformRejectUnknownFields, } from './platform_helpers'; -export { wrapRepositoryValidationError } from './error_helpers'; +export { selectPrimaryValidationIssue, wrapRepositoryValidationError } from './error_helpers'; export { throwRepositorySqlWriteError } from './sql_helpers'; export { recordRepositoryPlatformCreateWhitelistAudit, diff --git a/modules/core/service/orm/repository/validation/error_helpers.ts b/modules/core/service/orm/repository/validation/error_helpers.ts index 81335b43c..b92caecd2 100644 --- a/modules/core/service/orm/repository/validation/error_helpers.ts +++ b/modules/core/service/orm/repository/validation/error_helpers.ts @@ -1,14 +1,38 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 -import type { ModelMetadata } from '../../metadata'; +import type { ModelMetadata, ValidationIssue } from '../../metadata'; import { ValidationPipelineError, type ConstraintMode } from '../../metadata'; import { GrpcCode, ChoysumError } from '@/core/service/error'; import type { ObjectRecord } from '../../../../utils/types'; +/** Canonical non-OK gRPC status codes (connect/gRPC: 1..16). */ +function issueGrpcCode(issue: ValidationIssue | undefined): number | undefined { + const raw = (issue?.meta || {}).grpcCode; + if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined; + // Reject fractions (avoid Number.isInteger for QuickJS portability). + if (Math.floor(raw) !== raw) return undefined; + // OK (0) is not status-bearing for errors; reject out-of-range values. + if (raw < 1 || raw > 16) return undefined; + return raw; +} + +/** + * Prefer a status-bearing constraint issue (meta.grpcCode) so Unauthenticated / + * PermissionDenied are not masked by an earlier kernel/platform InvalidArgument. + */ +export function selectPrimaryValidationIssue(issues: ValidationIssue[]): ValidationIssue | undefined { + const errors = issues.filter(issue => issue.severity === 'error'); + const statusBearing = errors.find(issue => issueGrpcCode(issue) !== undefined); + return statusBearing || errors[0] || issues[0]; +} + export function wrapRepositoryValidationError(meta: ModelMetadata, error: ValidationPipelineError, mode: ConstraintMode): ChoysumError { - const primaryIssue = error.issues.find(issue => issue.severity === 'error') || error.issues[0]; + const primaryIssue = selectPrimaryValidationIssue(error.issues); const message = primaryIssue?.message || error.message || 'validation failed'; + const primaryMeta = (primaryIssue?.meta || {}) as ObjectRecord; + const grpcFromMeta = issueGrpcCode(primaryIssue); + const grpcCode = grpcFromMeta !== undefined ? (grpcFromMeta as GrpcCode) : GrpcCode.InvalidArgument; const wrapped = ChoysumError.wrap( error, { @@ -17,7 +41,7 @@ export function wrapRepositoryValidationError(meta: ModelMetadata, error: Valida message, }, true - ).withGrpcCode(GrpcCode.InvalidArgument); + ).withGrpcCode(grpcCode); const metadata: Record = { mode, @@ -75,6 +99,10 @@ export function wrapRepositoryValidationError(meta: ModelMetadata, error: Valida if (primaryIssue?.field) metadata.field = primaryIssue.field; if (primaryIssue?.method) metadata.method = primaryIssue.method; if (primaryIssue?.code) metadata.issueCode = primaryIssue.code; + const causeCode = String(primaryMeta.causeCode || '').trim(); + const causeDomain = String(primaryMeta.causeDomain || '').trim(); + if (causeCode) metadata.causeCode = causeCode; + if (causeDomain) metadata.causeDomain = causeDomain; if (primaryIssue?.scope === 'sql') { if (primaryIssue?.code) metadata.sqlCode = primaryIssue.code; const sqlMeta = (primaryIssue?.meta || {}) as ObjectRecord; diff --git a/modules/core/service/orm/repository/validation/index.ts b/modules/core/service/orm/repository/validation/index.ts index d1b4b73c4..0981ba34f 100644 --- a/modules/core/service/orm/repository/validation/index.ts +++ b/modules/core/service/orm/repository/validation/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export { + selectPrimaryValidationIssue, wrapRepositoryValidationError, throwRepositorySqlWriteError, recordRepositoryPlatformCreateWhitelistAudit, diff --git a/modules/core/service/orm/repository/validation/tests/error_helpers.test.ts b/modules/core/service/orm/repository/validation/tests/error_helpers.test.ts index 7d99241f2..c481f4c38 100644 --- a/modules/core/service/orm/repository/validation/tests/error_helpers.test.ts +++ b/modules/core/service/orm/repository/validation/tests/error_helpers.test.ts @@ -3,7 +3,7 @@ import { GrpcCode, ChoysumError } from '@/core/service/error'; import { ValidationPipelineError } from '../../../metadata'; -import { wrapRepositoryValidationError } from '..'; +import { selectPrimaryValidationIssue, wrapRepositoryValidationError } from '..'; test('repository validation error helper builds rich metadata for sql/kernel/global issues', () => { const error = new ValidationPipelineError('pipeline failed', [ @@ -86,6 +86,94 @@ test('repository validation error helper builds rich metadata for sql/kernel/glo expect(issues[2].field).toBe(undefined); }); +test('repository validation error helper prefers status-bearing constraint issue over earlier kernel errors', () => { + const wrapped = wrapRepositoryValidationError( + { + fullModelName: 'web.SavedFilter', + modelName: 'SavedFilter', + name: '', + } as any, + new ValidationPipelineError('pipeline failed', [ + { + scope: 'kernel', + field: 'Name', + code: 'required', + message: 'Name is required', + severity: 'error', + }, + { + scope: 'constraint', + method: 'validateSavedFilterConstraint', + code: 'constraint_execution_failed', + message: 'Authentication required', + severity: 'error', + meta: { + causeCode: 'PermissionDenied', + causeDomain: 'web', + grpcCode: GrpcCode.Unauthenticated, + }, + }, + ] as any), + 'create' + ); + + expect(wrapped.grpcCode).toBe(GrpcCode.Unauthenticated); + expect(wrapped.message).toBe('Authentication required'); + expect(wrapped.metadata.causeCode).toBe('PermissionDenied'); + expect(wrapped.metadata.causeDomain).toBe('web'); + expect(wrapped.metadata.issueCode).toBe('constraint_execution_failed'); +}); + +test('selectPrimaryValidationIssue falls back to first error when no grpc meta is present', () => { + const primary = selectPrimaryValidationIssue([ + { scope: 'kernel', code: 'required', message: 'a', severity: 'error' }, + { scope: 'platform', code: 'platform_x', message: 'b', severity: 'error' }, + ] as any); + expect(primary?.code).toBe('required'); +}); + +test('selectPrimaryValidationIssue ignores OK, non-integer, and out-of-range grpcCode as status-bearing', () => { + const issues = [ + { + scope: 'constraint', + code: 'bad_ok', + message: 'ok code', + severity: 'error', + meta: { grpcCode: 0 }, + }, + { + scope: 'constraint', + code: 'bad_frac', + message: 'fraction', + severity: 'error', + meta: { grpcCode: 7.5 }, + }, + { + scope: 'constraint', + code: 'bad_range', + message: 'range', + severity: 'error', + meta: { grpcCode: 99 }, + }, + { + scope: 'kernel', + code: 'required', + message: 'fallback', + severity: 'error', + }, + ] as any; + // Invalid grpcCode values are not status-bearing, so selection falls back to the first error. + expect(selectPrimaryValidationIssue(issues)?.code).toBe('bad_ok'); + + const wrapped = wrapRepositoryValidationError( + { fullModelName: 'demo.Model', modelName: 'Model', name: '' } as any, + new ValidationPipelineError('pipeline failed', issues), + 'create' + ); + expect(wrapped.grpcCode).toBe(GrpcCode.InvalidArgument); + expect(wrapped.message).toBe('ok code'); +}); + test('repository validation error helper falls back message and keeps minimal metadata when issues are empty', () => { const wrapped = wrapRepositoryValidationError( { diff --git a/modules/core/service/rpc/index.ts b/modules/core/service/rpc/index.ts index 5722e9ae6..7bde9effb 100644 --- a/modules/core/service/rpc/index.ts +++ b/modules/core/service/rpc/index.ts @@ -4,4 +4,10 @@ export { CreateServerApiService } from './server_api_service'; export { logServerRpcError, shouldSilenceServerRpcError } from './server_errors'; export { tryLocalServiceCall } from './server_routing'; -export { createServiceByModel, getServiceFactory, registerServiceFactory, type ServiceFactory } from './service_factory'; +export { + createServiceByModel, + getServiceFactory, + registerServiceFactory, + unregisterServiceFactory, + type ServiceFactory, +} from './service_factory'; diff --git a/modules/core/service/rpc/service_factory.test.ts b/modules/core/service/rpc/service_factory.test.ts index 0c08baf9c..b19b8f5f4 100644 --- a/modules/core/service/rpc/service_factory.test.ts +++ b/modules/core/service/rpc/service_factory.test.ts @@ -1,7 +1,12 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 -import { createServiceByModel, registerServiceFactory } from './service_factory'; +import { + createServiceByModel, + getServiceFactory, + registerServiceFactory, + unregisterServiceFactory, +} from './service_factory'; test('registerServiceFactory + createServiceByModel should create service instance', () => { const modelName = `test.Model.${Date.now()}`; @@ -12,6 +17,8 @@ test('registerServiceFactory + createServiceByModel should create service instan expect(created).toBe(serviceInstance); expect(created.Ping()).toBe('pong'); + unregisterServiceFactory(modelName); + expect(getServiceFactory(modelName)).toBeUndefined(); }); test('createServiceByModel should throw when service factory missing', () => { diff --git a/modules/core/service/rpc/service_factory.ts b/modules/core/service/rpc/service_factory.ts index 09fba4af1..6aecba0cd 100644 --- a/modules/core/service/rpc/service_factory.ts +++ b/modules/core/service/rpc/service_factory.ts @@ -25,6 +25,13 @@ export function getServiceFactory(modelName: string): ServiceFactory | undefined return serviceFactoryRegistry.get(modelName); } +/** + * Removes a registered service factory (test helpers / hot reload cleanup). + */ +export function unregisterServiceFactory(modelName: string): void { + serviceFactoryRegistry.delete(modelName); +} + /** * Creates a service instance from the factory registered for the model name. */ diff --git a/modules/core/service/runtime/validation/engine.test.ts b/modules/core/service/runtime/validation/engine.test.ts index 3a7dd8f87..6bccac365 100644 --- a/modules/core/service/runtime/validation/engine.test.ts +++ b/modules/core/service/runtime/validation/engine.test.ts @@ -5,10 +5,12 @@ import { BaseModel, Compute, Field } from '@/core/service'; import { Constraint, ValidationPipelineError } from '@/core/service/api/constraint'; import { MetadataStorage } from '@/core/service/api/metadata'; import { ValidationEngine } from '@/core/service/api/validation'; +import { GrpcCode, ChoysumError } from '@/core/service/error'; import { Model } from '../../orm/decorator/model'; import { RepositoryFactory } from '../../orm/repository/repository_factory'; const engineCallLog: Array> = []; +const shortCircuitCallLog: string[] = []; type ObjectRecord = Record; class ConstraintEngineModel extends BaseModel { @@ -64,6 +66,27 @@ class ConstraintEngineEdgeModel extends BaseModel { } } +class ChoysumErrorShortCircuitModel extends BaseModel { + Name?: string; + + static resetLog() { + shortCircuitCallLog.length = 0; + } + + static checkAuth(_self: ChoysumErrorShortCircuitModel) { + shortCircuitCallLog.push('checkAuth'); + throw new ChoysumError({ + domain: 'web', + code: 'PermissionDenied', + message: 'Authentication required', + }).withGrpcCode(GrpcCode.Unauthenticated); + } + + static checkLater(_self: ChoysumErrorShortCircuitModel) { + shortCircuitCallLog.push('checkLater'); + } +} + class PlatformValidationModel extends BaseModel { @Field({ type: 'varchar', size: 64 }) Name?: string; @@ -188,6 +211,8 @@ Constraint('Status', { priority: 2 })(ConstraintEngineMod Constraint('Name', { priority: 3, preview: true })(ConstraintEngineModel, 'checkPreview', undefined as any); Constraint('Name', { priority: 1 })(ConstraintEngineEdgeModel, 'checkRaisesPipeline', undefined as any); Constraint('Name', { priority: 2 })(ConstraintEngineEdgeModel, 'checkMissingMethod', undefined as any); +Constraint('Name', { priority: 1 })(ChoysumErrorShortCircuitModel, 'checkAuth', undefined as any); +Constraint('Name', { priority: 2 })(ChoysumErrorShortCircuitModel, 'checkLater', undefined as any); test('validation engine merges current and incoming values for constraint self', async () => { ConstraintEngineModel.resetLog(); @@ -271,6 +296,35 @@ test('validation engine unwraps nested ValidationPipelineError from constraint m expect(issues.some(issue => issue.code === 'constraint_method_missing')).toBe(true); }); +test('validation engine stops later handlers after domain ChoysumError', async () => { + ChoysumErrorShortCircuitModel.resetLog(); + const metadata = MetadataStorage.instance.getModelMetadata(ChoysumErrorShortCircuitModel as any); + + const issues = await ValidationEngine.validate( + { + mode: 'update', + model: ChoysumErrorShortCircuitModel as any, + metadata, + current: { Id: '1', Name: 'old' }, + values: { Name: 'next' }, + changedFields: new Set(['Name']), + repository: {} as any, + requestContext: {}, + }, + { + includeKernel: false, + includePlatform: false, + includeConstraints: true, + } + ); + + expect(shortCircuitCallLog).toEqual(['checkAuth']); + expect(issues.length).toBe(1); + expect(issues[0]?.code).toBe('constraint_execution_failed'); + expect(issues[0]?.meta?.causeCode).toBe('PermissionDenied'); + expect(issues[0]?.meta?.grpcCode).toBe(GrpcCode.Unauthenticated); +}); + test('validation engine only runs preview constraints in preview mode', async () => { ConstraintEngineModel.resetLog(); const metadata = MetadataStorage.instance.getModelMetadata(ConstraintEngineModel as any); diff --git a/modules/core/service/runtime/validation/engine.ts b/modules/core/service/runtime/validation/engine.ts index 6b115f73e..9ed6d8448 100644 --- a/modules/core/service/runtime/validation/engine.ts +++ b/modules/core/service/runtime/validation/engine.ts @@ -21,6 +21,7 @@ import { getRuntimeRepository } from '../runtime_repository_facade'; import { markProxyKind } from '../proxy/brand'; import { createForbiddenPersistenceMethodStub, isDraftForbiddenPersistenceMethod } from '../proxy/draftPersistenceGuards'; import type { ObjectRecord } from '../../../utils/types'; +import { ChoysumError } from '@/core/service/error'; import { _t } from '@/core/service/i18n_binder'; type ReferenceModelMeta = Pick; @@ -570,13 +571,26 @@ export class ValidationEngine { } const message = error instanceof Error ? error.message : String(error); - issues.push({ + const issue: ValidationIssue = { scope: 'constraint', method: handler.method, code: 'constraint_execution_failed', message, severity: 'error', - }); + }; + // Keep repository wrap as validation_failed, but retain the original + // ChoysumError code/gRPC status in issue meta for API clients/tests. + if (error instanceof ChoysumError) { + issue.meta = { + causeCode: error.code, + causeDomain: error.domain, + grpcCode: error.grpcCode, + }; + issues.push(issue); + // Domain auth/status failures must not run later handlers (side effects). + break; + } + issues.push(issue); } continue; } @@ -619,13 +633,26 @@ export class ValidationEngine { } const message = error instanceof Error ? error.message : String(error); - issues.push({ + const issue: ValidationIssue = { scope: 'constraint', method: handler.method, code: 'constraint_execution_failed', message, severity: 'error', - }); + }; + // Keep repository wrap as validation_failed, but retain the original + // ChoysumError code/gRPC status in issue meta for API clients/tests. + if (error instanceof ChoysumError) { + issue.meta = { + causeCode: error.code, + causeDomain: error.domain, + grpcCode: error.grpcCode, + }; + issues.push(issue); + // Domain auth/status failures must not run later handlers (side effects). + break; + } + issues.push(issue); } } diff --git a/modules/document/service/tests/_owner_auth_test_fixtures.ts b/modules/document/service/tests/_owner_auth_test_fixtures.ts index 591adc536..95adc01a3 100644 --- a/modules/document/service/tests/_owner_auth_test_fixtures.ts +++ b/modules/document/service/tests/_owner_auth_test_fixtures.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import Role from '@/auth/service/models/role'; -import { resolveEffectiveModelId } from '@/auth/service/models/_resolve_effective_model'; +import { createServiceByModel } from '@/core/service/rpc'; +import type MetaModelModel from '@/meta/service/models/model'; import RoleFieldRule from '@/auth/service/models/role_field_rule'; import RoleRecordRule from '@/auth/service/models/role_record_rule'; import User from '@/auth/service/models/user'; @@ -11,6 +12,8 @@ import { withPermissionGraphBypass } from '@/auth/service/models/_user_authz_sha import { invalidateAuthzCachesForUsers } from '@/auth/service/models/_request_cache_invalidation'; import { getTestRepository } from '@/core/service/testing'; +const MetaModel = createServiceByModel('meta.MetaModel'); + const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); const FR_CACHE_KEY = Symbol.for('choysum.fieldrule.cache'); @@ -105,7 +108,11 @@ export async function ensureAuthUserOwnerRecordRuleGrants(): Promise { if (authUserOwnerGrantsSeeded) return; await withPermissionGraphBypass(async () => { - const modelId = await resolveEffectiveModelId('auth', 'User'); + const modelRows = await MetaModel.Search( + { And: [['Application', '=', 'auth'], ['Name', '=', 'User']] } as any, + { fields: ['Id'], limit: 1 } as any + ); + const modelId = String(modelRows?.[0]?.Id || '').trim(); if (!modelId) { throw new Error('meta model auth.User not found for document owner RR fixture'); } diff --git a/modules/meta/package.json b/modules/meta/package.json index e4b2876b8..03dd21d85 100644 --- a/modules/meta/package.json +++ b/modules/meta/package.json @@ -20,7 +20,6 @@ "category": "Platform/Metadata", "depends": [ "core", - "web", "task" ], "cli": ">=0.0.0-0 <0.0.0", diff --git a/modules/meta/service/models/model_data.ts b/modules/meta/service/models/model_data.ts index 0e160d555..c31adb8fc 100644 --- a/modules/meta/service/models/model_data.ts +++ b/modules/meta/service/models/model_data.ts @@ -57,7 +57,7 @@ export default class MetaModelData extends BaseModel { @Field({ type: 'varchar', size: 255, notNull: true, index: true, string: _lt('Name', { scope: 'meta.model.MetaModelData.fields' }) }) Name!: string; - /** Effective meta_model.id written by the host loader (LookupEffectiveModel). */ + /** meta_model.id written by the host loader (unique per application+name). */ @Field({ type: 'ManyToOne', relation: { targetModel: () => MetaModel }, string: _lt('Model', { scope: 'meta.model.MetaModelData.fields' }) }) ModelId?: MetaModel; diff --git a/modules/partner/package.json b/modules/partner/package.json index 9b6e5211f..92bd064c6 100644 --- a/modules/partner/package.json +++ b/modules/partner/package.json @@ -19,7 +19,7 @@ "depends": [ "core", "base", - "web" + "auth" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/partner_bank/package.json b/modules/partner_bank/package.json index 6daf96b6e..10e4e51dd 100644 --- a/modules/partner_bank/package.json +++ b/modules/partner_bank/package.json @@ -18,7 +18,7 @@ "depends": [ "partner", "base", - "web" + "auth" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/partner_commercial/package.json b/modules/partner_commercial/package.json index 65da109a2..b704e29fd 100644 --- a/modules/partner_commercial/package.json +++ b/modules/partner_commercial/package.json @@ -18,7 +18,7 @@ "depends": [ "partner", "base", - "web" + "auth" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/task/package.json b/modules/task/package.json index 4c7d27d77..0cebc06a9 100644 --- a/modules/task/package.json +++ b/modules/task/package.json @@ -14,8 +14,7 @@ "application": "task", "category": "System/Task", "depends": [ - "core", - "base" + "core" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { diff --git a/modules/web/data/bootstrap.json b/modules/web/data/bootstrap.json new file mode 100644 index 000000000..a6e45f8a8 --- /dev/null +++ b/modules/web/data/bootstrap.json @@ -0,0 +1,234 @@ +{ + "records": [ + { + "name": "rma_base_user_web_saved_filter_search", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/Search" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_browse", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/Browse" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_create", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/Create" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_update", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/Update" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_update_by_id", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/UpdateById" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_delete", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/Delete" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rma_base_user_web_saved_filter_delete_by_id", + "application": "auth", + "model": "RoleMethodAccess", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": null, + "MetaServiceId": { + "serviceRef": "web.SavedFilter/DeleteById" + }, + "LogicalModelName": null, + "Mode": "allow" + } + }, + { + "name": "rfr_base_user_web_saved_filter_rw", + "application": "auth", + "model": "RoleFieldRule", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "MetaApplicationId": null, + "MetaModelId": { + "modelRef": "web.SavedFilter" + }, + "MetaFieldId": null, + "PermRead": "allow", + "PermWrite": "allow" + } + }, + { + "name": "rrr_base_user_web_saved_filter_rc", + "application": "auth", + "model": "RoleRecordRule", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "Kind": "grant", + "MetaApplicationId": null, + "MetaModelId": { + "modelRef": "web.SavedFilter" + }, + "Condition": { + "Or": [ + [ + "UserId", + "=", + "$userId" + ], + [ + "UserId", + "is", + null + ] + ] + }, + "PermRead": true, + "PermWrite": false, + "PermCreate": true, + "PermDelete": false + } + }, + { + "name": "rrr_base_user_web_saved_filter_wd_private", + "application": "auth", + "model": "RoleRecordRule", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "Kind": "grant", + "MetaApplicationId": null, + "MetaModelId": { + "modelRef": "web.SavedFilter" + }, + "Condition": { + "And": [ + [ + "UserId", + "=", + "$userId" + ] + ] + }, + "PermRead": false, + "PermWrite": true, + "PermCreate": false, + "PermDelete": true + } + }, + { + "name": "rrr_base_user_web_saved_filter_wd_shared", + "application": "auth", + "model": "RoleRecordRule", + "values": { + "RoleId": { + "ref": "auth.role_base_user" + }, + "Kind": "grant", + "MetaApplicationId": null, + "MetaModelId": { + "modelRef": "web.SavedFilter" + }, + "Condition": { + "And": [ + [ + "UserId", + "is", + null + ], + [ + "CreateUid", + "=", + "$userId" + ] + ] + }, + "PermRead": false, + "PermWrite": true, + "PermCreate": false, + "PermDelete": true + } + } + ] +} diff --git a/modules/web/package.json b/modules/web/package.json index 93658677e..1375736a7 100644 --- a/modules/web/package.json +++ b/modules/web/package.json @@ -32,12 +32,19 @@ "moduleName": "web", "application": "web", "depends": [ - "core" + "core", + "auth", + "meta", + "document" ], "cli": ">=0.0.0-0 <0.0.0", "entryPoints": { - "web": "./web/index.ts" - } + "web": "./web/index.ts", + "service": "./service/index.ts" + }, + "data": [ + "data/bootstrap.json" + ] }, "publishConfig": { "access": "public" diff --git a/modules/web/service/i18n.ts b/modules/web/service/i18n.ts new file mode 100644 index 000000000..b75094138 --- /dev/null +++ b/modules/web/service/i18n.ts @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { createTranslate } from '@/core/service/i18n'; + +/** Web service terminology binder (module owner = web). */ +const translate = createTranslate('web'); +export const _t = translate._t; +export const _lt = translate._lt; diff --git a/modules/web/service/index.ts b/modules/web/service/index.ts new file mode 100644 index 000000000..c5f54eba2 --- /dev/null +++ b/modules/web/service/index.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Web service model exports. + */ +export * from './models'; diff --git a/modules/web/service/models/_scope_key.ts b/modules/web/service/models/_scope_key.ts new file mode 100644 index 000000000..19c09ed57 --- /dev/null +++ b/modules/web/service/models/_scope_key.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Normalize a route path into SavedFilter.ScopeKey (keep in sync with + * modules/web/web/composables/search/scopeKey.ts). + */ +export function normalizeScopeKey(path: string | null | undefined): string { + let p = String(path ?? '').trim(); + if (!p) return ''; + const q = p.indexOf('?'); + if (q >= 0) p = p.slice(0, q); + const h = p.indexOf('#'); + if (h >= 0) p = p.slice(0, h); + p = p.replace(/\\/g, '/').replace(/\/+/g, '/'); + if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1); + if (!p.startsWith('/') && p.length > 0) p = `/${p}`; + return p + .split('/') + .map(seg => { + if (!seg) return seg; + if (/^\d+$/.test(seg)) return ':id'; + if (/^[a-z0-9_-]{16,}$/i.test(seg)) return ':id'; + return seg; + }) + .join('/'); +} diff --git a/modules/web/service/models/index.ts b/modules/web/service/models/index.ts new file mode 100644 index 000000000..4f3402675 --- /dev/null +++ b/modules/web/service/models/index.ts @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +/** + * Web service model exports. + */ +export { default as SavedFilter } from './saved_filter'; diff --git a/modules/web/service/models/saved_filter.ts b/modules/web/service/models/saved_filter.ts new file mode 100644 index 000000000..903bb36f3 --- /dev/null +++ b/modules/web/service/models/saved_filter.ts @@ -0,0 +1,349 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { BaseModel, Model, Field } from '@/core/service'; +import { Constraint, type ConstraintContext } from '@/core/service/api/constraint'; +import type { QueryCondition } from '@/core/service/api/query'; +import { ChoysumError, GrpcCode } from '@/core/service/error'; +import { createServiceByModel } from '@/core/service/rpc'; +import type MetaModel from '@/meta/service/models/model'; +import { _lt, _t } from '../i18n'; +import { normalizeScopeKey } from './_scope_key'; + +const SCOPE = 'web.model.SavedFilter'; +const MetaModelService = createServiceByModel('meta.MetaModel'); + +/** + * Persisted Favorites filter for OSearch (Owner Application = web). + * + * Identity: Application + ModelName. ModelId stores the unique live meta.MetaModel id. + * Uniqueness / IsDefault mutex are scoped by ScopeKey (normalized route path) + UserId + Name. + * Field normalize / ModelId / uniqueness / IsDefault mutex → `@Constraint` (uses ctx.mode; + * Create pre-assigns Id before validation, so `!this.Id` is not a reliable create signal). + * Shared write/delete ACL (SF11) → auth.RoleRecordRule seeds in modules/web/data/bootstrap.json. + */ +@Model('SavedFilter', { application: 'web', softDelete: false }) +export default class SavedFilter extends BaseModel { + /** + * Display name of the favorite (unique per Application/ModelName/ScopeKey/UserId). + */ + @Field({ + type: 'varchar', + size: 255, + notNull: true, + index: true, + string: _lt('Name', { scope: `${SCOPE}.fields` }), + }) + Name: string; + + /** + * Normalized route path that scopes Name uniqueness and IsDefault (not shown in Favorites UI). + */ + @Field({ + type: 'varchar', + size: 512, + notNull: true, + index: true, + default: () => '', + string: _lt('Scope Key', { scope: `${SCOPE}.fields` }), + help: _lt('Normalized route path; Favorites UI shows Name only.', { scope: `${SCOPE}.fields` }), + }) + ScopeKey: string; + + /** + * Target application short name (store.application). + */ + @Field({ + type: 'varchar', + size: 64, + notNull: true, + index: true, + string: _lt('Application', { scope: `${SCOPE}.fields` }), + }) + Application: string; + + /** + * Target model short name (store.modelName). + */ + @Field({ + type: 'varchar', + size: 128, + notNull: true, + index: true, + string: _lt('Model Name', { scope: `${SCOPE}.fields` }), + }) + ModelName: string; + + /** + * Effective meta.MetaModel id for Application + ModelName (SF12). + * notNull is false at the Field/kernel layer because required checks run before + * `@Constraint` writeback; the constraint always resolves and requires ModelId. + */ + @Field({ + type: 'ManyToOneRef', + relation: { targetModel: 'meta.MetaModel' }, + notNull: false, + size: 20, + index: true, + string: _lt('Model', { scope: `${SCOPE}.fields` }), + }) + ModelId: string; + + /** + * QueryCondition JSON applied via store.Search (Choysum Condition, not Odoo domain). + */ + @Field({ + type: 'jsonobject', + notNull: true, + default: () => ({}), + string: _lt('Condition', { scope: `${SCOPE}.fields` }), + }) + Condition: QueryCondition; + + /** + * Optional sort specification JSON. + */ + @Field({ + type: 'jsonobject', + notNull: false, + string: _lt('Sort', { scope: `${SCOPE}.fields` }), + }) + Sort?: any; + + /** + * Owner user id; null means shared with all logged-in users. + */ + @Field({ + type: 'varchar', + size: 20, + notNull: false, + index: true, + string: _lt('User', { scope: `${SCOPE}.fields` }), + }) + UserId?: string | null; + + /** + * Whether this favorite is applied when the search view opens. + */ + @Field({ + type: 'boolean', + default: () => false, + string: _lt('Is Default', { scope: `${SCOPE}.fields` }), + }) + IsDefault: boolean; + + /** + * Soft-active flag (table uses hard delete; Active gates visibility). + */ + @Field({ + type: 'boolean', + default: () => true, + string: _lt('Active', { scope: `${SCOPE}.fields` }), + }) + Active: boolean; + + /** + * Creator user id for SF11 shared-row write/delete Record rules. + * BaseModel does not yet expose CreateUid audit; persist on this model. + * notNull is false for the same kernel-before-constraint reason as ModelId; + * `@Constraint` always stamps CreateUid on create. + */ + @Field({ + type: 'varchar', + size: 20, + notNull: false, + index: true, + copy: false, + string: _lt('Created By', { scope: `${SCOPE}.fields` }), + }) + CreateUid: string; + + private static _fail(code: string, message: string, grpc: GrpcCode = GrpcCode.InvalidArgument): never { + throw new ChoysumError({ domain: 'web', code, message }).withGrpcCode(grpc); + } + + /** + * Owner may be null (shared) or the current actor. Other user ids are rejected. + */ + private static _normalizeOwnerUserId(raw: unknown, actor: string): string | null { + if (raw == null || raw === '') return null; + const id = String(raw).trim(); + if (!id) return null; + if (id === actor) return actor; + this._fail('PermissionDenied', _t('Cannot assign a favorite to another user', { scope: SCOPE }), GrpcCode.PermissionDenied); + } + + /** + * Clear other IsDefault rows the actor may write (SF11). Shared defaults owned by + * someone else remain; fail so we never leave two shared defaults silently. + */ + private static async _clearOtherDefaults( + app: string, + modelName: string, + scopeKey: string, + userId: string | null, + exceptId?: string + ): Promise { + const cond: any = { + And: [ + ['Application', '=', app], + ['ModelName', '=', modelName], + ['ScopeKey', '=', scopeKey], + ['IsDefault', '=', true], + ], + }; + if (userId == null || userId === '') { + cond.And.push(['UserId', '=', null]); + } else { + cond.And.push(['UserId', '=', userId]); + } + if (exceptId) { + cond.And.push(['Id', '!=', exceptId]); + } + // Preflight under sudo so a foreign shared default surfaces PermissionDenied + // instead of a generic record_rule_violation from Update. + const actor = String(this.userId || '').trim(); + const candidates = await SavedFilter.sudo( + () => SavedFilter.Search(cond as any, { fields: ['Id', 'UserId', 'CreateUid'], limit: 50 } as any), + { hint: 'web.SavedFilter.clearOtherDefaults.preflight' } + ); + for (const row of candidates || []) { + const shared = (row as any).UserId == null || (row as any).UserId === ''; + const canWrite = shared + ? String((row as any).CreateUid || '').trim() === actor + : String((row as any).UserId || '').trim() === actor; + if (!canWrite) { + this._fail( + 'PermissionDenied', + _t('Cannot replace another user\'s shared default favorite', { scope: SCOPE }), + GrpcCode.PermissionDenied + ); + } + } + // No sudo: Record rules must still authorize the clear for rows we expect to write. + await SavedFilter.Update(cond as any, { IsDefault: false } as any, ['Id'] as any); + const remaining = await SavedFilter.sudo( + () => SavedFilter.Search(cond as any, { fields: ['Id'], limit: 1 } as any), + { hint: 'web.SavedFilter.clearOtherDefaults.check' } + ); + if (Array.isArray(remaining) && remaining.length > 0) { + this._fail( + 'PermissionDenied', + _t('Cannot replace another user\'s shared default favorite', { scope: SCOPE }), + GrpcCode.PermissionDenied + ); + } + } + + private static async _assertUniqueName( + app: string, + modelName: string, + scopeKey: string, + userId: string | null, + name: string, + exceptId?: string + ): Promise { + const cond: any = { + And: [ + ['Application', '=', app], + ['ModelName', '=', modelName], + ['ScopeKey', '=', scopeKey], + ['Name', '=', name], + ], + }; + if (userId == null || userId === '') { + cond.And.push(['UserId', '=', null]); + } else { + cond.And.push(['UserId', '=', userId]); + } + if (exceptId) { + cond.And.push(['Id', '!=', exceptId]); + } + const hits = await SavedFilter.Search(cond as any, { fields: ['Id'], limit: 1 } as any); + if (Array.isArray(hits) && hits.length > 0) { + this._fail('AlreadyExists', _t('A favorite with this name already exists', { scope: SCOPE }), GrpcCode.AlreadyExists); + } + } + + private static _mergedField(self: SavedFilter, ctx: ConstraintContext, key: string): any { + if (Object.prototype.hasOwnProperty.call(ctx.values || {}, key)) return (ctx.values as any)[key]; + if (Object.prototype.hasOwnProperty.call(self as any, key)) return (self as any)[key]; + return (ctx.current as any)?.[key]; + } + + /** + * Normalize identity / ownership, resolve ModelId, enforce uniqueness and IsDefault mutex. + * Static so we can read `ctx.mode` (Create pre-assigns Id before validation). + */ + @Constraint(['Name', 'ScopeKey', 'Application', 'ModelName', 'ModelId', 'UserId', 'IsDefault', 'Condition', 'Active', 'CreateUid']) + static async validateSavedFilterConstraint(self: SavedFilter, ctx: ConstraintContext): Promise { + const isCreate = ctx.mode === 'create'; + const values = ctx.values as Record; + const currentId = String((isCreate ? values.Id : SavedFilter._mergedField(self, ctx, 'Id')) || '').trim() || undefined; + const actor = String(this.userId || '').trim(); + if (!actor) { + SavedFilter._fail('PermissionDenied', _t('Authentication required', { scope: SCOPE }), GrpcCode.Unauthenticated); + } + + const app = String(SavedFilter._mergedField(self, ctx, 'Application') || '').trim(); + const modelName = String(SavedFilter._mergedField(self, ctx, 'ModelName') || '').trim(); + const name = String(SavedFilter._mergedField(self, ctx, 'Name') || '').trim(); + if (!app || !modelName || !name) { + SavedFilter._fail('InvalidArgument', _t('Name, Application, and ModelName are required', { scope: SCOPE })); + } + values.Application = app; + values.ModelName = modelName; + values.Name = name; + values.ScopeKey = normalizeScopeKey(SavedFilter._mergedField(self, ctx, 'ScopeKey')); + + const modelRows = await MetaModelService.Search( + { And: [['Application', '=', app], ['Name', '=', modelName]] } as any, + { fields: ['Id'], limit: 1 } as any + ); + const modelId = String(modelRows?.[0]?.Id || '').trim(); + if (!modelId) { + SavedFilter._fail( + 'FailedPrecondition', + _t('No effective model found for %s.%s', { scope: SCOPE }, app, modelName), + GrpcCode.FailedPrecondition + ); + } + values.ModelId = modelId; + + if (isCreate) { + values.CreateUid = actor; + const touchedUserId = Object.prototype.hasOwnProperty.call(values, 'UserId'); + if (!touchedUserId) { + values.UserId = actor; + } else { + values.UserId = SavedFilter._normalizeOwnerUserId(values.UserId, actor); + } + if (values.IsDefault == null) values.IsDefault = false; + if (values.Active == null) values.Active = true; + if (values.Condition == null) values.Condition = {}; + } else { + // CreateUid is immutable. + values.CreateUid = String((ctx.current as any)?.CreateUid || SavedFilter._mergedField(self, ctx, 'CreateUid') || '').trim(); + if (Object.prototype.hasOwnProperty.call(values, 'UserId')) { + values.UserId = SavedFilter._normalizeOwnerUserId(values.UserId, actor); + } + } + + const effectiveUserId = (() => { + const raw = Object.prototype.hasOwnProperty.call(values, 'UserId') + ? values.UserId + : SavedFilter._mergedField(self, ctx, 'UserId'); + if (raw == null || raw === '') return null; + return String(raw).trim(); + })(); + + await SavedFilter._assertUniqueName(app, modelName, values.ScopeKey, effectiveUserId, name, isCreate ? undefined : currentId); + + const isDefault = Object.prototype.hasOwnProperty.call(values, 'IsDefault') + ? values.IsDefault === true + : SavedFilter._mergedField(self, ctx, 'IsDefault') === true; + if (isDefault) { + await SavedFilter._clearOtherDefaults(app, modelName, values.ScopeKey, effectiveUserId, isCreate ? undefined : currentId); + } + } +} diff --git a/modules/web/service/tests/saved_filter.test.ts b/modules/web/service/tests/saved_filter.test.ts new file mode 100644 index 000000000..02cb9a4da --- /dev/null +++ b/modules/web/service/tests/saved_filter.test.ts @@ -0,0 +1,1250 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import Role from '@/auth/service/models/role'; +import User from '@/auth/service/models/user'; +import UserRole from '@/auth/service/models/user_role'; +import { withContext as withModelContext } from '@/core/service/api/context'; +import { ChoysumError } from '@/core/service/error'; +import { createServiceByModel } from '@/core/service/rpc'; +import type MetaModel from '@/meta/service/models/model'; +import MetaModelData from '@/meta/service/models/model_data'; +import SavedFilter from '@/web/service/models/saved_filter'; + +const MetaModelService = createServiceByModel('meta.MetaModel'); + +async function metaModelId(app: string, name: string): Promise { + const rows = await MetaModelService.Search( + { And: [['Application', '=', app], ['Name', '=', name]] } as any, + { fields: ['Id'], limit: 1 } as any + ); + return String(rows?.[0]?.Id || '').trim(); +} + +const RR_CACHE_KEY = Symbol.for('choysum.recordrule.cache'); +const FR_CACHE_KEY = Symbol.for('choysum.fieldrule.cache'); + +function ensureRequestContext(): any { + const root: any = (globalThis as any).$choysum ?? {}; + if (!root.request) root.request = {}; + if (!root.request.context) root.request.context = {}; + const jsCtx = root.request.context; + if (!jsCtx.ctx) jsCtx.ctx = {}; + if (!jsCtx.req) jsCtx.req = {}; + if (!jsCtx.identity) jsCtx.identity = {}; + (globalThis as any).$choysum = root; + return jsCtx; +} + +function resetRequestContext(): void { + const jsCtx = ensureRequestContext(); + jsCtx.ctx = {}; + jsCtx.req = { + depth: 0, + recordRuleMode: 'allowlist', + recordRuleAllow: [ + 'web.SavedFilter:read', + 'web.SavedFilter:write', + 'web.SavedFilter:create', + 'web.SavedFilter:delete', + 'SavedFilter:read', + 'SavedFilter:write', + 'SavedFilter:create', + 'SavedFilter:delete', + 'meta.MetaModel:read', + 'MetaModel:read', + 'meta.MetaModelData:read', + 'MetaModelData:read', + 'web.FieldDefault:read', + 'FieldDefault:read', + 'web.AppSetting:read', + 'AppSetting:read', + 'auth.User:read', + 'auth.User:write', + 'auth.User:create', + 'User:read', + 'User:write', + 'User:create', + 'auth.Role:read', + 'Role:read', + 'auth.UserRole:read', + 'auth.UserRole:write', + 'auth.UserRole:create', + 'UserRole:read', + 'UserRole:write', + 'UserRole:create', + 'auth.RoleRecordRule:read', + 'RoleRecordRule:read', + ], + fieldRuleMode: 'skip', + }; + jsCtx.identity = { userId: uid('bootstrap') }; + delete (jsCtx as any)[Symbol.for('choysum.ctx.override')]; + delete (jsCtx as any)[Symbol.for('choysum.ctx.frozen')]; + delete (jsCtx as any)[RR_CACHE_KEY]; + delete (jsCtx as any)[FR_CACHE_KEY]; +} + +function uid(prefix: string): string { + const xid = (globalThis as any).$choysum?.xid?.New?.(); + const u = typeof xid === 'string' && xid.trim() ? xid.trim() : String(Date.now()); + return `${prefix}_${u}`; +} + +function setIdentity(userId?: string): void { + const jsCtx = ensureRequestContext(); + if (!jsCtx.identity) jsCtx.identity = {}; + if (userId) jsCtx.identity.userId = userId; + else delete jsCtx.identity.userId; +} + +function setReq(patch: Record): void { + const jsCtx = ensureRequestContext(); + if (!jsCtx.req) jsCtx.req = {}; + Object.assign(jsCtx.req, patch); +} + +function disableAllowlist(): void { + setReq({ recordRuleMode: '', recordRuleAllow: [] }); +} + +function toErr(err: any): { domain?: string; code?: string } | null { + if (!err) return null; + if (err instanceof ChoysumError) return err as any; + const visited = new Set(); + const queue: any[] = [err]; + while (queue.length) { + const cur = queue.shift(); + if (!cur || visited.has(cur)) continue; + visited.add(cur); + if (cur instanceof ChoysumError) return cur as any; + if (typeof cur === 'object') { + if (typeof cur.domain === 'string' || typeof cur.code === 'string') { + return { domain: cur.domain, code: cur.code }; + } + if (cur.cause) queue.push(cur.cause); + if (cur.error) queue.push(cur.error); + } + } + return null; +} + +function collectErrorCodes(err: any): string[] { + const codes: string[] = []; + const visited = new Set(); + const queue: any[] = [err]; + while (queue.length) { + const cur = queue.shift(); + if (!cur || visited.has(cur)) continue; + visited.add(cur); + if (typeof cur.code === 'string' && cur.code) codes.push(cur.code); + if (typeof cur?.metadata?.causeCode === 'string' && cur.metadata.causeCode) { + codes.push(cur.metadata.causeCode); + } + if (typeof cur?.meta?.causeCode === 'string' && cur.meta.causeCode) { + codes.push(cur.meta.causeCode); + } + if (Array.isArray(cur.issues)) for (const issue of cur.issues) queue.push(issue); + if (cur.cause) queue.push(cur.cause); + if (cur.error) queue.push(cur.error); + } + return codes; +} + +async function expectCode(fn: () => Promise, code: string, messageHint?: string): Promise { + let caught: any; + try { + await fn(); + } catch (e) { + caught = e; + } + if (!caught) { + throw new Error(`expected error ${code}, got nothing`); + } + const oe = toErr(caught); + const codes = collectErrorCodes(caught); + const hasCode = oe?.code === code || codes.includes(code); + if (!hasCode) { + throw new Error(`expected error ${code}, got codes=${codes.join(',') || '(none)'} msg=${String((caught as any)?.message || caught)}`); + } + // messageHint is an additional assertion after the code matches (not an alternative). + if (messageHint) { + const msg = String((caught as any)?.message || ''); + if (!msg.includes(messageHint)) { + throw new Error(`expected message hint=${messageHint}, got ${msg}`); + } + } +} + +async function resolveRoleByCode(code: string): Promise { + const rows = await Role.Search({ And: [['Code', '=', code]] } as any, { fields: ['Id'], limit: 1 } as any); + const id = String((rows as any)?.[0]?.Id || '').trim(); + if (!id) throw new Error(`bootstrap role not found: ${code}`); + return id; +} + +async function resolveAdminCompanyId(): Promise { + const rows = await User.Search({ And: [['Username', '=', 'admin']] } as any, { fields: ['CompanyId'], limit: 1 } as any); + const companyId = String((rows as any)?.[0]?.CompanyId || '').trim(); + if (!companyId) throw new Error('bootstrap admin company not found'); + return companyId; +} + +async function createBaseUser(companyId: string): Promise { + return await withModelContext( + { activeCompanyId: companyId, enabledCompanyIds: [companyId] } as any, + async () => { + const created = await User.Create( + { + Username: uid('sf_u'), + PasswordHash: 'test', + FirstName: 'SF', + LastName: 'User', + CompanyId: companyId, + CompanyIds: [companyId], + IsActive: true, + } as any, + ['Id'] as any + ); + const userId = String((created as any).Id || '').trim(); + const roleId = await resolveRoleByCode('base.user'); + await UserRole.Create( + { + UserId: { Id: userId } as any, + RoleId: { Id: roleId } as any, + CompanyId: null as any, + } as any, + ['Id'] as any + ); + return userId; + }, + { merge: false } + ); +} + +test('SF13: web FieldDefault and AppSetting models exist after declared service', async () => { + resetRequestContext(); + const fd = await MetaModelService.Search( + { + And: [ + ['Application', '=', 'web'], + ['Name', '=', 'FieldDefault'], + ], + } as any, + { fields: ['Id'], limit: 1 } as any + ); + const as = await MetaModelService.Search( + { + And: [ + ['Application', '=', 'web'], + ['Name', '=', 'AppSetting'], + ], + } as any, + { fields: ['Id'], limit: 1 } as any + ); + expect(Array.isArray(fd) && fd.length > 0).toBe(true); + expect(Array.isArray(as) && as.length > 0).toBe(true); +}); + +test('SavedFilter CRUD + IsDefault exclusivity + visibility', async () => { + resetRequestContext(); + const actor = uid('sf_actor'); + setIdentity(actor); + + const modelId = await metaModelId('web', 'SavedFilter'); + expect(modelId).toBeTruthy(); + + const nameA = uid('fav_a'); + const privateFav = await SavedFilter.Create( + { + Name: nameA, + Application: 'web', + ModelName: 'SavedFilter', + Condition: { And: [['Active', '=', true]] }, + IsDefault: true, + } as any, + ['Id', 'UserId', 'ModelId', 'CreateUid', 'IsDefault'] as any + ); + expect(String((privateFav as any).UserId)).toBe(actor); + expect(String((privateFav as any).ModelId)).toBe(modelId); + expect(String((privateFav as any).CreateUid)).toBe(actor); + expect((privateFav as any).IsDefault).toBe(true); + + const nameB = uid('fav_b'); + const second = await SavedFilter.Create( + { + Name: nameB, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'IsDefault'] as any + ); + expect((second as any).IsDefault).toBe(true); + const firstAgain = await SavedFilter.Browse(String((privateFav as any).Id), ['IsDefault'] as any); + expect((firstAgain as any).IsDefault).toBe(false); + + const sharedName = uid('shared'); + const shared = await SavedFilter.Create( + { + Name: sharedName, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: false, + } as any, + ['Id', 'UserId', 'CreateUid'] as any + ); + expect((shared as any).UserId == null || (shared as any).UserId === '').toBe(true); + + const other = uid('sf_other'); + setIdentity(other); + const visible = await SavedFilter.Search( + { + And: [ + ['Application', '=', 'web'], + ['ModelName', '=', 'SavedFilter'], + { + Or: [ + ['UserId', '=', other], + ['UserId', '=', null], + ], + }, + ], + } as any, + { fields: ['Id', 'Name', 'UserId'] } as any + ); + const ids = new Set((visible || []).map((r: any) => String(r.Id))); + expect(ids.has(String((shared as any).Id))).toBe(true); + expect(ids.has(String((privateFav as any).Id))).toBe(false); + + setIdentity(actor); + await SavedFilter.DeleteById(String((privateFav as any).Id)); + await SavedFilter.DeleteById(String((second as any).Id)); + await SavedFilter.DeleteById(String((shared as any).Id)); +}); + +test('SavedFilter rejects Create without effective MetaModel', async () => { + resetRequestContext(); + const actor = uid('sf_noeff'); + setIdentity(actor); + await expectCode( + async () => + SavedFilter.Create( + { + Name: uid('gone'), + Application: 'no_such_app', + ModelName: 'NoSuchModel', + Condition: {}, + } as any, + ['Id'] as any + ), + 'FailedPrecondition', + 'No effective model' + ); +}); + +test('SavedFilter rejects foreign UserId on Create', async () => { + resetRequestContext(); + const actor = uid('sf_owner'); + const other = uid('sf_victim'); + setIdentity(actor); + await expectCode( + async () => + SavedFilter.Create( + { + Name: uid('steal'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: other, + } as any, + ['Id'] as any + ), + 'PermissionDenied', + 'another user' + ); +}); + +test('SavedFilter private and shared IsDefault can coexist', async () => { + resetRequestContext(); + const actor = uid('sf_bucket'); + setIdentity(actor); + const privateName = uid('priv_def'); + const sharedName = uid('shared_def'); + const priv = await SavedFilter.Create( + { + Name: privateName, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'IsDefault', 'UserId'] as any + ); + const shared = await SavedFilter.Create( + { + Name: sharedName, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: true, + } as any, + ['Id', 'IsDefault', 'UserId'] as any + ); + expect((priv as any).IsDefault).toBe(true); + expect((shared as any).IsDefault).toBe(true); + const privAgain = await SavedFilter.Browse(String((priv as any).Id), ['IsDefault'] as any); + expect((privAgain as any).IsDefault).toBe(true); + await SavedFilter.DeleteById(String((priv as any).Id)); + await SavedFilter.DeleteById(String((shared as any).Id)); +}); + +test('web bootstrap seeds SavedFilter authz packs (RMA/RFR/RR)', async () => { + resetRequestContext(); + const expected: Array<{ name: string; model: string }> = [ + { name: 'rma_base_user_web_saved_filter_search', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_browse', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_create', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_update', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_update_by_id', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_delete', model: 'RoleMethodAccess' }, + { name: 'rma_base_user_web_saved_filter_delete_by_id', model: 'RoleMethodAccess' }, + { name: 'rfr_base_user_web_saved_filter_rw', model: 'RoleFieldRule' }, + { name: 'rrr_base_user_web_saved_filter_rc', model: 'RoleRecordRule' }, + { name: 'rrr_base_user_web_saved_filter_wd_private', model: 'RoleRecordRule' }, + { name: 'rrr_base_user_web_saved_filter_wd_shared', model: 'RoleRecordRule' }, + ]; + for (const { name, model } of expected) { + const rows = await MetaModelData.Search( + { + And: [ + ['Module', '=', 'web'], + ['Name', '=', name], + ], + } as any, + { fields: ['Id', 'Application', 'ModelName'], limit: 1 } as any + ); + expect(Array.isArray(rows) && rows.length === 1, `missing web.${name}`).toBe(true); + expect(String((rows as any)[0].Application)).toBe('auth'); + expect(String((rows as any)[0].ModelName)).toBe(model); + } +}); + +test('SF11: shared write/delete only for creator via Record rules', async () => { + resetRequestContext(); + const companyId = await resolveAdminCompanyId(); + const creator = await createBaseUser(companyId); + const stranger = await createBaseUser(companyId); + + setIdentity(creator); + const shared = await SavedFilter.Create( + { + Name: uid('sf11'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + } as any, + ['Id', 'CreateUid'] as any + ); + + disableAllowlist(); + delete (ensureRequestContext() as any)[RR_CACHE_KEY]; + + setIdentity(stranger); + // Write/delete on another user's shared row: targets fail the WD record-rule expr → violation. + await expectCode( + async () => SavedFilter.UpdateById(String((shared as any).Id), { Name: uid('hijack') } as any, ['Id'] as any), + 'record_rule_violation', + 'violates record rule' + ); + await expectCode( + async () => SavedFilter.DeleteById(String((shared as any).Id)), + 'record_rule_violation', + 'violates record rule' + ); + + setIdentity(creator); + await SavedFilter.UpdateById(String((shared as any).Id), { Name: uid('ok') } as any, ['Id'] as any); + const deleted = await SavedFilter.DeleteById(String((shared as any).Id)); + expect(deleted).toBe(1); +}); + +test('SavedFilter rejects Create without authentication', async () => { + resetRequestContext(); + setIdentity(undefined); + await expectCode( + async () => + SavedFilter.Create( + { + Name: uid('anon'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ), + 'PermissionDenied', + 'Authentication required' + ); +}); + +test('SavedFilter rejects duplicate Name in the same ownership bucket', async () => { + resetRequestContext(); + const actor = uid('sf_dup'); + setIdentity(actor); + const name = uid('same_name'); + const first = await SavedFilter.Create( + { + Name: name, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ); + await expectCode( + async () => + SavedFilter.Create( + { + Name: name, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ), + 'AlreadyExists', + 'already exists' + ); + await SavedFilter.DeleteById(String((first as any).Id)); +}); + +test('SavedFilter ScopeKey scopes Name uniqueness and IsDefault mutex', async () => { + resetRequestContext(); + const actor = uid('sf_scope'); + setIdentity(actor); + const name = uid('scoped_name'); + const a = await SavedFilter.Create( + { + Name: name, + ScopeKey: '/web/partners/1', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'ScopeKey', 'IsDefault'] as any + ); + expect((a as any).ScopeKey).toBe('/web/partners/:id'); + expect((a as any).IsDefault).toBe(true); + + const b = await SavedFilter.Create( + { + Name: name, + ScopeKey: '/web/companies/2', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'ScopeKey', 'IsDefault'] as any + ); + expect((b as any).ScopeKey).toBe('/web/companies/:id'); + expect((b as any).IsDefault).toBe(true); + const aStill = await SavedFilter.Browse(String((a as any).Id), ['IsDefault'] as any); + expect((aStill as any).IsDefault).toBe(true); + + await expectCode( + async () => + SavedFilter.Create( + { + Name: name, + ScopeKey: '/web/partners/99', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ), + 'AlreadyExists', + 'already exists' + ); + + const a2 = await SavedFilter.Create( + { + Name: uid('scoped_other'), + ScopeKey: '/web/partners/3', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'IsDefault'] as any + ); + expect((a2 as any).IsDefault).toBe(true); + const aCleared = await SavedFilter.Browse(String((a as any).Id), ['IsDefault'] as any); + const bStill = await SavedFilter.Browse(String((b as any).Id), ['IsDefault'] as any); + expect((aCleared as any).IsDefault).toBe(false); + expect((bStill as any).IsDefault).toBe(true); + + await SavedFilter.DeleteById(String((a as any).Id)); + await SavedFilter.DeleteById(String((b as any).Id)); + await SavedFilter.DeleteById(String((a2 as any).Id)); +}); + +test('SavedFilter normalizes ScopeKey query/hash/opaque on Create', async () => { + resetRequestContext(); + const actor = uid('sf_scope_norm'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('scoped_norm'), + ScopeKey: '\\web\\partners\\abc123def456ghi7\\edit?x=1#y', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id', 'ScopeKey'] as any + ); + expect((created as any).ScopeKey).toBe('/web/partners/:id/edit'); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter fills Create defaults (UserId/IsDefault/Active/Condition)', async () => { + resetRequestContext(); + const actor = uid('sf_defaults'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('fills'), + Application: 'web', + ModelName: 'SavedFilter', + } as any, + ['Id', 'UserId', 'IsDefault', 'Active', 'Condition', 'CreateUid', 'ScopeKey'] as any + ); + expect(String((created as any).UserId)).toBe(actor); + expect((created as any).IsDefault).toBe(false); + expect((created as any).Active).toBe(true); + expect((created as any).Condition || {}).toEqual({}); + expect(String((created as any).CreateUid)).toBe(actor); + expect((created as any).ScopeKey).toBe(''); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter Update keeps CreateUid immutable and normalizes UserId', async () => { + resetRequestContext(); + const actor = uid('sf_upd'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('upd'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id', 'CreateUid', 'UserId'] as any + ); + const createUid = String((created as any).CreateUid); + const updated = await SavedFilter.UpdateById( + String((created as any).Id), + { CreateUid: uid('hijack_uid'), UserId: '' } as any, + ['Id', 'CreateUid', 'UserId'] as any + ); + expect(String((updated as any).CreateUid)).toBe(createUid); + expect((updated as any).UserId == null || (updated as any).UserId === '').toBe(true); + + await expectCode( + async () => + SavedFilter.UpdateById(String((created as any).Id), { UserId: uid('other') } as any, ['Id'] as any), + 'PermissionDenied', + 'another user' + ); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter private IsDefault update clears other defaults with exceptId', async () => { + resetRequestContext(); + const actor = uid('sf_except'); + setIdentity(actor); + const a = await SavedFilter.Create( + { + Name: uid('def_a'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'IsDefault'] as any + ); + const b = await SavedFilter.Create( + { + Name: uid('def_b'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: false, + } as any, + ['Id', 'IsDefault'] as any + ); + await SavedFilter.UpdateById(String((b as any).Id), { IsDefault: true } as any, ['Id', 'IsDefault'] as any); + const aAgain = await SavedFilter.Browse(String((a as any).Id), ['IsDefault'] as any); + expect((aAgain as any).IsDefault).toBe(false); + await SavedFilter.DeleteById(String((a as any).Id)); + await SavedFilter.DeleteById(String((b as any).Id)); +}); + +test('SavedFilter rejects Create missing Name/Application/ModelName', async () => { + resetRequestContext(); + setIdentity(uid('sf_req')); + await expectCode( + async () => + SavedFilter.Create( + { + Name: '', + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ), + 'InvalidArgument', + 'required' + ); +}); + +test('SavedFilter Update reads unchanged fields from current via mergedField', async () => { + resetRequestContext(); + const actor = uid('sf_merged'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('merged'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: { And: [['A', '=', 1]] }, + Active: true, + } as any, + ['Id', 'Name', 'Condition'] as any + ); + // Touch only Active so Name/Application/ModelName resolve from current. + const updated = await SavedFilter.UpdateById( + String((created as any).Id), + { Active: false } as any, + ['Id', 'Name', 'Active', 'Application', 'ModelName'] as any + ); + expect(String((updated as any).Name)).toBe(String((created as any).Name)); + expect((updated as any).Active).toBe(false); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter shared-default clear PermissionDenied when stranger cannot replace', async () => { + resetRequestContext(); + const companyId = await resolveAdminCompanyId(); + const creator = await createBaseUser(companyId); + const stranger = await createBaseUser(companyId); + + setIdentity(creator); + const shared = await SavedFilter.Create( + { + Name: uid('shared_def_owner'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: true, + } as any, + ['Id', 'CreateUid', 'IsDefault'] as any + ); + expect((shared as any).IsDefault).toBe(true); + + disableAllowlist(); + delete (ensureRequestContext() as any)[RR_CACHE_KEY]; + + setIdentity(stranger); + let caught: any; + try { + await SavedFilter.Create( + { + Name: uid('shared_def_stranger'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: true, + } as any, + ['Id'] as any + ); + } catch (e) { + caught = e; + } + if (!caught) { + throw new Error('expected shared-default replacement to fail'); + } + const codes = collectErrorCodes(caught); + const msg = String((caught as any)?.message || ''); + if (!codes.includes('PermissionDenied') || !msg.includes("another user's shared default")) { + throw new Error(`expected PermissionDenied with shared-default message, got codes=${codes.join(',')} msg=${msg}`); + } + + // Creator's shared default must remain the sole default. + setIdentity(creator); + const again = await SavedFilter.Browse(String((shared as any).Id), ['IsDefault'] as any); + expect((again as any).IsDefault).toBe(true); + await SavedFilter.DeleteById(String((shared as any).Id)); +}); + +test('SavedFilter whitespace UserId normalizes to shared null', async () => { + resetRequestContext(); + const actor = uid('sf_ws'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('ws_uid'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: ' ', + IsDefault: true, + } as any, + ['Id', 'UserId', 'IsDefault'] as any + ); + expect((created as any).UserId == null || (created as any).UserId === '').toBe(true); + expect((created as any).IsDefault).toBe(true); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter creator can replace own shared default', async () => { + resetRequestContext(); + const actor = uid('sf_shared_ok'); + setIdentity(actor); + const first = await SavedFilter.Create( + { + Name: uid('shared_a'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: true, + } as any, + ['Id', 'IsDefault', 'CreateUid'] as any + ); + expect((first as any).IsDefault).toBe(true); + const second = await SavedFilter.Create( + { + Name: uid('shared_b'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + UserId: null, + IsDefault: true, + } as any, + ['Id', 'IsDefault'] as any + ); + expect((second as any).IsDefault).toBe(true); + const firstAgain = await SavedFilter.Browse(String((first as any).Id), ['IsDefault'] as any); + expect((firstAgain as any).IsDefault).toBe(false); + await SavedFilter.DeleteById(String((first as any).Id)); + await SavedFilter.DeleteById(String((second as any).Id)); +}); + +test('SavedFilter Update without IsDefault still clears peers when row is default', async () => { + resetRequestContext(); + const actor = uid('sf_upd_def'); + setIdentity(actor); + const a = await SavedFilter.Create( + { + Name: uid('upd_def_a'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: true, + } as any, + ['Id', 'IsDefault', 'Name'] as any + ); + const b = await SavedFilter.Create( + { + Name: uid('upd_def_b'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: false, + } as any, + ['Id', 'IsDefault'] as any + ); + // Promote b via Update; a should clear. Then rename a while it is no longer default + // and rename the still-default b without sending IsDefault (mergedField path). + await SavedFilter.UpdateById(String((b as any).Id), { IsDefault: true } as any, ['Id', 'IsDefault'] as any); + const newName = uid('renamed_def'); + await SavedFilter.UpdateById(String((b as any).Id), { Name: newName } as any, ['Id', 'Name', 'IsDefault'] as any); + const bAgain = await SavedFilter.Browse(String((b as any).Id), ['Name', 'IsDefault'] as any); + expect(String((bAgain as any).Name)).toBe(newName); + expect((bAgain as any).IsDefault).toBe(true); + const aAgain = await SavedFilter.Browse(String((a as any).Id), ['IsDefault'] as any); + expect((aAgain as any).IsDefault).toBe(false); + await SavedFilter.DeleteById(String((a as any).Id)); + await SavedFilter.DeleteById(String((b as any).Id)); +}); + +test('SavedFilter Update rename collision uses exceptId uniqueness', async () => { + resetRequestContext(); + const actor = uid('sf_rename'); + setIdentity(actor); + const nameA = uid('rename_a'); + const nameB = uid('rename_b'); + const a = await SavedFilter.Create( + { + Name: nameA, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id', 'Name'] as any + ); + const b = await SavedFilter.Create( + { + Name: nameB, + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id', 'Name'] as any + ); + await expectCode( + async () => SavedFilter.UpdateById(String((b as any).Id), { Name: nameA } as any, ['Id'] as any), + 'AlreadyExists', + 'already exists' + ); + // Same-name update on self must succeed (exceptId excludes current). + const self = await SavedFilter.UpdateById(String((a as any).Id), { Name: nameA, Active: true } as any, [ + 'Id', + 'Name', + ] as any); + expect(String((self as any).Name)).toBe(nameA); + await SavedFilter.DeleteById(String((a as any).Id)); + await SavedFilter.DeleteById(String((b as any).Id)); +}); + +test('SavedFilter Create accepts explicit self UserId', async () => { + resetRequestContext(); + const actor = uid('sf_self'); + setIdentity(actor); + const created = await SavedFilter.Create( + { + Name: uid('self_uid'), + Application: 'web', + ModelName: 'SavedFilter', + UserId: actor, + Condition: {}, + } as any, + ['Id', 'UserId'] as any + ); + expect(String((created as any).UserId)).toBe(actor); + await SavedFilter.DeleteById(String((created as any).Id)); +}); + +test('SavedFilter constraint fills null IsDefault/Active/Condition on create', async () => { + resetRequestContext(); + const actor = uid('sf_null_defs'); + setIdentity(actor); + const SF = SavedFilter as any; + const values: Record = { + Id: uid('null_defs'), + Name: uid('null_defs_name'), + Application: 'web', + ModelName: 'SavedFilter', + UserId: actor, + IsDefault: null, + Active: null, + Condition: null, + }; + await SF.validateSavedFilterConstraint({}, { mode: 'create', values, current: undefined }); + expect(values.IsDefault).toBe(false); + expect(values.Active).toBe(true); + expect(values.Condition).toEqual({}); + expect(values.UserId).toBe(actor); +}); + +test('SavedFilter rejects null Application/ModelName via mergedField empty trim', async () => { + resetRequestContext(); + setIdentity(uid('sf_null_app')); + await expectCode( + async () => + SavedFilter.Create( + { + Name: uid('null_app'), + Application: null, + ModelName: 'SavedFilter', + Condition: {}, + } as any, + ['Id'] as any + ), + 'InvalidArgument', + 'required' + ); + await expectCode( + async () => + SavedFilter.Create( + { + Name: uid('null_model'), + Application: 'web', + ModelName: null, + Condition: {}, + } as any, + ['Id'] as any + ), + 'InvalidArgument', + 'required' + ); +}); + +test('SavedFilter _mergedField falls through values/self/current', () => { + const SF = SavedFilter as any; + expect(SF._mergedField({}, { values: undefined, current: {} }, 'Name')).toBeUndefined(); + expect(SF._mergedField({ Name: 'FromSelf' }, { values: undefined, current: {} }, 'Name')).toBe('FromSelf'); + expect(SF._mergedField({}, { values: {}, current: { Name: 'FromCurrent' } }, 'Name')).toBe('FromCurrent'); + expect(SF._mergedField({ Name: 'Self' }, { values: { Name: 'Values' }, current: { Name: 'Current' } }, 'Name')).toBe( + 'Values' + ); +}); + +test('SavedFilter _clearOtherDefaults covers null candidates, remaining fail, and canWrite edges', async () => { + resetRequestContext(); + const actor = uid('sf_clear_stub'); + setIdentity(actor); + const SF = SavedFilter as any; + const sudoOwn = Object.prototype.hasOwnProperty.call(SF, 'sudo'); + const updateOwn = Object.prototype.hasOwnProperty.call(SF, 'Update'); + const origSudo = SF.sudo; + const origUpdate = SF.Update; + try { + // candidates || [] when preflight returns null; remaining non-array skips _fail. + SF.sudo = async (_fn: any, opts: any) => { + const hint = String(opts?.hint || ''); + if (hint.includes('preflight')) return null; + if (hint.includes('check')) return null; + return origSudo.call(SavedFilter, _fn, opts); + }; + SF.Update = async () => []; + await SF._clearOtherDefaults('web', 'SavedFilter', '', null); + // Empty-string userId uses the same shared-bucket branch as null. + await SF._clearOtherDefaults('web', 'SavedFilter', '/scope', ''); + + // Shared row missing CreateUid → !canWrite → PermissionDenied (CreateUid || ''). + SF.sudo = async (_fn: any, opts: any) => { + const hint = String(opts?.hint || ''); + if (hint.includes('preflight')) return [{ Id: 'x', UserId: null }]; + return []; + }; + await expectCode( + async () => SF._clearOtherDefaults('web', 'SavedFilter', '', null), + 'PermissionDenied', + "another user's shared default" + ); + + // Private-ish row with falsy UserId 0 → UserId || '' → !canWrite. + SF.sudo = async (_fn: any, opts: any) => { + const hint = String(opts?.hint || ''); + if (hint.includes('preflight')) return [{ Id: 'y', UserId: 0 }]; + return []; + }; + await expectCode( + async () => SF._clearOtherDefaults('web', 'SavedFilter', '', actor), + 'PermissionDenied', + "another user's shared default" + ); + + // Writable preflight + stuck remaining after Update → post-check _fail. + SF.sudo = async (_fn: any, opts: any) => { + const hint = String(opts?.hint || ''); + if (hint.includes('preflight')) return [{ Id: 'z', UserId: null, CreateUid: actor }]; + if (hint.includes('check')) return [{ Id: 'stuck' }]; + return []; + }; + SF.Update = async () => []; + await expectCode( + async () => SF._clearOtherDefaults('web', 'SavedFilter', '', null), + 'PermissionDenied', + "another user's shared default" + ); + } finally { + if (sudoOwn) SF.sudo = origSudo; + else delete SF.sudo; + if (updateOwn) SF.Update = origUpdate; + else delete SF.Update; + } +}); + +test('SavedFilter _clearOtherDefaults reads empty actor when identity has no userId', async () => { + resetRequestContext(); + setIdentity(undefined); + const SF = SavedFilter as any; + const sudoOwn = Object.prototype.hasOwnProperty.call(SF, 'sudo'); + const updateOwn = Object.prototype.hasOwnProperty.call(SF, 'Update'); + const origSudo = SF.sudo; + const origUpdate = SF.Update; + try { + SF.sudo = async () => []; + SF.Update = async () => []; + // Hits `String(this.userId || '').trim()` with falsy BaseModel.userId. + await SF._clearOtherDefaults('web', 'SavedFilter', '', null); + } finally { + if (sudoOwn) SF.sudo = origSudo; + else delete SF.sudo; + if (updateOwn) SF.Update = origUpdate; + else delete SF.Update; + } +}); + +test('SavedFilter _assertUniqueName treats empty-string UserId as shared bucket', async () => { + resetRequestContext(); + const actor = uid('sf_assert_empty'); + setIdentity(actor); + const SF = SavedFilter as any; + const name = uid('assert_empty_name'); + const shared = await SavedFilter.Create( + { + Name: name, + ScopeKey: '/assert', + Application: 'web', + ModelName: 'SavedFilter', + UserId: null, + Condition: {}, + } as any, + ['Id'] as any + ); + await expectCode( + async () => SF._assertUniqueName('web', 'SavedFilter', '/assert', '', name), + 'AlreadyExists', + 'already exists' + ); + // exceptId skips the existing row (update rename path). + await SF._assertUniqueName('web', 'SavedFilter', '/assert', '', name, String((shared as any).Id)); + await SavedFilter.DeleteById(String((shared as any).Id)); +}); + +test('SavedFilter validateSavedFilterConstraint covers empty create Id and CreateUid fallbacks', async () => { + resetRequestContext(); + const actor = uid('sf_validate'); + setIdentity(actor); + const SF = SavedFilter as any; + const modelId = await metaModelId('web', 'SavedFilter'); + if (!modelId) { + throw new Error('expected MetaModel for web.SavedFilter'); + } + + // Create with whitespace Id → trim || undefined (exceptId omitted on unique check). + const valuesCreate: Record = { + Id: ' ', + Name: uid('empty_id'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: false, + Active: true, + }; + await SF.validateSavedFilterConstraint({}, { mode: 'create', values: valuesCreate, current: undefined }); + expect(valuesCreate.UserId).toBe(actor); + expect(valuesCreate.ModelId).toBe(modelId); + + // Falsy Id hits `(values.Id || '')` then `trim() || undefined`. + const valuesNullId: Record = { + Id: null, + Name: uid('null_id'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: false, + Active: true, + }; + await SF.validateSavedFilterConstraint({}, { mode: 'create', values: valuesNullId, current: undefined }); + expect(valuesNullId.ModelId).toBe(modelId); + + const valuesUndefId: Record = { + Name: uid('undef_id'), + Application: 'web', + ModelName: 'SavedFilter', + Condition: {}, + IsDefault: false, + Active: true, + }; + await SF.validateSavedFilterConstraint({}, { mode: 'create', values: valuesUndefId, current: undefined }); + expect(valuesUndefId.ModelId).toBe(modelId); + + // Update CreateUid chain: empty current → self → final ''. + const valuesUpd: Record = { + Name: uid('cuid_fb'), + Application: 'web', + ModelName: 'SavedFilter', + IsDefault: false, + }; + await SF.validateSavedFilterConstraint( + { CreateUid: 'fromSelf', UserId: actor, ModelId: modelId, Id: uid('row') }, + { + mode: 'update', + values: valuesUpd, + current: { CreateUid: '', UserId: actor, Application: 'web', ModelName: 'SavedFilter', Name: valuesUpd.Name }, + } + ); + expect(valuesUpd.CreateUid).toBe('fromSelf'); + + const valuesEmpty: Record = { + Name: uid('cuid_empty'), + Application: 'web', + ModelName: 'SavedFilter', + IsDefault: false, + }; + await SF.validateSavedFilterConstraint( + { UserId: actor, ModelId: modelId, Id: uid('row2') }, + { + mode: 'update', + values: valuesEmpty, + current: { UserId: actor, Application: 'web', ModelName: 'SavedFilter', Name: valuesEmpty.Name }, + } + ); + expect(valuesEmpty.CreateUid).toBe(''); +}); + +test('SavedFilter validate merges ScopeKey from current on update', async () => { + resetRequestContext(); + const actor = uid('sf_scope_merge'); + setIdentity(actor); + const SF = SavedFilter as any; + const modelId = await metaModelId('web', 'SavedFilter'); + if (!modelId) { + throw new Error('expected MetaModel for web.SavedFilter'); + } + const values: Record = { + Name: uid('scope_merge'), + IsDefault: false, + }; + await SF.validateSavedFilterConstraint( + { + UserId: actor, + ModelId: modelId, + Id: uid('row_scope'), + Application: 'web', + ModelName: 'SavedFilter', + ScopeKey: '/web/from-current/1', + Name: values.Name, + CreateUid: actor, + }, + { + mode: 'update', + values, + current: { + UserId: actor, + Application: 'web', + ModelName: 'SavedFilter', + Name: values.Name, + ScopeKey: '/web/from-current/1', + CreateUid: actor, + }, + } + ); + expect(values.ScopeKey).toBe('/web/from-current/:id'); +}); diff --git a/modules/web/service/tests/scope_key.test.ts b/modules/web/service/tests/scope_key.test.ts new file mode 100644 index 000000000..e6a4cfdfe --- /dev/null +++ b/modules/web/service/tests/scope_key.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeScopeKey } from '@/web/service/models/_scope_key'; + +test('normalizeScopeKey returns empty for blank input', () => { + expect(normalizeScopeKey('')).toBe(''); + expect(normalizeScopeKey(null)).toBe(''); + expect(normalizeScopeKey(undefined)).toBe(''); + expect(normalizeScopeKey(' ')).toBe(''); +}); + +test('normalizeScopeKey strips query and hash independently', () => { + expect(normalizeScopeKey('/web/users?q=1')).toBe('/web/users'); + expect(normalizeScopeKey('/web/users#section')).toBe('/web/users'); + expect(normalizeScopeKey('/web/users/?q=1#hash')).toBe('/web/users'); +}); + +test('normalizeScopeKey collapses slashes and keeps root', () => { + expect(normalizeScopeKey('\\web\\users\\')).toBe('/web/users'); + expect(normalizeScopeKey('web/users///')).toBe('/web/users'); + expect(normalizeScopeKey('/')).toBe('/'); +}); + +test('normalizeScopeKey replaces numeric and opaque segments', () => { + expect(normalizeScopeKey('/web/partners/42/edit')).toBe('/web/partners/:id/edit'); + expect(normalizeScopeKey('/web/partners/abc123def456ghi7/form')).toBe('/web/partners/:id/form'); + expect(normalizeScopeKey('/web/partners/AbC_def-0123456789/x')).toBe('/web/partners/:id/x'); + expect(normalizeScopeKey('/web/partners/short/form')).toBe('/web/partners/short/form'); +}); diff --git a/modules/web/web/components/view/OChartView.vue b/modules/web/web/components/view/OChartView.vue index d7b3c759d..5648bd5f9 100644 --- a/modules/web/web/components/view/OChartView.vue +++ b/modules/web/web/components/view/OChartView.vue @@ -746,6 +746,7 @@ function onSearch(payload: QueryUpdatePayload) { keyword: payload.keyword, keywordFields: props.keywordFields, forcedCondition: props.forcedCondition, + appliedFilters: payload.appliedFilters as any, orderBy: props.orderBy, }) .then(() => { diff --git a/modules/web/web/components/view/OKanbanView.firstframe.test.ts b/modules/web/web/components/view/OKanbanView.firstframe.test.ts new file mode 100644 index 000000000..4212d9453 --- /dev/null +++ b/modules/web/web/components/view/OKanbanView.firstframe.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { defineComponent, h, reactive, ref } from 'vue'; +import { mount, flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { applyMock, preloadLaneMock, awaitFieldSelectionMock, deferState } = vi.hoisted(() => ({ + applyMock: vi.fn(async () => {}), + preloadLaneMock: vi.fn(async () => {}), + awaitFieldSelectionMock: vi.fn(async () => {}), + deferState: { defer: false }, +})); + +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })); + +vi.mock('@/web/web/controllers/kanbanController', () => ({ + createKanbanController: vi.fn(() => ({ + vm: reactive({ + result: { kind: 'search', total: 0, rows: [] }, + }), + lanes: ref([]), + laneRecords: ref({}), + apply: applyMock, + paginate: vi.fn(async () => {}), + getLaneField: () => null, + getLaneRemain: () => 0, + preloadLane: preloadLaneMock, + loadMoreLane: vi.fn(async () => {}), + })), +})); + +vi.mock('@/web/web/query/utils/registry/fieldReady', () => ({ + awaitFieldSelection: (...args: any[]) => awaitFieldSelectionMock(...args), +})); + +vi.mock('@/web/web/components/view/kanbanFirstFrame', () => ({ + shouldDeferKanbanFirstFrame: () => deferState.defer, +})); + +vi.mock('@/web/web/i18n', async () => { + const actual = await vi.importActual('@/web/web/i18n'); + return { + ...actual, + createTranslate: () => ({ _t: (msg: string) => msg, _lt: (msg: string) => msg }), + }; +}); + +vi.mock('element-plus', async () => { + const actual = await vi.importActual('element-plus'); + return { + ...actual, + ElMessage: { success: vi.fn(), error: vi.fn(), warning: vi.fn() }, + }; +}); + +vi.mock('vuedraggable', () => ({ + default: defineComponent({ + name: 'DraggableStub', + setup(_, { slots }) { + return () => h('div', { class: 'draggable-stub' }, slots.item?.({ element: { key: '1', payload: { Id: '1' } }, index: 0 })); + }, + }), +})); + +import OKanbanView from './OKanbanView.vue'; + +function makeStore() { + return { + fieldsMetadata: {}, + state: { + queryState: { + keyword: '', + appliedFilters: [], + appliedGroups: [], + keywordFields: [], + pagination: { limit: 20, offset: 0 }, + }, + result: { total: 0 }, + orderBy: undefined, + }, + } as any; +} + +const stubs = { + OViewContainer: { + template: `
`, + }, + OPagination: true, + 'el-button': true, + 'el-icon': true, + OSearchView: true, +}; + +describe('OKanbanView first-frame load', () => { + beforeEach(() => { + applyMock.mockClear(); + preloadLaneMock.mockClear(); + awaitFieldSelectionMock.mockClear(); + awaitFieldSelectionMock.mockImplementation(async () => {}); + deferState.defer = false; + }); + + it('skips mount apply when first-frame should defer to OSearchView', async () => { + deferState.defer = true; + const SearchStub = defineComponent({ + name: 'SearchStub', + setup() { + return () => h('div'); + }, + }); + const wrapper = mount(OKanbanView as any, { + props: { + store: makeStore(), + searchView: SearchStub, + showHeader: true, + showActions: false, + showPaginate: false, + }, + global: { stubs }, + }); + try { + await flushPromises(); + expect(awaitFieldSelectionMock).not.toHaveBeenCalled(); + expect(applyMock).not.toHaveBeenCalled(); + } finally { + wrapper.unmount(); + } + }); + + it('runs mount apply when first-frame should not defer', async () => { + deferState.defer = false; + const CustomSearch = defineComponent({ + name: 'CustomSearch', + setup() { + return () => h('div', { class: 'custom-search' }); + }, + }); + const wrapper = mount(OKanbanView as any, { + props: { + store: makeStore(), + searchView: CustomSearch, + showHeader: true, + showActions: false, + showPaginate: false, + }, + global: { stubs }, + }); + try { + await flushPromises(); + expect(awaitFieldSelectionMock).toHaveBeenCalled(); + expect(applyMock).toHaveBeenCalled(); + } finally { + wrapper.unmount(); + } + }); + + it('skips mount apply when custom search already emitted query-update', async () => { + deferState.defer = false; + // Hang field selection so onSearch never reaches apply; mount must still skip. + awaitFieldSelectionMock.mockImplementationOnce(() => new Promise(() => {})); + const SyncEmitSearch = defineComponent({ + name: 'SyncEmitSearch', + emits: ['query-update'], + setup(_, { emit }) { + emit('query-update', { + keyword: 'pre', + appliedFilters: [], + appliedGroups: [], + }); + return () => h('div', { class: 'sync-emit-search' }); + }, + }); + const wrapper = mount(OKanbanView as any, { + props: { + store: makeStore(), + searchView: SyncEmitSearch, + showHeader: true, + showActions: false, + showPaginate: false, + }, + global: { stubs }, + }); + try { + await flushPromises(); + expect(awaitFieldSelectionMock).toHaveBeenCalledTimes(1); + expect(applyMock).not.toHaveBeenCalled(); + } finally { + wrapper.unmount(); + } + }); + + it('onSearch with falsy payload does not apply', async () => { + deferState.defer = true; + const FalsyEmitSearch = defineComponent({ + name: 'FalsyEmitSearch', + emits: ['query-update'], + setup(_, { emit }) { + emit('query-update', null as any); + return () => h('div'); + }, + }); + const wrapper = mount(OKanbanView as any, { + props: { + store: makeStore(), + searchView: FalsyEmitSearch, + showHeader: true, + showActions: false, + showPaginate: false, + }, + global: { stubs }, + }); + try { + await flushPromises(); + expect(awaitFieldSelectionMock).not.toHaveBeenCalled(); + expect(applyMock).not.toHaveBeenCalled(); + } finally { + wrapper.unmount(); + } + }); +}); diff --git a/modules/web/web/components/view/OKanbanView.readonly.test.ts b/modules/web/web/components/view/OKanbanView.readonly.test.ts index 6ac5bf8e8..aaab25e8e 100644 --- a/modules/web/web/components/view/OKanbanView.readonly.test.ts +++ b/modules/web/web/components/view/OKanbanView.readonly.test.ts @@ -19,4 +19,12 @@ describe('OKanbanView laneFieldReadonly (T5.4)', () => { expect(src).not.toMatch(/laneFieldReadonly[\s\S]{0,200}getFieldMeta/); expect(src).not.toMatch(/laneFieldReadonly[\s\S]{0,200}ensureFieldsGet/); }); + + it('waits for OSearchView first-frame query-update instead of mount apply', () => { + expect(src).toContain('shouldDeferKanbanFirstFrame(props.searchView, OSearchView)'); + expect(src).toContain('First-frame load: when searchView is present, wait for its query-update'); + expect(src).not.toContain('const firstApplied = ref(false)'); + expect(src).toMatch(/function onSearch\(payload[\s\S]*?lastSearchPayload\.value = payload/); + expect(src).not.toMatch(/function onSearch\(payload[\s\S]*?if \(!firstApplied\.value\)/); + }); }); diff --git a/modules/web/web/components/view/OKanbanView.vue b/modules/web/web/components/view/OKanbanView.vue index 9f2d596de..cd57189ac 100644 --- a/modules/web/web/components/view/OKanbanView.vue +++ b/modules/web/web/components/view/OKanbanView.vue @@ -155,6 +155,7 @@ import { ElMessage } from 'element-plus'; import draggable from 'vuedraggable'; import { provide, defineComponent, reactive } from 'vue'; import OSearchView from '@/web/web/components/view/OSearchView.vue'; +import { shouldDeferKanbanFirstFrame } from '@/web/web/components/view/kanbanFirstFrame'; import { createTranslate } from '@/web/web/i18n'; const { _t } = createTranslate('web', { scope: 'web/components/view/OKanbanView' }); @@ -441,13 +442,9 @@ async function handleCreate() { } } -// Search and pagination +// Search and pagination — first-frame load comes from OSearchView query-update when searchView is set. function onSearch(payload: QueryUpdatePayload) { emit('search-change'); - if (!firstApplied.value) { - firstApplied.value = true; - return; - } lastSearchPayload.value = payload; if (payload) { awaitFieldSelection(store, { requireNonEmpty: true }).then(() => { @@ -491,12 +488,22 @@ function emitCardClick(rr: RecordRow) { // Normalize and merge forced conditions // Merge helpers and debug output were removed; the view layer now passes only the forced condition -// First-frame load +// First-frame load: when searchView is present, wait for its query-update (includes SavedFilter defaults). onMounted(async () => { await nextTick(); if (props.orderBy !== undefined) { (store.state as any).orderBy = props.orderBy as any; } + // Only OSearchView guarantees a mount-time query-update with SavedFilter defaults. + // Custom SearchViewComponent implementations may never emit; keep the mount apply. + if (shouldDeferKanbanFirstFrame(props.searchView, OSearchView)) { + return; + } + // Custom search views that already emitted query-update (onSearch sets lastSearchPayload + // synchronously) must not run a second fallback apply that can race/supersede it. + if (lastSearchPayload.value) { + return; + } // laneLoadLimit injection has been removed; queryState.pagination controls loading consistently await awaitFieldSelection(store, { requireNonEmpty: true }); await controller.apply({ @@ -508,7 +515,6 @@ onMounted(async () => { }); await nextTick(); await preloadInitialLanes(); - firstApplied.value = true; }); // Watch dynamic forcedCondition changes @@ -534,8 +540,6 @@ watch( ); const boardWrapRef = ref(null); -// Avoid running apply twice from initial mounted plus the initial OSearch trigger -const firstApplied = ref(false); // Latest search payload (keyword / appliedFilters / appliedGroups) const lastSearchPayload = ref | null>(null); diff --git a/modules/web/web/components/view/OListView.test.ts b/modules/web/web/components/view/OListView.test.ts index 6fa62f948..75fc86962 100644 --- a/modules/web/web/components/view/OListView.test.ts +++ b/modules/web/web/components/view/OListView.test.ts @@ -57,7 +57,7 @@ vi.mock('@/web/web/i18n', async () => { const actual = await vi.importActual('@/web/web/i18n'); return { ...actual, - createTranslate: () => ({ _t: (msg: string) => msg }), + createTranslate: () => ({ _t: (msg: string) => msg, _lt: (msg: string) => msg }), }; }); diff --git a/modules/web/web/components/view/OSearchView.scopeKey.test.ts b/modules/web/web/components/view/OSearchView.scopeKey.test.ts new file mode 100644 index 000000000..8e63f672f --- /dev/null +++ b/modules/web/web/components/view/OSearchView.scopeKey.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { mount, flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; + +const { sfSearch, actorState, routeState } = vi.hoisted(() => ({ + sfSearch: vi.fn(async () => [] as any[]), + actorState: { id: 'me' as string }, + routeState: { path: '/web/widgets/99/edit?x=1' }, +})); + +vi.mock('@/web/web/i18n', async () => { + const actual = await vi.importActual('@/web/web/i18n'); + return { + ...actual, + createTranslate: () => ({ _t: (msg: string) => msg, _lt: (msg: string) => msg }), + }; +}); + +vi.mock('@/web/web/stores/registry', () => ({ + createStoreByModel: (model: string) => { + if (model === 'web.SavedFilter') return { Search: (...args: any[]) => sfSearch(...args) }; + return {}; + }, +})); + +vi.mock('@/web/web/composables/search/actorUserId', () => ({ + actorUserId: () => actorState.id, +})); + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router'); + return { + ...actual, + useRoute: () => routeState, + }; +}); + +import OSearchView from './OSearchView.vue'; + +const OSearchStub = defineComponent({ + name: 'OSearch', + props: ['store', 'placeholder', 'currentKeyword', 'currentAppliedFilters', 'currentAppliedGroups', 'defaultFilters'], + emits: ['query-update', 'defaults-ready'], + setup() { + return () => h('div', { class: 'o-search-stub' }); + }, +}); + +describe('OSearchView ScopeKey from route', () => { + beforeEach(() => { + actorState.id = 'me'; + routeState.path = '/web/widgets/99/edit?x=1'; + sfSearch.mockReset(); + sfSearch.mockResolvedValue([]); + }); + + it('passes normalized ScopeKey into SavedFilter Search', async () => { + mount(OSearchView as any, { + props: { + store: { application: 'demo', modelName: 'Widget', state: { queryState: {} } }, + initialEmit: false, + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(sfSearch.mock.calls[0]![0].And).toEqual( + expect.arrayContaining([['ScopeKey', '=', '/web/widgets/:id/edit']]) + ); + }); + + it('uses empty ScopeKey when route.path is missing', async () => { + routeState.path = undefined as any; + mount(OSearchView as any, { + props: { + store: { application: 'demo', modelName: 'Widget', state: { queryState: {} } }, + initialEmit: false, + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(sfSearch.mock.calls[0]![0].And).toEqual(expect.arrayContaining([['ScopeKey', '=', '']])); + }); +}); diff --git a/modules/web/web/components/view/OSearchView.test.ts b/modules/web/web/components/view/OSearchView.test.ts new file mode 100644 index 000000000..cf7a83603 --- /dev/null +++ b/modules/web/web/components/view/OSearchView.test.ts @@ -0,0 +1,293 @@ +// @vitest-environment happy-dom +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { mount, flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; + +const { sfSearch, actorState } = vi.hoisted(() => ({ + sfSearch: vi.fn(async () => [] as any[]), + actorState: { id: 'me' as string }, +})); + +vi.mock('@/web/web/i18n', async () => { + const actual = await vi.importActual('@/web/web/i18n'); + return { + ...actual, + createTranslate: () => ({ _t: (msg: string) => msg, _lt: (msg: string) => msg }), + }; +}); + +vi.mock('@/web/web/stores/registry', () => ({ + createStoreByModel: (model: string) => { + if (model === 'web.SavedFilter') return { Search: (...args: any[]) => sfSearch(...args) }; + return {}; + }, +})); + +vi.mock('@/web/web/composables/search/actorUserId', () => ({ + actorUserId: () => actorState.id, +})); + +import OSearchView from './OSearchView.vue'; + +const OSearchStub = defineComponent({ + name: 'OSearch', + props: ['store', 'placeholder', 'currentKeyword', 'currentAppliedFilters', 'currentAppliedGroups', 'defaultFilters'], + emits: ['query-update', 'defaults-ready'], + setup(props, { emit }) { + return () => + h('div', { class: 'o-search-stub' }, [ + h('pre', { class: 'defaults' }, JSON.stringify(props.defaultFilters || [])), + h( + 'button', + { + type: 'button', + class: 'emit-defaults-ready', + onClick: () => emit('defaults-ready', []), + }, + 'defaults-ready' + ), + ]); + }, +}); + +function makeStore(patch: Record = {}) { + return { + application: 'demo', + modelName: 'Widget', + state: { queryState: {} }, + ...patch, + } as any; +} + +describe('OSearchView server defaults', () => { + beforeEach(() => { + actorState.id = 'me'; + sfSearch.mockReset(); + sfSearch.mockResolvedValue([]); + }); + + it('prefers private IsDefault over shared and emits first query-update after load', async () => { + sfSearch.mockResolvedValue([ + { + Id: 's1', + Name: 'SharedDef', + Condition: { And: [['S', '=', 1]] }, + IsDefault: true, + UserId: null, + }, + { + Id: 'p1', + Name: 'PrivateDef', + Condition: { And: [['P', '=', 1]] }, + IsDefault: true, + UserId: 'me', + }, + ]); + const wrapper = mount(OSearchView as any, { + props: { + store: makeStore(), + defaultFilters: [{ name: 'Code', query: ['C', '=', 1], selected: true }], + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(wrapper.emitted('query-update')?.length).toBe(1); + const defaultsText = wrapper.find('.defaults').text(); + const defaults = JSON.parse(defaultsText); + expect(defaults[0]).toMatchObject({ name: 'PrivateDef', selected: true }); + expect(defaults.find((d: any) => d.name === 'Code')?.selected).toBe(false); + }); + + it('skips Search when application or modelName is missing', async () => { + const wrapper = mount(OSearchView as any, { + props: { store: makeStore({ application: '', modelName: 'Widget' }) }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(sfSearch).not.toHaveBeenCalled(); + expect(wrapper.emitted('query-update')?.length).toBe(1); + }); + + it('falls back to code defaults when Search throws', async () => { + sfSearch.mockRejectedValue(new Error('unavailable')); + const wrapper = mount(OSearchView as any, { + props: { + store: makeStore(), + defaultFilters: [{ name: 'Code', query: ['C', '=', 1], selected: true }], + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + const defaults = JSON.parse(wrapper.find('.defaults').text()); + expect(defaults[0]).toMatchObject({ name: 'Code', selected: true }); + }); + + it('reloads server defaults on defaults-ready and supports initialEmit=false', async () => { + sfSearch.mockResolvedValue([ + { Id: 's1', Name: 'SharedOnly', Condition: {}, IsDefault: true, UserId: '' }, + ]); + const wrapper = mount(OSearchView as any, { + props: { store: makeStore(), initialEmit: false }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(wrapper.emitted('query-update')).toBeUndefined(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('SharedOnly'); + + sfSearch.mockResolvedValue([ + { Id: 'p2', Name: 'LaterPrivate', Condition: {}, IsDefault: true, UserId: 'me' }, + ]); + await wrapper.find('.emit-defaults-ready').trigger('click'); + await flushPromises(); + await nextTick(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('LaterPrivate'); + }); + + it('accepts singleton defaultFilters and queryState.defaultFilters', async () => { + const wrapper = mount(OSearchView as any, { + props: { + store: makeStore({ application: '', modelName: 'Widget' }), + defaultFilters: { name: 'Solo', query: ['S', '=', 1], selected: true }, + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(JSON.parse(wrapper.find('.defaults').text())[0]).toMatchObject({ name: 'Solo', selected: true }); + + const qsWrapper = mount(OSearchView as any, { + props: { + store: makeStore({ + application: '', + modelName: 'Widget', + state: { queryState: { defaultFilters: [{ name: 'FromQs', query: ['Q', '=', 1], selected: true }] } }, + }), + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(JSON.parse(qsWrapper.find('.defaults').text())[0].name).toBe('FromQs'); + }); + + it('skips Search when modelName is missing', async () => { + const wrapper = mount(OSearchView as any, { + props: { store: makeStore({ application: 'demo', modelName: '' }) }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(sfSearch).not.toHaveBeenCalled(); + expect(wrapper.emitted('query-update')?.length).toBe(1); + }); + + it('requests shared-only defaults when actor is empty', async () => { + actorState.id = ''; + sfSearch.mockResolvedValue([{ Id: 's1', Name: 'Shared', Condition: {}, IsDefault: true, UserId: null }]); + mount(OSearchView as any, { + props: { store: makeStore() }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + const cond = sfSearch.mock.calls[0]![0] as any; + expect(cond.And).toEqual( + expect.arrayContaining([ + ['Application', '=', 'demo'], + ['ModelName', '=', 'Widget'], + ['ScopeKey', '=', ''], + ['Active', '=', true], + ['IsDefault', '=', true], + { Or: [['UserId', '=', null]] }, + ]) + ); + }); + + it('ignores stale server-default responses', async () => { + let resolveSlow!: (rows: any[]) => void; + const slow = new Promise(resolve => { + resolveSlow = resolve; + }); + sfSearch.mockImplementationOnce(() => slow); + sfSearch.mockResolvedValueOnce([ + { Id: 'p-fast', Name: 'FastPrivate', Condition: {}, IsDefault: true, UserId: 'me' }, + ]); + const wrapper = mount(OSearchView as any, { + props: { store: makeStore(), initialEmit: false }, + global: { stubs: { OSearch: OSearchStub } }, + }); + // First load is slow; trigger a second load that finishes first. + await wrapper.find('.emit-defaults-ready').trigger('click'); + await flushPromises(); + await nextTick(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('FastPrivate'); + + resolveSlow([{ Id: 'p-slow', Name: 'SlowPrivate', Condition: {}, IsDefault: true, UserId: 'me' }]); + await flushPromises(); + await nextTick(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('FastPrivate'); + }); + + it('treats null Search rows as empty server defaults', async () => { + sfSearch.mockResolvedValueOnce(null as any); + const wrapper = mount(OSearchView as any, { + props: { + store: makeStore(), + defaultFilters: [{ name: 'Code', query: ['C', '=', 1], selected: true }], + initialEmit: false, + }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(JSON.parse(wrapper.find('.defaults').text())[0]).toMatchObject({ name: 'Code', selected: true }); + }); + + it('ignores stale Search rejections in catch', async () => { + let rejectSlow!: (err: Error) => void; + const slow = new Promise((_resolve, reject) => { + rejectSlow = reject; + }); + sfSearch.mockImplementationOnce(() => slow); + sfSearch.mockResolvedValueOnce([ + { Id: 'p-fast', Name: 'KeepFast', Condition: {}, IsDefault: true, UserId: 'me' }, + ]); + const wrapper = mount(OSearchView as any, { + props: { store: makeStore(), initialEmit: false }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await wrapper.find('.emit-defaults-ready').trigger('click'); + await flushPromises(); + await nextTick(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('KeepFast'); + + rejectSlow(new Error('stale fail')); + await flushPromises(); + await nextTick(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('KeepFast'); + }); + + it('skips clearing defaults when a newer load supersedes empty app/model', async () => { + sfSearch.mockResolvedValueOnce([ + { Id: 'p1', Name: 'Keep', Condition: {}, IsDefault: true, UserId: 'me' }, + ]); + const store = makeStore(); + const wrapper = mount(OSearchView as any, { + props: { store, initialEmit: false }, + global: { stubs: { OSearch: OSearchStub } }, + }); + await flushPromises(); + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('Keep'); + + // Do not await the first click: VTU would wait past the yield and miss the race. + store.application = ''; + void wrapper.find('.emit-defaults-ready').trigger('click'); + store.application = 'demo'; + sfSearch.mockResolvedValueOnce([ + { Id: 'p2', Name: 'Newer', Condition: {}, IsDefault: true, UserId: 'me' }, + ]); + await wrapper.find('.emit-defaults-ready').trigger('click'); + await flushPromises(); + await nextTick(); + // Distinct newer row: fails if a stale empty-app clear wins after this load. + expect(JSON.parse(wrapper.find('.defaults').text())[0].name).toBe('Newer'); + }); +}); diff --git a/modules/web/web/components/view/OSearchView.vue b/modules/web/web/components/view/OSearchView.vue index b609cf6c1..fc6aeae46 100644 --- a/modules/web/web/components/view/OSearchView.vue +++ b/modules/web/web/components/view/OSearchView.vue @@ -10,21 +10,30 @@ SPDX-License-Identifier: Apache-2.0 :current-keyword="keywordForChild" :current-applied-filters="appliedFiltersForChild" :current-applied-groups="appliedGroupsForChild" - :default-filters="defaultFiltersForChild" + :default-filters="mergedDefaultFilters" @query-update="onQueryUpdate" + @defaults-ready="onDefaultsReady" />