From 4acdb963ec5026efb9deb2342471481846e5ad1e Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 11:28:00 +0800 Subject: [PATCH 1/4] chore(i18n): ImportPackaged facade, CRUD invalidate, sync design - Extend $choysum.i18n with invalidateModule and upsertPackagedTerms via ScopeProvider. - Invalidate TermStore after TranslationTerm CRUD and add ImportPackaged facade. - Refresh core i18n README and auth_forward comments for TranslationTerm topology. Co-authored-by: Cursor --- internal/defaultengine/init.go | 6 +- internal/i18n/bridge/terminology.go | 161 ++++++++++++++-- internal/i18n/bridge/terminology_test.go | 136 +++++++++++++ internal/i18n/gateway/auth_forward.go | 4 +- modules/core/i18n/README.md | 7 +- modules/core/service/i18n/translate.ts | 14 ++ .../orm/model/_translation_term_cache.ts | 70 +++++++ .../orm/model/translation_term_base_model.ts | 163 +++++++++++++++- .../orm/model/translation_term_cache.test.ts | 180 ++++++++++++++++++ modules/core/types/$choysum.d.ts | 21 ++ 10 files changed, 736 insertions(+), 26 deletions(-) create mode 100644 modules/core/service/orm/model/_translation_term_cache.ts create mode 100644 modules/core/service/orm/model/translation_term_cache.test.ts diff --git a/internal/defaultengine/init.go b/internal/defaultengine/init.go index 467cfffd9..1d994fc28 100644 --- a/internal/defaultengine/init.go +++ b/internal/defaultengine/init.go @@ -6,7 +6,6 @@ package defaultengine import ( "github.com/choysum-dev/choysum/internal/defaultengine/quickjsruntime" i18nbridge "github.com/choysum-dev/choysum/internal/i18n/bridge" - i18nstore "github.com/choysum-dev/choysum/internal/i18n/store" "github.com/choysum-dev/choysum/pkg/auth" "github.com/choysum-dev/choysum/pkg/jsengine" "github.com/choysum-dev/choysum/pkg/jsengine/quickjsbridge" @@ -39,9 +38,8 @@ func defaultQuickjsReplaceableRuntimePlugins() []jsengine.RuntimePlugin { jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginFS, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption { return []jsengine.JsEngineOption{quickjsruntime.WithCompilerFs()} }), - jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginI18n, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption { - reg := i18nstore.RegistryFor(runtimeScope) - return []jsengine.JsEngineOption{i18nbridge.WithTerminology(reg)} + jsengine.NewRuntimePluginWithProvider(quickjsengine.RuntimePluginI18n, func(scopeProvider jsengine.ScopeProvider, authenticator auth.Authenticator) []jsengine.JsEngineOption { + return []jsengine.JsEngineOption{i18nbridge.WithTerminologyProvider(scopeProvider)} }), jsengine.NewRuntimePlugin(quickjsengine.RuntimePluginGRPC, func(runtimeScope scope.Scope, authenticator auth.Authenticator) []jsengine.JsEngineOption { return []jsengine.JsEngineOption{quickjsbridge.WithGrpc(runtimeScope)} diff --git a/internal/i18n/bridge/terminology.go b/internal/i18n/bridge/terminology.go index 69cc48f3a..6d8cb24f1 100644 --- a/internal/i18n/bridge/terminology.go +++ b/internal/i18n/bridge/terminology.go @@ -4,9 +4,12 @@ package bridge import ( + "context" + "fmt" "strings" "github.com/buke/quickjs-go" + i18nimport "github.com/choysum-dev/choysum/internal/i18n/import" "github.com/choysum-dev/choysum/internal/i18n/models" "github.com/choysum-dev/choysum/internal/i18n/store" "github.com/choysum-dev/choysum/pkg/jsengine" @@ -16,35 +19,70 @@ import ( // LookupFunc is the sync terminology lookup used by $choysum.i18n.t. type LookupFunc func(module, lang, scope, src, kind string) (value string, ok bool) -// WithTerminology registers sync $choysum.i18n.t(module, lang, scope, src[, kind]). -// Miss returns empty string; TS _t is responsible for falling back to src. +// WithTerminology registers sync $choysum.i18n.t against a fixed registry (lookup only). +// Prefer WithTerminologyProvider when invalidate/import are needed. func WithTerminology(reg *store.Registry) jsengine.JsEngineOption { + if reg == nil { + return WithTerminologyLookup(nil) + } return WithTerminologyLookup(reg.Lookup) } -// WithTerminologyLookup registers $choysum.i18n.t with a custom lookup (tests). +// WithTerminologyProvider registers $choysum.i18n.t, invalidateModule, and upsertPackagedTerms. +// The process-shared Registry is captured once at install (StaticScopeProvider wraps a new +// scope per ResolveScope call; re-calling RegistryFor would reset the cache). DB writes for +// upsert resolve the request scope via provider. // // Note: quickjs-go Value.Set consumes the property value (JS_SetProperty without // Dup). Do not Free() values after Set — that double-frees and crashes. -func WithTerminologyLookup(lookup LookupFunc) jsengine.JsEngineOption { +func WithTerminologyProvider(scopeProvider jsengine.ScopeProvider) jsengine.JsEngineOption { return func(jsEngine jsengine.JsEngine) error { jse := jsEngine.(*quickjsengine.QuickjsEngine) - globalsObj := jse.Ctx.Globals() - - choysumObj := globalsObj.Get("$choysum") - if !choysumObj.IsObject() { - // Get() returns an owned handle; free before replacing non-objects - // (undefined/null/primitives). Do not Free() Globals() itself. - choysumObj.Free() - choysumObj = jse.Ctx.Object() + base := jsengine.ResolveScope(scopeProvider, context.Background()) + var reg *store.Registry + if base != nil { + reg = store.RegistryFor(base) } + return installI18nObject(jse, scopeProvider, reg, nil) + } +} + +// WithTerminologyLookup registers $choysum.i18n.t with a custom lookup (tests). +func WithTerminologyLookup(lookup LookupFunc) jsengine.JsEngineOption { + return func(jsEngine jsengine.JsEngine) error { + jse := jsEngine.(*quickjsengine.QuickjsEngine) + return installI18nObject(jse, nil, nil, lookup) + } +} - i18nObj := jse.Ctx.Object() +func installI18nObject(jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, lookup LookupFunc) error { + globalsObj := jse.Ctx.Globals() + + choysumObj := globalsObj.Get("$choysum") + if !choysumObj.IsObject() { + // Get() returns an owned handle; free before replacing non-objects + // (undefined/null/primitives). Do not Free() Globals() itself. + choysumObj.Free() + choysumObj = jse.Ctx.Object() + } + + i18nObj := jse.Ctx.Object() + if lookup != nil { i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(lookup))) - choysumObj.Set("i18n", i18nObj) - globalsObj.Set("$choysum", choysumObj) - return nil + } else if reg != nil { + i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(reg.Lookup))) + } else { + i18nObj.Set("t", jse.Ctx.Function(terminologyLookupFunc(nil))) } + if reg != nil { + i18nObj.Set("invalidateModule", jse.Ctx.Function(invalidateModuleFunc(reg))) + } + if scopeProvider != nil && reg != nil { + i18nObj.Set("upsertPackagedTerms", jse.Ctx.NewFunction(upsertPackagedTermsAsyncFactory(jse, scopeProvider, reg))) + } + choysumObj.Set("i18n", i18nObj) + globalsObj.Set("$choysum", choysumObj) + return nil } func terminologyLookupFunc(lookup LookupFunc) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { @@ -69,3 +107,94 @@ func terminologyLookupFunc(lookup LookupFunc) func(ctx *quickjs.Context, this *q return ctx.String(val) } } + +func invalidateModuleFunc(reg *store.Registry) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { + return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { + if reg == nil || len(args) < 2 { + return ctx.Bool(false) + } + application := strings.TrimSpace(args[0].String()) + module := strings.TrimSpace(args[1].String()) + if application == "" || application == "core" || module == "" { + return ctx.Bool(false) + } + reg.StoreFor(application).InvalidateModule(module) + return ctx.Bool(true) + } +} + +func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { + return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { + return ctx.NewPromise(func(resolve, reject func(*quickjs.Value)) { + ret := performUpsertPackagedTerms(ctx, jse, scopeProvider, reg, args) + if ret.IsError() { + defer ret.Free() + reject(ret) + return + } + defer ret.Free() + resolve(ret) + }) + } +} + +func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, args []*quickjs.Value) *quickjs.Value { + if len(args) < 4 { + return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms requires application, module, lang, poText")) + } + application := strings.TrimSpace(args[0].String()) + module := strings.TrimSpace(args[1].String()) + lang := strings.TrimSpace(args[2].String()) + poText, err := poTextBytes(args[3]) + if err != nil { + return ctx.ThrowError(err) + } + if application == "" || application == "core" || module == "" || lang == "" { + return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required")) + } + + execCtx := jse.ExecContext() + if execCtx == nil { + execCtx = context.Background() + } + rs := jsengine.ResolveScope(scopeProvider, execCtx) + if rs == nil || rs.Session() == nil { + return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms: missing runtime session")) + } + stats, err := i18nimport.UpsertPackagedTerms(rs, reg, application, module, lang, poText) + if err != nil { + return ctx.ThrowError(err) + } + if stats == nil { + stats = &i18nimport.ImportStats{Lang: lang} + } + payload := map[string]any{ + "upserted": stats.Upserted, + "skippedOverride": stats.SkippedOverride, + "rejectedNoCtxt": stats.RejectedNoCtxt, + "skippedObsolete": stats.SkippedObsolete, + "purgedRetired": stats.PurgedRetired, + "lang": stats.Lang, + } + val, marshalErr := ctx.Marshal(payload) + if marshalErr != nil { + return ctx.ThrowError(marshalErr) + } + return val +} + +func poTextBytes(v *quickjs.Value) ([]byte, error) { + if v == nil || v.IsUndefined() || v.IsNull() { + return nil, fmt.Errorf("poText is required") + } + if v.IsString() { + return []byte(v.String()), nil + } + if v.IsUint8Array() || v.IsUint8ClampedArray() { + return v.ToUint8Array() + } + if v.IsByteArray() { + return v.ToByteArray(uint(v.ByteLen())) + } + return nil, fmt.Errorf("poText must be a string or Uint8Array") +} diff --git a/internal/i18n/bridge/terminology_test.go b/internal/i18n/bridge/terminology_test.go index d7271321e..4a78bf028 100644 --- a/internal/i18n/bridge/terminology_test.go +++ b/internal/i18n/bridge/terminology_test.go @@ -4,11 +4,22 @@ package bridge_test import ( + "context" + "fmt" + "io" + "log/slog" + "path/filepath" "testing" + "github.com/buke/quickjs-go" "github.com/choysum-dev/choysum/internal/i18n/bridge" + "github.com/choysum-dev/choysum/internal/i18n/store" + "github.com/choysum-dev/choysum/internal/testing/scopetest" "github.com/choysum-dev/choysum/pkg/jsengine" "github.com/choysum-dev/choysum/pkg/jsengine/quickjsengine" + "github.com/choysum-dev/choysum/pkg/scope" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) func TestWithTerminologyLookupSync(t *testing.T) { @@ -111,3 +122,128 @@ func TestWithTerminologyLookupExplicitKind(t *testing.T) { t.Fatalf("literal = %q, want 你好", lit.String()) } } + +func TestWithTerminologyProviderInvalidateAndUpsert(t *testing.T) { + store.ResetSharedRegistryForTests() + t.Cleanup(store.ResetSharedRegistryForTests) + + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "bridge.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + rs := &bridgeTestScope{ + ctx: context.Background(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + session: &scope.Session{DB: db}, + } + provider := jsengine.StaticScopeProvider(rs) + + engineIface, err := quickjsengine.NewFactory(bridge.WithTerminologyProvider(provider))() + if err != nil { + t.Fatalf("NewFactory: %v", err) + } + engine := engineIface.(*quickjsengine.QuickjsEngine) + t.Cleanup(func() { _ = engine.Close() }) + + noop := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('', 'auth')`) + defer noop.Free() + if noop.IsException() { + t.Fatalf("invalidate empty app: %v", engine.Ctx.Exception()) + } + if noop.ToBool() { + t.Fatal("expected invalidateModule('', ...) to return false") + } + + ok := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', 'auth')`) + defer ok.Free() + if ok.IsException() { + t.Fatalf("invalidate: %v", engine.Ctx.Exception()) + } + if !ok.ToBool() { + t.Fatal("expected invalidateModule to return true") + } + + promise := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'auth', 'zh_CN', ` + "`" + ` +msgctxt "web/a@new" +msgid "Hello" +msgstr "你好" +` + "`" + `)`) + defer promise.Free() + if promise.IsException() { + t.Fatalf("upsertPackagedTerms eval: %v", engine.Ctx.Exception()) + } + result, err := awaitPromise(engine, promise) + if err != nil { + t.Fatalf("upsertPackagedTerms: %v", err) + } + defer result.Free() + upserted := result.Get("upserted") + defer upserted.Free() + if int(upserted.ToInt64()) != 1 { + t.Fatalf("upserted=%v, want 1", upserted.ToInt64()) + } + + var count int64 + if err := rs.Session().Table("auth_translation_term").Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("db rows=%d, want 1", count) + } + + // The engine-captured registry (not RegistryFor(rs)) holds the warm cache. + hit := engine.Ctx.Eval(`$choysum.i18n.t('auth', 'zh_CN', 'web/a@new', 'Hello')`) + defer hit.Free() + if hit.IsException() { + t.Fatalf("lookup after upsert: %v", engine.Ctx.Exception()) + } + if hit.String() != "你好" { + t.Fatalf("t after upsert = %q, want 你好", hit.String()) + } + + bad := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'auth', '', 'x')`) + defer bad.Free() + if _, err := awaitPromise(engine, bad); err == nil { + t.Fatal("expected upsertPackagedTerms validation error") + } +} + +type bridgeTestScope struct { + ctx context.Context + logger *slog.Logger + session *scope.Session +} + +func (s *bridgeTestScope) Run(fn func(scope.Scope) error) error { return fn(s) } +func (s *bridgeTestScope) Transactor() scope.Transactor { + return scopetest.NewPassthroughTransactor(s) +} +func (s *bridgeTestScope) Session() *scope.Session { return s.session } +func (s *bridgeTestScope) WithContext(ctx context.Context) scope.Scope { + if ctx == nil { + ctx = s.ctx + } + return &bridgeTestScope{ctx: ctx, logger: s.logger, session: s.session} +} +func (s *bridgeTestScope) Context() context.Context { + if s.ctx != nil { + return s.ctx + } + return context.Background() +} +func (s *bridgeTestScope) Logger() *slog.Logger { return s.logger } + +func awaitPromise(engine *quickjsengine.QuickjsEngine, promise *quickjs.Value) (*quickjs.Value, error) { + if promise == nil { + return nil, fmt.Errorf("nil promise") + } + result := promise.Await() + if result == nil { + return nil, fmt.Errorf("await returned nil") + } + if result.IsException() { + defer result.Free() + return nil, engine.Ctx.Exception() + } + return result, nil +} diff --git a/internal/i18n/gateway/auth_forward.go b/internal/i18n/gateway/auth_forward.go index fef024c9c..8087b5a68 100644 --- a/internal/i18n/gateway/auth_forward.go +++ b/internal/i18n/gateway/auth_forward.go @@ -28,7 +28,7 @@ func accessTokenFromHTTP(ctx context.Context, authorizationHeader string) string // requireTermsAuth accepts either a trusted Identity in context or a Bearer token. // /web/ is HTTP-auth excluded, so IdentityFromContext is often empty and the -// Authorization header is the primary signal for PO export (and former terms routes). +// Authorization header is the primary signal for PO export. func requireTermsAuth(ctx context.Context, authorizationHeader string) (accessToken string, ok bool) { token := accessTokenFromHTTP(ctx, authorizationHeader) if id := auth.IdentityFromContext(ctx); id != nil && id.IsValid() { @@ -40,7 +40,7 @@ func requireTermsAuth(ctx context.Context, authorizationHeader string) (accessTo return token, true } -// outgoingContextForUserRPC forwards the caller's identity (D1) for SearchTerms/UpdateTerm. +// outgoingContextForUserRPC forwards the caller's identity (D1) for user-scoped RPCs. func outgoingContextForUserRPC(ctx context.Context, accessToken string) context.Context { md := metadata.MD{} if in, ok := metadata.FromIncomingContext(ctx); ok { diff --git a/modules/core/i18n/README.md b/modules/core/i18n/README.md index 2a2ff7d4e..215c04ab6 100644 --- a/modules/core/i18n/README.md +++ b/modules/core/i18n/README.md @@ -3,11 +3,12 @@ - `core.pot` / `zh_CN.po` — packaged terms for shared platform validation, authz denials, CRUD not-found messages, and web error fallbacks owned by core. - CI gate: `choysum i18n status core --lang zh_CN`. -- **Scheme A (runtime):** there is no `core.I18n` / `core_translation_term`. +- **Scheme A (runtime):** there is no `core.TranslationTerm` / `core_translation_term`. On install/upgrade of each real Application module, Go imports `modules/core/i18n/*.po` into that app's `{app}_translation_term` with `Module=core`. Upgrading `core` itself fans the same PO out to every host app. - Gateway asks each `{app}.I18n` for module `core` alongside the app's own modules. + Gateway dials each `{app}.TranslationTerm.GetTranslations` for module `core` + alongside the app's own modules. - Service code imports `_t` / `_lt` from `modules/core/service/i18n_binder.ts` (`createTranslate('core')`). Frontend fallbacks use `createTranslate('core', …)` from `@/web/web/i18n` (same `{ _t, _lt }` shape). @@ -17,5 +18,5 @@ - Only literal `_t` / `_lt` calls are extracted. Selection labels stay plain English until a request-scoped options API exists (do not use `_lt` for selection labels). - Keep `"application": "core"` in `package.json` as the D13 sentinel (skip DDL / - I18n registration). Do not remove or empty it without relaxing + TranslationTerm host registration). Do not remove or empty it without relaxing `ValidatePackageJSON` and updating all skip checks. diff --git a/modules/core/service/i18n/translate.ts b/modules/core/service/i18n/translate.ts index 3c089b1aa..0b7cea449 100644 --- a/modules/core/service/i18n/translate.ts +++ b/modules/core/service/i18n/translate.ts @@ -76,6 +76,20 @@ export function createTermReferenceKey( type ChoysumI18n = { t: (module: string, lang: string, scope: string, src: string, kind?: string) => string; + invalidateModule?: (application: string, module: string) => boolean; + upsertPackagedTerms?: ( + application: string, + module: string, + lang: string, + poText: string | Uint8Array + ) => Promise<{ + upserted: number; + skippedOverride: number; + rejectedNoCtxt: number; + skippedObsolete: number; + purgedRetired: number; + lang: string; + }>; }; function getBridge(): ChoysumI18n | undefined { diff --git a/modules/core/service/orm/model/_translation_term_cache.ts b/modules/core/service/orm/model/_translation_term_cache.ts new file mode 100644 index 000000000..77dbfb8e2 --- /dev/null +++ b/modules/core/service/orm/model/_translation_term_cache.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +export type ChoysumI18nBridge = { + t?: (module: string, lang: string, scope: string, src: string, kind?: string) => string; + invalidateModule?: (application: string, module: string) => boolean; + upsertPackagedTerms?: ( + application: string, + module: string, + lang: string, + poText: string | Uint8Array + ) => Promise<{ + upserted: number; + skippedOverride: number; + rejectedNoCtxt: number; + skippedObsolete: number; + purgedRetired: number; + lang: string; + }>; +}; + +export function getChoysumI18nBridge(): ChoysumI18nBridge | undefined { + const root = globalThis as { $choysum?: { i18n?: ChoysumI18nBridge } }; + return root.$choysum?.i18n; +} + +/** Invalidate Go TermStore for one module (no-op when bridge is absent). */ +export function invalidateTerminologyModule(application: string, module: string): void { + const app = String(application || '').trim(); + const mod = String(module || '').trim(); + if (!app || app === 'core' || !mod) return; + const bridge = getChoysumI18nBridge(); + if (!bridge || typeof bridge.invalidateModule !== 'function') return; + try { + bridge.invalidateModule(app, mod); + } catch { + /* best-effort: write already succeeded */ + } +} + +/** Invalidate each distinct module name for the host application. */ +export function invalidateTerminologyModules(application: string, modules: Iterable): void { + const seen = new Set(); + for (const raw of modules) { + const mod = String(raw || '').trim(); + if (!mod || seen.has(mod)) continue; + seen.add(mod); + invalidateTerminologyModule(application, mod); + } +} + +export function modulesFromRows(rows: unknown): string[] { + const list = Array.isArray(rows) ? rows : rows != null ? [rows] : []; + const out: string[] = []; + for (const row of list) { + const mod = String((row as any)?.Module ?? '').trim(); + if (mod) out.push(mod); + } + return out; +} + +export function modulesFromPayloads(values: unknown): string[] { + const list = Array.isArray(values) ? values : values != null ? [values] : []; + const out: string[] = []; + for (const row of list) { + const mod = String((row as any)?.Module ?? '').trim(); + if (mod) out.push(mod); + } + return out; +} diff --git a/modules/core/service/orm/model/translation_term_base_model.ts b/modules/core/service/orm/model/translation_term_base_model.ts index 967f46f85..1e402420a 100644 --- a/modules/core/service/orm/model/translation_term_base_model.ts +++ b/modules/core/service/orm/model/translation_term_base_model.ts @@ -6,6 +6,20 @@ import { MetadataStorage } from '../metadata/storage'; import { raiseDomainError } from '@/core/service/error'; import BaseModel from './model'; import type { InstantiableModelCtor } from './types'; +import type { + Insertable, + Updateable, + FieldSelection, + QueryCondition, + DeleteOptions, + UpdateOptions, +} from '../repository/types'; +import { + getChoysumI18nBridge, + invalidateTerminologyModules, + modulesFromPayloads, + modulesFromRows, +} from './_translation_term_cache'; /** Minimal surface for `pool('TranslationTerm')` typing. */ export type TranslationTermModelCtor = { @@ -26,6 +40,21 @@ export type GetTranslationsResp = { terms_by_module?: Record>>; }; +export type ImportPackagedReq = { + module: string; + lang: string; + poText: string | Uint8Array; +}; + +export type ImportPackagedResp = { + upserted: number; + skippedOverride: number; + rejectedNoCtxt: number; + skippedObsolete: number; + purgedRetired: number; + lang: string; +}; + const KIND_LITERAL = 'literal'; const SOURCE_PACKAGED = 'packaged'; @@ -289,7 +318,7 @@ export default class TranslationTermBaseModel extends BaseModel { /** * Gateway catalog read: language-wide term hash; `module_names` filters - * `terms_by_module` only (empty → `{}`, matching Go I18n GetTranslations). + * `terms_by_module` only (empty → `{}`, matching TranslationTerm GetTranslations). * Shape: terms_by_module module → scope → src → value (literal kind only). */ static async GetTranslations( @@ -369,4 +398,136 @@ export default class TranslationTermBaseModel extends BaseModel { terms_by_module: termsByModule, }; } + + /** + * Packaged PO upsert via Go shared helper (not the install default path). + */ + static async ImportPackaged( + this: InstantiableModelCtor, + req: ImportPackagedReq + ): Promise { + const application = String(storeMeta(this)?.application || '').trim(); + if (!application || application === 'core') { + fail('TRANSLATION_TERM_IMPORT_APP', 'ImportPackaged requires a non-core application host'); + } + const module = String(req?.module ?? '').trim(); + const lang = String(req?.lang ?? '').trim(); + if (!module || !lang) { + fail('TRANSLATION_TERM_IMPORT_ARGS', 'module and lang are required'); + } + if (req?.poText == null) { + fail('TRANSLATION_TERM_IMPORT_ARGS', 'poText is required'); + } + const bridge = getChoysumI18nBridge(); + if (!bridge || typeof bridge.upsertPackagedTerms !== 'function') { + fail('TRANSLATION_TERM_IMPORT_BRIDGE', '$choysum.i18n.upsertPackagedTerms is not available'); + } + return bridge.upsertPackagedTerms(application, module, lang, req.poText); + } + + static override async Create( + this: { new (...args: any[]): T } & typeof BaseModel, + value: Partial>, + returnFields?: FieldSelection + ): Promise { + const application = hostApplication(this); + const out = await super.Create(value as any, returnFields as any); + invalidateTerminologyModules(application, modulesFromRows(out)); + return out as unknown as T; + } + + static override async CreateMany( + this: { new (...args: any[]): T } & typeof BaseModel, + values: Partial>[], + returnFields?: FieldSelection + ): Promise { + const application = hostApplication(this); + const out = await super.CreateMany(values as any, returnFields as any); + invalidateTerminologyModules(application, [ + ...modulesFromPayloads(values), + ...modulesFromRows(out), + ]); + return out as unknown as T[]; + } + + static override async Update( + this: { new (...args: any[]): T } & typeof BaseModel, + condition: QueryCondition, + values: Partial>, + returnFields?: FieldSelection, + options?: UpdateOptions + ): Promise[]> { + const application = hostApplication(this); + const before = await (this as any).Search(condition as any, { + fields: ['Module'] as any, + limit: 0, + }); + const out = await super.Update(condition as any, values as any, returnFields as any, options as any); + invalidateTerminologyModules(application, [ + ...modulesFromPayloads(values), + ...modulesFromRows(before), + ...modulesFromRows(out), + ]); + return out as unknown as Partial[]; + } + + static override async UpdateById( + this: { new (...args: any[]): T } & typeof BaseModel, + id: string, + values: Partial>, + returnFields?: FieldSelection, + options?: UpdateOptions + ): Promise> { + const application = hostApplication(this); + let module = String((values as any)?.Module ?? '').trim(); + if (!module) { + try { + const existing = await (this as any).Browse(id, ['Module'] as any); + module = String(existing?.Module ?? '').trim(); + } catch { + /* Browse may fail if row gone; still attempt update */ + } + } + const out = await super.UpdateById(id as any, values as any, returnFields as any, options as any); + invalidateTerminologyModules(application, [module, ...modulesFromRows(out)]); + return out as unknown as Partial; + } + + static override async Delete( + this: { new (...args: any[]): T } & typeof BaseModel, + condition: QueryCondition, + options?: DeleteOptions + ): Promise { + const application = hostApplication(this); + const before = await (this as any).Search(condition as any, { + fields: ['Module'] as any, + limit: 0, + ...(options || {}), + }); + const count = await super.Delete(condition as any, options as any); + invalidateTerminologyModules(application, modulesFromRows(before)); + return count; + } + + static override async DeleteById( + this: { new (...args: any[]): T } & typeof BaseModel, + id: string, + options?: DeleteOptions + ): Promise { + const application = hostApplication(this); + let module = ''; + try { + const existing = await (this as any).Browse(id, ['Module'] as any, options as any); + module = String(existing?.Module ?? '').trim(); + } catch { + /* missing row */ + } + const count = await super.DeleteById(id as any, options as any); + invalidateTerminologyModules(application, [module]); + return count; + } +} + +function hostApplication(ctor: any): string { + return String(storeMeta(ctor as InstantiableModelCtor)?.application || '').trim(); } diff --git a/modules/core/service/orm/model/translation_term_cache.test.ts b/modules/core/service/orm/model/translation_term_cache.test.ts new file mode 100644 index 000000000..9978e926c --- /dev/null +++ b/modules/core/service/orm/model/translation_term_cache.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { Model } from '../decorator'; +import { ChoysumError } from '@/core/service/error'; +import TranslationTermBaseModel from './translation_term_base_model'; +import { + invalidateTerminologyModule, + invalidateTerminologyModules, + modulesFromPayloads, + modulesFromRows, +} from './_translation_term_cache'; + +@Model('TranslationTerm', { application: 'ttinv', softDelete: false }) +class TtInvTerm extends TranslationTermBaseModel {} + +@Model('TranslationTerm', { application: 'core', softDelete: false }) +class TtCoreInvTerm extends TranslationTermBaseModel {} + +test('modulesFrom helpers collect Module names', () => { + expect(modulesFromPayloads({ Module: 'auth' })).toEqual(['auth']); + expect(modulesFromRows([{ Module: 'a' }, { Module: 'a' }, { Module: 'b' }])).toEqual(['a', 'a', 'b']); + expect(modulesFromPayloads(null)).toEqual([]); +}); + +test('invalidateTerminologyModule is no-op without bridge', () => { + const root = globalThis as any; + const prev = root.$choysum; + delete root.$choysum; + expect(() => invalidateTerminologyModule('auth', 'auth')).not.toThrow(); + root.$choysum = prev; +}); + +test('invalidateTerminologyModules calls bridge once per distinct module', () => { + const root = globalThis as any; + const prev = root.$choysum; + const calls: Array<[string, string]> = []; + root.$choysum = { + i18n: { + invalidateModule: (app: string, mod: string) => { + calls.push([app, mod]); + return true; + }, + }, + }; + try { + invalidateTerminologyModules('auth', ['web', 'web', 'auth', '']); + expect(calls).toEqual([ + ['auth', 'web'], + ['auth', 'auth'], + ]); + } finally { + root.$choysum = prev; + } +}); + +async function expectRejects(promise: Promise, code: string) { + try { + await promise; + expect(false).toBe(true); + } catch (err) { + expect(err instanceof ChoysumError).toBe(true); + expect((err as ChoysumError).code).toBe(code); + } +} + +test('ImportPackaged validates host application and args', async () => { + await expectRejects( + TtCoreInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: 'x' }), + 'TRANSLATION_TERM_IMPORT_APP' + ); + + await expectRejects( + TtInvTerm.ImportPackaged({ module: '', lang: 'zh_CN', poText: 'x' } as any), + 'TRANSLATION_TERM_IMPORT_ARGS' + ); + + await expectRejects( + TtInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: null as any }), + 'TRANSLATION_TERM_IMPORT_ARGS' + ); + + const root = globalThis as any; + const prev = root.$choysum; + delete root.$choysum; + try { + await expectRejects( + TtInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: 'x' }), + 'TRANSLATION_TERM_IMPORT_BRIDGE' + ); + } finally { + root.$choysum = prev; + } +}); + +test('ImportPackaged forwards to $choysum.i18n.upsertPackagedTerms', async () => { + const root = globalThis as any; + const prev = root.$choysum; + root.$choysum = { + i18n: { + upsertPackagedTerms: async (app: string, module: string, lang: string, poText: string) => { + expect(app).toBe('ttinv'); + expect(module).toBe('auth'); + expect(lang).toBe('zh_CN'); + expect(poText).toContain('Hello'); + return { + upserted: 1, + skippedOverride: 0, + rejectedNoCtxt: 0, + skippedObsolete: 0, + purgedRetired: 0, + lang, + }; + }, + }, + }; + try { + const stats = await TtInvTerm.ImportPackaged({ + module: 'auth', + lang: 'zh_CN', + poText: 'msgctxt "a"\nmsgid "Hello"\nmsgstr "你好"\n', + }); + expect(stats.upserted).toBe(1); + } finally { + root.$choysum = prev; + } +}); + +test('Create invalidates Module from created row', async () => { + const root = globalThis as any; + const prev = root.$choysum; + const calls: Array<[string, string]> = []; + root.$choysum = { + i18n: { + invalidateModule: (app: string, mod: string) => { + calls.push([app, mod]); + return true; + }, + }, + }; + + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; + const originalCreate = BaseModel.Create; + BaseModel.Create = (async (_value: any) => ({ Module: 'web', Src: 'Hello' })) as any; + try { + await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.Create = originalCreate; + root.$choysum = prev; + } +}); + +test('UpdateById Browses Module when payload omits it then invalidates', async () => { + const root = globalThis as any; + const prev = root.$choysum; + const calls: Array<[string, string]> = []; + root.$choysum = { + i18n: { + invalidateModule: (app: string, mod: string) => { + calls.push([app, mod]); + return true; + }, + }, + }; + + const originalBrowse = TtInvTerm.Browse; + TtInvTerm.Browse = (async () => ({ Module: 'web' })) as any; + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; + const originalUpdateById = BaseModel.UpdateById; + BaseModel.UpdateById = (async () => ({ Id: '1', Value: '新' })) as any; + try { + await TtInvTerm.UpdateById('1', { Value: '新' } as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.UpdateById = originalUpdateById; + TtInvTerm.Browse = originalBrowse; + root.$choysum = prev; + } +}); diff --git a/modules/core/types/$choysum.d.ts b/modules/core/types/$choysum.d.ts index ae8182931..d3b85323b 100644 --- a/modules/core/types/$choysum.d.ts +++ b/modules/core/types/$choysum.d.ts @@ -242,4 +242,25 @@ declare var $choysum: { stream: (service: string, method: string, data: TRequest) => TResponse; registerProto: (path: string, content: string) => void; }; + + /** + * Terminology bridge (Go TermStore Lookup + packaged write helper). + */ + i18n: { + t: (module: string, lang: string, scope: string, src: string, kind?: string) => string; + invalidateModule: (application: string, module: string) => boolean; + upsertPackagedTerms: ( + application: string, + module: string, + lang: string, + poText: string | Uint8Array + ) => Promise<{ + upserted: number; + skippedOverride: number; + rejectedNoCtxt: number; + skippedObsolete: number; + purgedRetired: number; + lang: string; + }>; + }; }; From c406b78dc4fc4442625d55c1d0b9e46944288ae7 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 11:54:43 +0800 Subject: [PATCH 2/4] fix(i18n): address PR review on bridge invalidate and facade - Use ExistingStore for invalidateModule and NewError for upsert rejections. - Guard nullish bridge args; invalidate Create from payload when Module is projected away. - Align $choysum.i18n types and facade unit-test teardown/assertions with runtime. Co-authored-by: Cursor --- internal/i18n/bridge/terminology.go | 28 +++++++++--- internal/i18n/bridge/terminology_test.go | 36 ++++++++++++--- internal/i18n/store/registry.go | 12 +++++ .../orm/model/_translation_term_cache.ts | 17 ++++--- .../orm/model/translation_term_base_model.ts | 5 ++- .../orm/model/translation_term_cache.test.ts | 44 ++++++++++++++++--- modules/core/types/$choysum.d.ts | 6 ++- 7 files changed, 117 insertions(+), 31 deletions(-) diff --git a/internal/i18n/bridge/terminology.go b/internal/i18n/bridge/terminology.go index 6d8cb24f1..625299c40 100644 --- a/internal/i18n/bridge/terminology.go +++ b/internal/i18n/bridge/terminology.go @@ -113,12 +113,20 @@ func invalidateModuleFunc(reg *store.Registry) func(ctx *quickjs.Context, this * if reg == nil || len(args) < 2 { return ctx.Bool(false) } + if args[0] == nil || args[0].IsUndefined() || args[0].IsNull() || + args[1] == nil || args[1].IsUndefined() || args[1].IsNull() { + return ctx.Bool(false) + } application := strings.TrimSpace(args[0].String()) module := strings.TrimSpace(args[1].String()) if application == "" || application == "core" || module == "" { return ctx.Bool(false) } - reg.StoreFor(application).InvalidateModule(module) + ts, ok := reg.ExistingStore(application) + if !ok { + return ctx.Bool(false) + } + ts.InvalidateModule(module) return ctx.Bool(true) } } @@ -127,6 +135,7 @@ func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProv return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { return ctx.NewPromise(func(resolve, reject func(*quickjs.Value)) { ret := performUpsertPackagedTerms(ctx, jse, scopeProvider, reg, args) + // NewError values are IsError; never resolve ThrowError's JS_EXCEPTION sentinel. if ret.IsError() { defer ret.Free() reject(ret) @@ -140,17 +149,22 @@ func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProv func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, args []*quickjs.Value) *quickjs.Value { if len(args) < 4 { - return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms requires application, module, lang, poText")) + return ctx.NewError(fmt.Errorf("upsertPackagedTerms requires application, module, lang, poText")) + } + if args[0] == nil || args[0].IsUndefined() || args[0].IsNull() || + args[1] == nil || args[1].IsUndefined() || args[1].IsNull() || + args[2] == nil || args[2].IsUndefined() || args[2].IsNull() { + return ctx.NewError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required")) } application := strings.TrimSpace(args[0].String()) module := strings.TrimSpace(args[1].String()) lang := strings.TrimSpace(args[2].String()) poText, err := poTextBytes(args[3]) if err != nil { - return ctx.ThrowError(err) + return ctx.NewError(err) } if application == "" || application == "core" || module == "" || lang == "" { - return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required")) + return ctx.NewError(fmt.Errorf("upsertPackagedTerms: application, module, and lang are required")) } execCtx := jse.ExecContext() @@ -159,11 +173,11 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs } rs := jsengine.ResolveScope(scopeProvider, execCtx) if rs == nil || rs.Session() == nil { - return ctx.ThrowError(fmt.Errorf("upsertPackagedTerms: missing runtime session")) + return ctx.NewError(fmt.Errorf("upsertPackagedTerms: missing runtime session")) } stats, err := i18nimport.UpsertPackagedTerms(rs, reg, application, module, lang, poText) if err != nil { - return ctx.ThrowError(err) + return ctx.NewError(err) } if stats == nil { stats = &i18nimport.ImportStats{Lang: lang} @@ -178,7 +192,7 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs } val, marshalErr := ctx.Marshal(payload) if marshalErr != nil { - return ctx.ThrowError(marshalErr) + return ctx.NewError(marshalErr) } return val } diff --git a/internal/i18n/bridge/terminology_test.go b/internal/i18n/bridge/terminology_test.go index 4a78bf028..e33d20277 100644 --- a/internal/i18n/bridge/terminology_test.go +++ b/internal/i18n/bridge/terminology_test.go @@ -154,13 +154,22 @@ func TestWithTerminologyProviderInvalidateAndUpsert(t *testing.T) { t.Fatal("expected invalidateModule('', ...) to return false") } - ok := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', 'auth')`) - defer ok.Free() - if ok.IsException() { - t.Fatalf("invalidate: %v", engine.Ctx.Exception()) + // No store yet: must not create one as a side effect of invalidate. + cold := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', 'auth')`) + defer cold.Free() + if cold.IsException() { + t.Fatalf("invalidate cold: %v", engine.Ctx.Exception()) } - if !ok.ToBool() { - t.Fatal("expected invalidateModule to return true") + if cold.ToBool() { + t.Fatal("expected invalidateModule before store exists to return false") + } + nullish := engine.Ctx.Eval(`$choysum.i18n.invalidateModule(null, 'auth')`) + defer nullish.Free() + if nullish.IsException() { + t.Fatalf("invalidate nullish: %v", engine.Ctx.Exception()) + } + if nullish.ToBool() { + t.Fatal("expected invalidateModule(null, ...) to return false") } promise := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'auth', 'zh_CN', ` + "`" + ` @@ -201,11 +210,26 @@ msgstr "你好" t.Fatalf("t after upsert = %q, want 你好", hit.String()) } + warmInv := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', 'auth')`) + defer warmInv.Free() + if warmInv.IsException() { + t.Fatalf("invalidate warm: %v", engine.Ctx.Exception()) + } + if !warmInv.ToBool() { + t.Fatal("expected invalidateModule after upsert to return true") + } + bad := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'auth', '', 'x')`) defer bad.Free() if _, err := awaitPromise(engine, bad); err == nil { t.Fatal("expected upsertPackagedTerms validation error") } + + nullApp := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms(null, 'auth', 'zh_CN', 'x')`) + defer nullApp.Free() + if _, err := awaitPromise(engine, nullApp); err == nil { + t.Fatal("expected upsertPackagedTerms null application to reject") + } } type bridgeTestScope struct { diff --git a/internal/i18n/store/registry.go b/internal/i18n/store/registry.go index 7de958388..f471fdd61 100644 --- a/internal/i18n/store/registry.go +++ b/internal/i18n/store/registry.go @@ -45,6 +45,18 @@ func (r *Registry) StoreFor(application string) *TermStore { return s } +// ExistingStore returns the TermStore for an application without creating one. +func (r *Registry) ExistingStore(application string) (*TermStore, bool) { + application = strings.TrimSpace(application) + if application == "" { + return nil, false + } + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.stores[application] + return s, ok +} + // Lookup resolves the module's application, then looks up in that store's cache. // Framework module "core" is hosted in each real application's table (Scheme A); // Lookup probes host app stores until a hit (terms are identical across hosts). diff --git a/modules/core/service/orm/model/_translation_term_cache.ts b/modules/core/service/orm/model/_translation_term_cache.ts index 77dbfb8e2..16959b193 100644 --- a/modules/core/service/orm/model/_translation_term_cache.ts +++ b/modules/core/service/orm/model/_translation_term_cache.ts @@ -33,8 +33,13 @@ export function invalidateTerminologyModule(application: string, module: string) if (!bridge || typeof bridge.invalidateModule !== 'function') return; try { bridge.invalidateModule(app, mod); - } catch { - /* best-effort: write already succeeded */ + } catch (err) { + // best-effort: write already succeeded; cache may stay stale until next warm + try { + console.warn('invalidateTerminologyModule failed', app, mod, err); + } catch { + /* console may be unavailable in some test hosts */ + } } } @@ -60,11 +65,5 @@ export function modulesFromRows(rows: unknown): string[] { } export function modulesFromPayloads(values: unknown): string[] { - const list = Array.isArray(values) ? values : values != null ? [values] : []; - const out: string[] = []; - for (const row of list) { - const mod = String((row as any)?.Module ?? '').trim(); - if (mod) out.push(mod); - } - return out; + return modulesFromRows(values); } diff --git a/modules/core/service/orm/model/translation_term_base_model.ts b/modules/core/service/orm/model/translation_term_base_model.ts index 1e402420a..7979e24b7 100644 --- a/modules/core/service/orm/model/translation_term_base_model.ts +++ b/modules/core/service/orm/model/translation_term_base_model.ts @@ -432,7 +432,10 @@ export default class TranslationTermBaseModel extends BaseModel { ): Promise { const application = hostApplication(this); const out = await super.Create(value as any, returnFields as any); - invalidateTerminologyModules(application, modulesFromRows(out)); + invalidateTerminologyModules(application, [ + ...modulesFromPayloads(value), + ...modulesFromRows(out), + ]); return out as unknown as T; } diff --git a/modules/core/service/orm/model/translation_term_cache.test.ts b/modules/core/service/orm/model/translation_term_cache.test.ts index 9978e926c..136167286 100644 --- a/modules/core/service/orm/model/translation_term_cache.test.ts +++ b/modules/core/service/orm/model/translation_term_cache.test.ts @@ -26,9 +26,12 @@ test('modulesFrom helpers collect Module names', () => { test('invalidateTerminologyModule is no-op without bridge', () => { const root = globalThis as any; const prev = root.$choysum; - delete root.$choysum; - expect(() => invalidateTerminologyModule('auth', 'auth')).not.toThrow(); - root.$choysum = prev; + try { + delete root.$choysum; + expect(() => invalidateTerminologyModule('auth', 'auth')).not.toThrow(); + } finally { + root.$choysum = prev; + } }); test('invalidateTerminologyModules calls bridge once per distinct module', () => { @@ -55,13 +58,17 @@ test('invalidateTerminologyModules calls bridge once per distinct module', () => }); async function expectRejects(promise: Promise, code: string) { + let rejected: unknown; + let settled = false; try { await promise; - expect(false).toBe(true); + settled = true; } catch (err) { - expect(err instanceof ChoysumError).toBe(true); - expect((err as ChoysumError).code).toBe(code); + rejected = err; } + expect(settled).toBe(false); + expect(rejected instanceof ChoysumError).toBe(true); + expect((rejected as ChoysumError).code).toBe(code); } test('ImportPackaged validates host application and args', async () => { @@ -151,6 +158,31 @@ test('Create invalidates Module from created row', async () => { } }); +test('Create invalidates Module from payload when returnFields omit it', async () => { + const root = globalThis as any; + const prev = root.$choysum; + const calls: Array<[string, string]> = []; + root.$choysum = { + i18n: { + invalidateModule: (app: string, mod: string) => { + calls.push([app, mod]); + return true; + }, + }, + }; + + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; + const originalCreate = BaseModel.Create; + BaseModel.Create = (async (_value: any) => ({ Id: '1', Src: 'Hello' })) as any; + try { + await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any, ['Id', 'Src'] as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.Create = originalCreate; + root.$choysum = prev; + } +}); + test('UpdateById Browses Module when payload omits it then invalidates', async () => { const root = globalThis as any; const prev = root.$choysum; diff --git a/modules/core/types/$choysum.d.ts b/modules/core/types/$choysum.d.ts index d3b85323b..8fe668d93 100644 --- a/modules/core/types/$choysum.d.ts +++ b/modules/core/types/$choysum.d.ts @@ -248,8 +248,10 @@ declare var $choysum: { */ i18n: { t: (module: string, lang: string, scope: string, src: string, kind?: string) => string; - invalidateModule: (application: string, module: string) => boolean; - upsertPackagedTerms: ( + /** Present when the engine was installed with a terminology Registry. */ + invalidateModule?: (application: string, module: string) => boolean; + /** Present when the engine was installed with Registry + ScopeProvider. */ + upsertPackagedTerms?: ( application: string, module: string, lang: string, From 51fc52849cd3a17c81a2399665e03a85b6c71383 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 12:12:47 +0800 Subject: [PATCH 3/4] test(i18n): raise P6 facade/invalidate patch coverage to 100% - Cover bridge error branches, poText variants, ExistingStore, and CRUD invalidate paths. - Drop unreachable ExecContext nil guard; add package-level hooks for upsert/marshal failures. Co-authored-by: Cursor --- internal/i18n/bridge/terminology.go | 15 +- .../i18n/bridge/terminology_coverage_test.go | 320 ++++++++++++++ internal/i18n/bridge/terminology_test.go | 46 ++ internal/i18n/store/registry_coverage_test.go | 29 ++ .../orm/model/translation_term_cache.test.ts | 402 +++++++++++++++--- 5 files changed, 737 insertions(+), 75 deletions(-) create mode 100644 internal/i18n/bridge/terminology_coverage_test.go diff --git a/internal/i18n/bridge/terminology.go b/internal/i18n/bridge/terminology.go index 625299c40..6532526cd 100644 --- a/internal/i18n/bridge/terminology.go +++ b/internal/i18n/bridge/terminology.go @@ -19,6 +19,14 @@ import ( // LookupFunc is the sync terminology lookup used by $choysum.i18n.t. type LookupFunc func(module, lang, scope, src, kind string) (value string, ok bool) +// Test hooks (production defaults); overridden in package tests for error branches. +var ( + upsertPackagedTermsFn = i18nimport.UpsertPackagedTerms + marshalFn = func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { + return ctx.Marshal(v) + } +) + // WithTerminology registers sync $choysum.i18n.t against a fixed registry (lookup only). // Prefer WithTerminologyProvider when invalidate/import are needed. func WithTerminology(reg *store.Registry) jsengine.JsEngineOption { @@ -168,14 +176,11 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs } execCtx := jse.ExecContext() - if execCtx == nil { - execCtx = context.Background() - } rs := jsengine.ResolveScope(scopeProvider, execCtx) if rs == nil || rs.Session() == nil { return ctx.NewError(fmt.Errorf("upsertPackagedTerms: missing runtime session")) } - stats, err := i18nimport.UpsertPackagedTerms(rs, reg, application, module, lang, poText) + stats, err := upsertPackagedTermsFn(rs, reg, application, module, lang, poText) if err != nil { return ctx.NewError(err) } @@ -190,7 +195,7 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs "purgedRetired": stats.PurgedRetired, "lang": stats.Lang, } - val, marshalErr := ctx.Marshal(payload) + val, marshalErr := marshalFn(ctx, payload) if marshalErr != nil { return ctx.NewError(marshalErr) } diff --git a/internal/i18n/bridge/terminology_coverage_test.go b/internal/i18n/bridge/terminology_coverage_test.go new file mode 100644 index 000000000..f004fc526 --- /dev/null +++ b/internal/i18n/bridge/terminology_coverage_test.go @@ -0,0 +1,320 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: LGPL-3.0-or-later + +package bridge + +import ( + "context" + "fmt" + "io" + "log/slog" + "path/filepath" + "testing" + + "github.com/buke/quickjs-go" + i18nimport "github.com/choysum-dev/choysum/internal/i18n/import" + "github.com/choysum-dev/choysum/internal/i18n/store" + "github.com/choysum-dev/choysum/internal/testing/scopetest" + "github.com/choysum-dev/choysum/pkg/jsengine" + "github.com/choysum-dev/choysum/pkg/jsengine/quickjsengine" + "github.com/choysum-dev/choysum/pkg/scope" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type coverageScope struct { + ctx context.Context + logger *slog.Logger + session *scope.Session +} + +func (s *coverageScope) Run(fn func(scope.Scope) error) error { return fn(s) } +func (s *coverageScope) Transactor() scope.Transactor { + return scopetest.NewPassthroughTransactor(s) +} +func (s *coverageScope) Session() *scope.Session { return s.session } +func (s *coverageScope) WithContext(ctx context.Context) scope.Scope { + if ctx == nil { + ctx = s.ctx + } + return &coverageScope{ctx: ctx, logger: s.logger, session: s.session} +} +func (s *coverageScope) Context() context.Context { + if s.ctx != nil { + return s.ctx + } + return context.Background() +} +func (s *coverageScope) Logger() *slog.Logger { return s.logger } + +func newCoverageEngine(t *testing.T, opts ...jsengine.JsEngineOption) *quickjsengine.QuickjsEngine { + t.Helper() + engineIface, err := quickjsengine.NewFactory(opts...)() + if err != nil { + t.Fatalf("NewFactory: %v", err) + } + engine := engineIface.(*quickjsengine.QuickjsEngine) + t.Cleanup(func() { _ = engine.Close() }) + return engine +} + +func TestWithTerminologyNilAndNonNil(t *testing.T) { + store.ResetSharedRegistryForTests() + t.Cleanup(store.ResetSharedRegistryForTests) + + engine := newCoverageEngine(t, WithTerminology(nil)) + empty := engine.Ctx.Eval(`$choysum.i18n.t('a', 'zh_CN', 's', 'Hello')`) + defer empty.Free() + if empty.String() != "" { + t.Fatalf("nil terminology t = %q, want empty", empty.String()) + } + few := engine.Ctx.Eval(`$choysum.i18n.t('a')`) + defer few.Free() + if few.String() != "" { + t.Fatalf("short-args t = %q, want empty", few.String()) + } + + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "with-term.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + rs := &coverageScope{ + ctx: context.Background(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + session: &scope.Session{DB: db}, + } + reg := store.NewRegistry(rs) + reg.RememberModuleApplication("auth", "auth") + engine2 := newCoverageEngine(t, WithTerminology(reg)) + miss := engine2.Ctx.Eval(`$choysum.i18n.t('auth', 'zh_CN', 's', 'Hello')`) + defer miss.Free() + if miss.IsException() { + t.Fatalf("WithTerminology lookup: %v", engine2.Ctx.Exception()) + } +} + +func TestWithTerminologyProviderNilScope(t *testing.T) { + store.ResetSharedRegistryForTests() + t.Cleanup(store.ResetSharedRegistryForTests) + + engine := newCoverageEngine(t, WithTerminologyProvider(nil)) + hit := engine.Ctx.Eval(`typeof $choysum.i18n.t === 'function' && typeof $choysum.i18n.invalidateModule === 'undefined'`) + defer hit.Free() + if !hit.ToBool() { + t.Fatal("expected t present and invalidateModule absent when provider is nil") + } +} + +func TestInvalidateModuleFuncNilRegistry(t *testing.T) { + engine := newCoverageEngine(t) + fn := invalidateModuleFunc(nil) + ret := fn(engine.Ctx, nil, []*quickjs.Value{engine.Ctx.String("auth"), engine.Ctx.String("auth")}) + defer ret.Free() + if ret.ToBool() { + t.Fatal("expected false for nil registry") + } + short := fn(engine.Ctx, nil, []*quickjs.Value{engine.Ctx.String("auth")}) + defer short.Free() + if short.ToBool() { + t.Fatal("expected false for short args") + } +} + +func TestPoTextBytesVariants(t *testing.T) { + engine := newCoverageEngine(t) + ctx := engine.Ctx + + if _, err := poTextBytes(nil); err == nil { + t.Fatal("expected nil poText error") + } + undef := ctx.Undefined() + defer undef.Free() + if _, err := poTextBytes(undef); err == nil { + t.Fatal("expected undefined poText error") + } + null := ctx.Null() + defer null.Free() + if _, err := poTextBytes(null); err == nil { + t.Fatal("expected null poText error") + } + + str := ctx.String("hello") + defer str.Free() + got, err := poTextBytes(str) + if err != nil || string(got) != "hello" { + t.Fatalf("string poText = %q err=%v", got, err) + } + + u8 := ctx.NewUint8Array([]byte("abc")) + defer u8.Free() + got, err = poTextBytes(u8) + if err != nil || string(got) != "abc" { + t.Fatalf("Uint8Array poText = %q err=%v", got, err) + } + + clamped := ctx.Eval(`new Uint8ClampedArray([65, 66])`) + defer clamped.Free() + got, err = poTextBytes(clamped) + if err != nil || string(got) != "AB" { + t.Fatalf("Uint8ClampedArray poText = %q err=%v", got, err) + } + + buf := ctx.NewArrayBuffer([]byte("xy")) + defer buf.Free() + got, err = poTextBytes(buf) + if err != nil || string(got) != "xy" { + t.Fatalf("ArrayBuffer poText = %q err=%v", got, err) + } + + num := ctx.Int32(1) + defer num.Free() + if _, err := poTextBytes(num); err == nil { + t.Fatal("expected number poText error") + } +} + +func TestPerformUpsertPackagedTermsBranches(t *testing.T) { + store.ResetSharedRegistryForTests() + t.Cleanup(store.ResetSharedRegistryForTests) + + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "upsert-cov.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + rs := &coverageScope{ + ctx: context.Background(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + session: &scope.Session{DB: db}, + } + provider := jsengine.StaticScopeProvider(rs) + reg := store.RegistryFor(rs) + engine := newCoverageEngine(t, WithTerminologyProvider(provider)) + + short := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, nil) + defer short.Free() + if !short.IsError() { + t.Fatal("expected error for short args") + } + + badType := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.Int32(1), + }) + defer badType.Free() + if !badType.IsError() { + t.Fatal("expected error for non-string poText") + } + + coreApp := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("core"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer coreApp.Free() + if !coreApp.IsError() { + t.Fatal("expected error for core application") + } + + nilSessionProvider := jsengine.ScopeProvider(func(ctx context.Context) scope.Scope { + return &coverageScope{ctx: ctx, logger: rs.logger, session: nil} + }) + noSession := performUpsertPackagedTerms(engine.Ctx, engine, nilSessionProvider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer noSession.Free() + if !noSession.IsError() { + t.Fatal("expected error for missing session") + } + + nilProvider := performUpsertPackagedTerms(engine.Ctx, engine, nil, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer nilProvider.Free() + if !nilProvider.IsError() { + t.Fatal("expected error for nil scope provider") + } + + prevUpsert := upsertPackagedTermsFn + upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return nil, fmt.Errorf("forced upsert failure") + } + t.Cleanup(func() { upsertPackagedTermsFn = prevUpsert }) + failUpsert := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer failUpsert.Free() + if !failUpsert.IsError() { + t.Fatal("expected error from upsert failure") + } + + upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return nil, nil + } + nilStats := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer nilStats.Free() + if nilStats.IsError() { + t.Fatal("expected success when stats are nil") + } + lang := nilStats.Get("lang") + defer lang.Free() + if lang.String() != "zh_CN" { + t.Fatalf("lang=%q, want zh_CN", lang.String()) + } + + upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return &i18nimport.ImportStats{Lang: lang, Upserted: 2}, nil + } + prevMarshal := marshalFn + marshalFn = func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { + return nil, fmt.Errorf("forced marshal failure") + } + t.Cleanup(func() { marshalFn = prevMarshal }) + failMarshal := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer failMarshal.Free() + if !failMarshal.IsError() { + t.Fatal("expected error from marshal failure") + } + + nullModule := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.Null(), + engine.Ctx.String("zh_CN"), + engine.Ctx.String("x"), + }) + defer nullModule.Free() + if !nullModule.IsError() { + t.Fatal("expected error for null module") + } + undefLang := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.Undefined(), + engine.Ctx.String("x"), + }) + defer undefLang.Free() + if !undefLang.IsError() { + t.Fatal("expected error for undefined lang") + } +} diff --git a/internal/i18n/bridge/terminology_test.go b/internal/i18n/bridge/terminology_test.go index e33d20277..6e10133ab 100644 --- a/internal/i18n/bridge/terminology_test.go +++ b/internal/i18n/bridge/terminology_test.go @@ -230,6 +230,52 @@ msgstr "你好" if _, err := awaitPromise(engine, nullApp); err == nil { t.Fatal("expected upsertPackagedTerms null application to reject") } + + coreInv := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('core', 'auth')`) + defer coreInv.Free() + if coreInv.ToBool() { + t.Fatal("expected invalidateModule('core', ...) to return false") + } + emptyMod := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', '')`) + defer emptyMod.Free() + if emptyMod.ToBool() { + t.Fatal("expected invalidateModule(..., '') to return false") + } + undefMod := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth', undefined)`) + defer undefMod.Free() + if undefMod.ToBool() { + t.Fatal("expected invalidateModule(..., undefined) to return false") + } + shortInv := engine.Ctx.Eval(`$choysum.i18n.invalidateModule('auth')`) + defer shortInv.Free() + if shortInv.ToBool() { + t.Fatal("expected invalidateModule with one arg to return false") + } + + u8 := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'web', 'zh_CN', new TextEncoder().encode('msgctxt "s"\nmsgid "X"\nmsgstr "Y"\n'))`) + // TextEncoder may be unavailable — fall back to Uint8Array of a minimal PO. + if u8.IsException() { + u8.Free() + _ = engine.Ctx.Exception() + u8 = engine.Ctx.Eval(`(() => { + const s = 'msgctxt "s"\nmsgid "X"\nmsgstr "Y"\n'; + const a = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); + return $choysum.i18n.upsertPackagedTerms('auth', 'web', 'zh_CN', a); + })()`) + } + defer u8.Free() + u8Result, err := awaitPromise(engine, u8) + if err != nil { + t.Fatalf("upsertPackagedTerms Uint8Array: %v", err) + } + u8Result.Free() + + fewArgs := engine.Ctx.Eval(`$choysum.i18n.upsertPackagedTerms('auth', 'auth', 'zh_CN')`) + defer fewArgs.Free() + if _, err := awaitPromise(engine, fewArgs); err == nil { + t.Fatal("expected upsertPackagedTerms with missing poText to reject") + } } type bridgeTestScope struct { diff --git a/internal/i18n/store/registry_coverage_test.go b/internal/i18n/store/registry_coverage_test.go index 92b8791d8..f1773e17d 100644 --- a/internal/i18n/store/registry_coverage_test.go +++ b/internal/i18n/store/registry_coverage_test.go @@ -59,6 +59,35 @@ func TestRegistryLoadModuleApplicationWithoutMetaModuleTable(t *testing.T) { } } +func TestRegistryExistingStore(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "existing-store.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + rs := ®istryCoverageScope{ + ctx: context.Background(), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + session: &scope.Session{DB: db}, + } + reg := NewRegistry(rs) + + if _, ok := reg.ExistingStore(""); ok { + t.Fatal("ExistingStore('') should be false") + } + if _, ok := reg.ExistingStore("auth"); ok { + t.Fatal("ExistingStore before StoreFor should be false") + } + created := reg.StoreFor("auth") + got, ok := reg.ExistingStore("auth") + if !ok || got != created { + t.Fatalf("ExistingStore(auth) ok=%v store=%p want %p", ok, got, created) + } + got, ok = reg.ExistingStore(" auth ") + if !ok || got != created { + t.Fatalf("ExistingStore trimmed ok=%v", ok) + } +} + func TestRegistryListHostApplicationsWithoutMetaModuleTable(t *testing.T) { db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "registry-hosts-no-meta.db")), &gorm.Config{}) if err != nil { diff --git a/modules/core/service/orm/model/translation_term_cache.test.ts b/modules/core/service/orm/model/translation_term_cache.test.ts index 136167286..1ba33897d 100644 --- a/modules/core/service/orm/model/translation_term_cache.test.ts +++ b/modules/core/service/orm/model/translation_term_cache.test.ts @@ -3,8 +3,10 @@ import { Model } from '../decorator'; import { ChoysumError } from '@/core/service/error'; +import { MetadataStorage } from '../metadata/storage'; import TranslationTermBaseModel from './translation_term_base_model'; import { + getChoysumI18nBridge, invalidateTerminologyModule, invalidateTerminologyModules, modulesFromPayloads, @@ -21,6 +23,35 @@ test('modulesFrom helpers collect Module names', () => { expect(modulesFromPayloads({ Module: 'auth' })).toEqual(['auth']); expect(modulesFromRows([{ Module: 'a' }, { Module: 'a' }, { Module: 'b' }])).toEqual(['a', 'a', 'b']); expect(modulesFromPayloads(null)).toEqual([]); + expect(modulesFromRows(undefined)).toEqual([]); + expect(modulesFromRows({ Module: ' ' })).toEqual([]); +}); + +test('getChoysumI18nBridge reads global', () => { + const root = globalThis as any; + const prev = root.$choysum; + try { + delete root.$choysum; + expect(getChoysumI18nBridge()).toBeUndefined(); + root.$choysum = { i18n: { t: () => 'x' } }; + expect(getChoysumI18nBridge()?.t?.('a', 'b', 'c', 'd')).toBe('x'); + } finally { + root.$choysum = prev; + } +}); + +test('invalidateTerminologyModule early-returns for empty/core and missing bridge method', () => { + const root = globalThis as any; + const prev = root.$choysum; + try { + expect(() => invalidateTerminologyModule('', 'auth')).not.toThrow(); + expect(() => invalidateTerminologyModule('core', 'auth')).not.toThrow(); + expect(() => invalidateTerminologyModule('auth', '')).not.toThrow(); + root.$choysum = { i18n: {} }; + expect(() => invalidateTerminologyModule('auth', 'auth')).not.toThrow(); + } finally { + root.$choysum = prev; + } }); test('invalidateTerminologyModule is no-op without bridge', () => { @@ -34,6 +65,52 @@ test('invalidateTerminologyModule is no-op without bridge', () => { } }); +test('invalidateTerminologyModule swallows bridge throws and warns', () => { + const root = globalThis as any; + const prev = root.$choysum; + const warnings: any[] = []; + const prevWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args); + }; + root.$choysum = { + i18n: { + invalidateModule: () => { + throw new Error('boom'); + }, + }, + }; + try { + expect(() => invalidateTerminologyModule('auth', 'web')).not.toThrow(); + expect(warnings.length).toBe(1); + } finally { + console.warn = prevWarn; + root.$choysum = prev; + } +}); + +test('invalidateTerminologyModule tolerates console.warn throwing', () => { + const root = globalThis as any; + const prev = root.$choysum; + const prevWarn = console.warn; + console.warn = () => { + throw new Error('warn unavailable'); + }; + root.$choysum = { + i18n: { + invalidateModule: () => { + throw new Error('boom'); + }, + }, + }; + try { + expect(() => invalidateTerminologyModule('auth', 'web')).not.toThrow(); + } finally { + console.warn = prevWarn; + root.$choysum = prev; + } +}); + test('invalidateTerminologyModules calls bridge once per distinct module', () => { const root = globalThis as any; const prev = root.$choysum; @@ -77,10 +154,26 @@ test('ImportPackaged validates host application and args', async () => { 'TRANSLATION_TERM_IMPORT_APP' ); + const meta = MetadataStorage.instance.getModelMetadata(TtInvTerm as any) as any; + const prevApp = meta.application; + meta.application = ''; + try { + await expectRejects( + TtInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: 'x' }), + 'TRANSLATION_TERM_IMPORT_APP' + ); + } finally { + meta.application = prevApp; + } + await expectRejects( TtInvTerm.ImportPackaged({ module: '', lang: 'zh_CN', poText: 'x' } as any), 'TRANSLATION_TERM_IMPORT_ARGS' ); + await expectRejects( + TtInvTerm.ImportPackaged({ module: 'auth', lang: '', poText: 'x' } as any), + 'TRANSLATION_TERM_IMPORT_ARGS' + ); await expectRejects( TtInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: null as any }), @@ -98,6 +191,16 @@ test('ImportPackaged validates host application and args', async () => { } finally { root.$choysum = prev; } + + root.$choysum = { i18n: {} }; + try { + await expectRejects( + TtInvTerm.ImportPackaged({ module: 'auth', lang: 'zh_CN', poText: 'x' }), + 'TRANSLATION_TERM_IMPORT_BRIDGE' + ); + } finally { + root.$choysum = prev; + } }); test('ImportPackaged forwards to $choysum.i18n.upsertPackagedTerms', async () => { @@ -133,80 +236,239 @@ test('ImportPackaged forwards to $choysum.i18n.upsertPackagedTerms', async () => } }); -test('Create invalidates Module from created row', async () => { - const root = globalThis as any; - const prev = root.$choysum; - const calls: Array<[string, string]> = []; - root.$choysum = { - i18n: { - invalidateModule: (app: string, mod: string) => { - calls.push([app, mod]); - return true; +function withInvalidateSpy(run: (calls: Array<[string, string]>, restore: () => void) => Promise) { + return async () => { + const root = globalThis as any; + const prev = root.$choysum; + const calls: Array<[string, string]> = []; + root.$choysum = { + i18n: { + invalidateModule: (app: string, mod: string) => { + calls.push([app, mod]); + return true; + }, }, - }, + }; + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + try { + await run(calls, () => { + root.$choysum = prev; + }); + } finally { + root.$choysum = prev; + void BaseModel; + } }; +} - const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; - const originalCreate = BaseModel.Create; - BaseModel.Create = (async (_value: any) => ({ Module: 'web', Src: 'Hello' })) as any; - try { - await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any); - expect(calls).toEqual([['ttinv', 'web']]); - } finally { - BaseModel.Create = originalCreate; - root.$choysum = prev; - } -}); +test( + 'Create invalidates Module from created row', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalCreate = BaseModel.Create; + BaseModel.Create = (async (_value: any) => ({ Module: 'web', Src: 'Hello' })) as any; + try { + await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.Create = originalCreate; + restore(); + } + }) +); -test('Create invalidates Module from payload when returnFields omit it', async () => { - const root = globalThis as any; - const prev = root.$choysum; - const calls: Array<[string, string]> = []; - root.$choysum = { - i18n: { - invalidateModule: (app: string, mod: string) => { - calls.push([app, mod]); - return true; - }, - }, - }; +test( + 'Create invalidates Module from payload when returnFields omit it', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalCreate = BaseModel.Create; + BaseModel.Create = (async (_value: any) => ({ Id: '1', Src: 'Hello' })) as any; + try { + await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any, ['Id', 'Src'] as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.Create = originalCreate; + restore(); + } + }) +); - const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; - const originalCreate = BaseModel.Create; - BaseModel.Create = (async (_value: any) => ({ Id: '1', Src: 'Hello' })) as any; - try { - await TtInvTerm.Create({ Module: 'web', Src: 'Hello', Value: '你好' } as any, ['Id', 'Src'] as any); - expect(calls).toEqual([['ttinv', 'web']]); - } finally { - BaseModel.Create = originalCreate; - root.$choysum = prev; - } -}); +test( + 'CreateMany invalidates modules from payloads and rows', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const original = BaseModel.CreateMany; + BaseModel.CreateMany = (async () => [{ Module: 'a' }, { Id: '2' }]) as any; + try { + await TtInvTerm.CreateMany([{ Module: 'a' }, { Module: 'b' }] as any); + expect(calls).toEqual([ + ['ttinv', 'a'], + ['ttinv', 'b'], + ]); + } finally { + BaseModel.CreateMany = original; + restore(); + } + }) +); -test('UpdateById Browses Module when payload omits it then invalidates', async () => { - const root = globalThis as any; - const prev = root.$choysum; - const calls: Array<[string, string]> = []; - root.$choysum = { - i18n: { - invalidateModule: (app: string, mod: string) => { - calls.push([app, mod]); - return true; - }, - }, - }; +test( + 'Update invalidates modules from before/payload/out', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalSearch = TtInvTerm.Search; + const originalUpdate = BaseModel.Update; + TtInvTerm.Search = (async () => [{ Module: 'old' }]) as any; + BaseModel.Update = (async () => [{ Module: 'new' }]) as any; + try { + await TtInvTerm.Update({} as any, { Value: 'x' } as any); + expect(calls).toEqual([ + ['ttinv', 'old'], + ['ttinv', 'new'], + ]); + } finally { + TtInvTerm.Search = originalSearch; + BaseModel.Update = originalUpdate; + restore(); + } + }) +); - const originalBrowse = TtInvTerm.Browse; - TtInvTerm.Browse = (async () => ({ Module: 'web' })) as any; - const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype).constructor as typeof TranslationTermBaseModel; - const originalUpdateById = BaseModel.UpdateById; - BaseModel.UpdateById = (async () => ({ Id: '1', Value: '新' })) as any; - try { - await TtInvTerm.UpdateById('1', { Value: '新' } as any); - expect(calls).toEqual([['ttinv', 'web']]); - } finally { - BaseModel.UpdateById = originalUpdateById; - TtInvTerm.Browse = originalBrowse; - root.$choysum = prev; - } -}); +test( + 'UpdateById uses payload Module without Browse', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalBrowse = TtInvTerm.Browse; + const originalUpdateById = BaseModel.UpdateById; + let browsed = false; + TtInvTerm.Browse = (async () => { + browsed = true; + return { Module: 'ignored' }; + }) as any; + BaseModel.UpdateById = (async () => ({ Id: '1' })) as any; + try { + await TtInvTerm.UpdateById('1', { Module: 'direct', Value: '新' } as any); + expect(browsed).toBe(false); + expect(calls).toEqual([['ttinv', 'direct']]); + } finally { + TtInvTerm.Browse = originalBrowse; + BaseModel.UpdateById = originalUpdateById; + restore(); + } + }) +); + +test( + 'UpdateById Browses Module when payload omits it then invalidates', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalBrowse = TtInvTerm.Browse; + const originalUpdateById = BaseModel.UpdateById; + TtInvTerm.Browse = (async () => ({ Module: 'web' })) as any; + BaseModel.UpdateById = (async () => ({ Id: '1', Value: '新' })) as any; + try { + await TtInvTerm.UpdateById('1', { Value: '新' } as any); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + BaseModel.UpdateById = originalUpdateById; + TtInvTerm.Browse = originalBrowse; + restore(); + } + }) +); + +test( + 'UpdateById continues when Browse throws', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalBrowse = TtInvTerm.Browse; + const originalUpdateById = BaseModel.UpdateById; + TtInvTerm.Browse = (async () => { + throw new Error('gone'); + }) as any; + BaseModel.UpdateById = (async () => ({ Module: 'fromOut' })) as any; + try { + await TtInvTerm.UpdateById('1', { Value: '新' } as any); + expect(calls).toEqual([['ttinv', 'fromOut']]); + } finally { + TtInvTerm.Browse = originalBrowse; + BaseModel.UpdateById = originalUpdateById; + restore(); + } + }) +); + +test( + 'Delete invalidates modules from pre-search', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalSearch = TtInvTerm.Search; + const originalDelete = BaseModel.Delete; + TtInvTerm.Search = (async () => [{ Module: 'web' }, { Module: 'auth' }]) as any; + BaseModel.Delete = (async () => 2) as any; + try { + const n = await TtInvTerm.Delete({} as any, { hard: true } as any); + expect(n).toBe(2); + expect(calls).toEqual([ + ['ttinv', 'web'], + ['ttinv', 'auth'], + ]); + } finally { + TtInvTerm.Search = originalSearch; + BaseModel.Delete = originalDelete; + restore(); + } + }) +); + +test( + 'DeleteById invalidates Module from Browse', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalBrowse = TtInvTerm.Browse; + const originalDeleteById = BaseModel.DeleteById; + TtInvTerm.Browse = (async () => ({ Module: 'web' })) as any; + BaseModel.DeleteById = (async () => 1) as any; + try { + const n = await TtInvTerm.DeleteById('1'); + expect(n).toBe(1); + expect(calls).toEqual([['ttinv', 'web']]); + } finally { + TtInvTerm.Browse = originalBrowse; + BaseModel.DeleteById = originalDeleteById; + restore(); + } + }) +); + +test( + 'DeleteById continues when Browse throws', + withInvalidateSpy(async (calls, restore) => { + const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) + .constructor as typeof TranslationTermBaseModel; + const originalBrowse = TtInvTerm.Browse; + const originalDeleteById = BaseModel.DeleteById; + TtInvTerm.Browse = (async () => { + throw new Error('missing'); + }) as any; + BaseModel.DeleteById = (async () => 0) as any; + try { + await TtInvTerm.DeleteById('missing'); + expect(calls).toEqual([]); + } finally { + TtInvTerm.Browse = originalBrowse; + BaseModel.DeleteById = originalDeleteById; + restore(); + } + }) +); From d0c6731cbde06bfb1d479bb0eb67a85bf30afb21 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Fri, 7 Aug 2026 12:22:59 +0800 Subject: [PATCH 4/4] refactor(i18n): remove mutable bridge test hooks and tidy spy helper - Pass upsert/marshal deps into performUpsertPackagedTerms instead of package globals. - Drop no-op void BaseModel and redundant restore from withInvalidateSpy. Co-authored-by: Cursor --- internal/i18n/bridge/terminology.go | 34 +++++++---- .../i18n/bridge/terminology_coverage_test.go | 58 +++++++++++-------- .../orm/model/translation_term_cache.test.ts | 39 ++++--------- 3 files changed, 71 insertions(+), 60 deletions(-) diff --git a/internal/i18n/bridge/terminology.go b/internal/i18n/bridge/terminology.go index 6532526cd..503af38ff 100644 --- a/internal/i18n/bridge/terminology.go +++ b/internal/i18n/bridge/terminology.go @@ -14,18 +14,25 @@ import ( "github.com/choysum-dev/choysum/internal/i18n/store" "github.com/choysum-dev/choysum/pkg/jsengine" "github.com/choysum-dev/choysum/pkg/jsengine/quickjsengine" + "github.com/choysum-dev/choysum/pkg/scope" ) // LookupFunc is the sync terminology lookup used by $choysum.i18n.t. type LookupFunc func(module, lang, scope, src, kind string) (value string, ok bool) -// Test hooks (production defaults); overridden in package tests for error branches. -var ( - upsertPackagedTermsFn = i18nimport.UpsertPackagedTerms - marshalFn = func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { - return ctx.Marshal(v) +type upsertPackagedDeps struct { + upsert func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) + marshal func(ctx *quickjs.Context, v any) (*quickjs.Value, error) +} + +func defaultUpsertPackagedDeps() upsertPackagedDeps { + return upsertPackagedDeps{ + upsert: i18nimport.UpsertPackagedTerms, + marshal: func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { + return ctx.Marshal(v) + }, } -) +} // WithTerminology registers sync $choysum.i18n.t against a fixed registry (lookup only). // Prefer WithTerminologyProvider when invalidate/import are needed. @@ -140,9 +147,10 @@ func invalidateModuleFunc(reg *store.Registry) func(ctx *quickjs.Context, this * } func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry) func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { + deps := defaultUpsertPackagedDeps() return func(ctx *quickjs.Context, this *quickjs.Value, args []*quickjs.Value) *quickjs.Value { return ctx.NewPromise(func(resolve, reject func(*quickjs.Value)) { - ret := performUpsertPackagedTerms(ctx, jse, scopeProvider, reg, args) + ret := performUpsertPackagedTerms(ctx, jse, scopeProvider, reg, args, deps) // NewError values are IsError; never resolve ThrowError's JS_EXCEPTION sentinel. if ret.IsError() { defer ret.Free() @@ -155,7 +163,13 @@ func upsertPackagedTermsAsyncFactory(jse *quickjsengine.QuickjsEngine, scopeProv } } -func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, args []*quickjs.Value) *quickjs.Value { +func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.QuickjsEngine, scopeProvider jsengine.ScopeProvider, reg *store.Registry, args []*quickjs.Value, deps upsertPackagedDeps) *quickjs.Value { + if deps.upsert == nil { + deps.upsert = defaultUpsertPackagedDeps().upsert + } + if deps.marshal == nil { + deps.marshal = defaultUpsertPackagedDeps().marshal + } if len(args) < 4 { return ctx.NewError(fmt.Errorf("upsertPackagedTerms requires application, module, lang, poText")) } @@ -180,7 +194,7 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs if rs == nil || rs.Session() == nil { return ctx.NewError(fmt.Errorf("upsertPackagedTerms: missing runtime session")) } - stats, err := upsertPackagedTermsFn(rs, reg, application, module, lang, poText) + stats, err := deps.upsert(rs, reg, application, module, lang, poText) if err != nil { return ctx.NewError(err) } @@ -195,7 +209,7 @@ func performUpsertPackagedTerms(ctx *quickjs.Context, jse *quickjsengine.Quickjs "purgedRetired": stats.PurgedRetired, "lang": stats.Lang, } - val, marshalErr := marshalFn(ctx, payload) + val, marshalErr := deps.marshal(ctx, payload) if marshalErr != nil { return ctx.NewError(marshalErr) } diff --git a/internal/i18n/bridge/terminology_coverage_test.go b/internal/i18n/bridge/terminology_coverage_test.go index f004fc526..ff0bda42f 100644 --- a/internal/i18n/bridge/terminology_coverage_test.go +++ b/internal/i18n/bridge/terminology_coverage_test.go @@ -189,8 +189,9 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { provider := jsengine.StaticScopeProvider(rs) reg := store.RegistryFor(rs) engine := newCoverageEngine(t, WithTerminologyProvider(provider)) + deps := defaultUpsertPackagedDeps() - short := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, nil) + short := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, nil, deps) defer short.Free() if !short.IsError() { t.Fatal("expected error for short args") @@ -201,7 +202,7 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.Int32(1), - }) + }, deps) defer badType.Free() if !badType.IsError() { t.Fatal("expected error for non-string poText") @@ -212,7 +213,7 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), - }) + }, deps) defer coreApp.Free() if !coreApp.IsError() { t.Fatal("expected error for core application") @@ -226,7 +227,7 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), - }) + }, deps) defer noSession.Free() if !noSession.IsError() { t.Fatal("expected error for missing session") @@ -237,36 +238,36 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), - }) + }, deps) defer nilProvider.Free() if !nilProvider.IsError() { t.Fatal("expected error for nil scope provider") } - prevUpsert := upsertPackagedTermsFn - upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { - return nil, fmt.Errorf("forced upsert failure") - } - t.Cleanup(func() { upsertPackagedTermsFn = prevUpsert }) failUpsert := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ engine.Ctx.String("auth"), engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), + }, upsertPackagedDeps{ + upsert: func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return nil, fmt.Errorf("forced upsert failure") + }, }) defer failUpsert.Free() if !failUpsert.IsError() { t.Fatal("expected error from upsert failure") } - upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { - return nil, nil - } nilStats := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ engine.Ctx.String("auth"), engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), + }, upsertPackagedDeps{ + upsert: func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return nil, nil + }, }) defer nilStats.Free() if nilStats.IsError() { @@ -278,19 +279,18 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { t.Fatalf("lang=%q, want zh_CN", lang.String()) } - upsertPackagedTermsFn = func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { - return &i18nimport.ImportStats{Lang: lang, Upserted: 2}, nil - } - prevMarshal := marshalFn - marshalFn = func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { - return nil, fmt.Errorf("forced marshal failure") - } - t.Cleanup(func() { marshalFn = prevMarshal }) failMarshal := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ engine.Ctx.String("auth"), engine.Ctx.String("auth"), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), + }, upsertPackagedDeps{ + upsert: func(runtimeScope scope.Scope, reg *store.Registry, application, module, lang string, poText []byte) (*i18nimport.ImportStats, error) { + return &i18nimport.ImportStats{Lang: lang, Upserted: 2}, nil + }, + marshal: func(ctx *quickjs.Context, v any) (*quickjs.Value, error) { + return nil, fmt.Errorf("forced marshal failure") + }, }) defer failMarshal.Free() if !failMarshal.IsError() { @@ -302,7 +302,7 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.Null(), engine.Ctx.String("zh_CN"), engine.Ctx.String("x"), - }) + }, deps) defer nullModule.Free() if !nullModule.IsError() { t.Fatal("expected error for null module") @@ -312,9 +312,21 @@ func TestPerformUpsertPackagedTermsBranches(t *testing.T) { engine.Ctx.String("auth"), engine.Ctx.Undefined(), engine.Ctx.String("x"), - }) + }, deps) defer undefLang.Free() if !undefLang.IsError() { t.Fatal("expected error for undefined lang") } + + // Empty deps fills production defaults. + emptyDeps := performUpsertPackagedTerms(engine.Ctx, engine, provider, reg, []*quickjs.Value{ + engine.Ctx.String("auth"), + engine.Ctx.String("auth"), + engine.Ctx.String(""), + engine.Ctx.String("x"), + }, upsertPackagedDeps{}) + defer emptyDeps.Free() + if !emptyDeps.IsError() { + t.Fatal("expected validation error with empty deps defaults") + } } diff --git a/modules/core/service/orm/model/translation_term_cache.test.ts b/modules/core/service/orm/model/translation_term_cache.test.ts index 1ba33897d..0153ee2cd 100644 --- a/modules/core/service/orm/model/translation_term_cache.test.ts +++ b/modules/core/service/orm/model/translation_term_cache.test.ts @@ -236,7 +236,7 @@ test('ImportPackaged forwards to $choysum.i18n.upsertPackagedTerms', async () => } }); -function withInvalidateSpy(run: (calls: Array<[string, string]>, restore: () => void) => Promise) { +function withInvalidateSpy(run: (calls: Array<[string, string]>) => Promise) { return async () => { const root = globalThis as any; const prev = root.$choysum; @@ -249,22 +249,17 @@ function withInvalidateSpy(run: (calls: Array<[string, string]>, restore: () => }, }, }; - const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) - .constructor as typeof TranslationTermBaseModel; try { - await run(calls, () => { - root.$choysum = prev; - }); + await run(calls); } finally { root.$choysum = prev; - void BaseModel; } }; } test( 'Create invalidates Module from created row', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalCreate = BaseModel.Create; @@ -274,14 +269,13 @@ test( expect(calls).toEqual([['ttinv', 'web']]); } finally { BaseModel.Create = originalCreate; - restore(); } }) ); test( 'Create invalidates Module from payload when returnFields omit it', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalCreate = BaseModel.Create; @@ -291,14 +285,13 @@ test( expect(calls).toEqual([['ttinv', 'web']]); } finally { BaseModel.Create = originalCreate; - restore(); } }) ); test( 'CreateMany invalidates modules from payloads and rows', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const original = BaseModel.CreateMany; @@ -311,14 +304,13 @@ test( ]); } finally { BaseModel.CreateMany = original; - restore(); } }) ); test( 'Update invalidates modules from before/payload/out', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalSearch = TtInvTerm.Search; @@ -334,14 +326,13 @@ test( } finally { TtInvTerm.Search = originalSearch; BaseModel.Update = originalUpdate; - restore(); } }) ); test( 'UpdateById uses payload Module without Browse', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalBrowse = TtInvTerm.Browse; @@ -359,14 +350,13 @@ test( } finally { TtInvTerm.Browse = originalBrowse; BaseModel.UpdateById = originalUpdateById; - restore(); } }) ); test( 'UpdateById Browses Module when payload omits it then invalidates', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalBrowse = TtInvTerm.Browse; @@ -379,14 +369,13 @@ test( } finally { BaseModel.UpdateById = originalUpdateById; TtInvTerm.Browse = originalBrowse; - restore(); } }) ); test( 'UpdateById continues when Browse throws', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalBrowse = TtInvTerm.Browse; @@ -401,14 +390,13 @@ test( } finally { TtInvTerm.Browse = originalBrowse; BaseModel.UpdateById = originalUpdateById; - restore(); } }) ); test( 'Delete invalidates modules from pre-search', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalSearch = TtInvTerm.Search; @@ -425,14 +413,13 @@ test( } finally { TtInvTerm.Search = originalSearch; BaseModel.Delete = originalDelete; - restore(); } }) ); test( 'DeleteById invalidates Module from Browse', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalBrowse = TtInvTerm.Browse; @@ -446,14 +433,13 @@ test( } finally { TtInvTerm.Browse = originalBrowse; BaseModel.DeleteById = originalDeleteById; - restore(); } }) ); test( 'DeleteById continues when Browse throws', - withInvalidateSpy(async (calls, restore) => { + withInvalidateSpy(async calls => { const BaseModel = Object.getPrototypeOf(TranslationTermBaseModel.prototype) .constructor as typeof TranslationTermBaseModel; const originalBrowse = TtInvTerm.Browse; @@ -468,7 +454,6 @@ test( } finally { TtInvTerm.Browse = originalBrowse; BaseModel.DeleteById = originalDeleteById; - restore(); } }) );