@cosmicdrift/kumiko-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/package.json +91 -0
- package/src/__tests__/anonymous-access.integration.ts +325 -0
- package/src/__tests__/error-contract.integration.ts +435 -0
- package/src/__tests__/field-access.integration.ts +269 -0
- package/src/__tests__/full-stack.integration.ts +914 -0
- package/src/__tests__/ownership.integration.ts +449 -0
- package/src/__tests__/reference-data.integration.ts +198 -0
- package/src/__tests__/transition-guard.integration.ts +340 -0
- package/src/api/__tests__/api.test.ts +337 -0
- package/src/api/__tests__/auth-middleware-transport.test.ts +80 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +179 -0
- package/src/api/__tests__/batch.integration.ts +404 -0
- package/src/api/__tests__/body-limit.test.ts +88 -0
- package/src/api/__tests__/csrf-middleware.test.ts +97 -0
- package/src/api/__tests__/dispatcher-live.integration.ts +216 -0
- package/src/api/__tests__/metrics-endpoint.test.ts +126 -0
- package/src/api/__tests__/nested-write.integration.ts +213 -0
- package/src/api/__tests__/readiness.test.ts +76 -0
- package/src/api/__tests__/request-id-middleware.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +58 -0
- package/src/api/__tests__/sse-route.test.ts +112 -0
- package/src/api/anonymous-cookie.ts +60 -0
- package/src/api/api-constants.ts +64 -0
- package/src/api/auth-middleware.ts +418 -0
- package/src/api/auth-routes.ts +982 -0
- package/src/api/csrf-middleware.ts +77 -0
- package/src/api/index.ts +31 -0
- package/src/api/jwt.ts +66 -0
- package/src/api/observability-middleware.ts +89 -0
- package/src/api/readiness.ts +132 -0
- package/src/api/request-context.ts +49 -0
- package/src/api/request-id-middleware.ts +50 -0
- package/src/api/route-registrars.ts +195 -0
- package/src/api/routes.ts +135 -0
- package/src/api/server.ts +640 -0
- package/src/api/sse-broker.ts +71 -0
- package/src/api/sse-route.ts +62 -0
- package/src/api/tokens.ts +16 -0
- package/src/db/__tests__/apply-entity-event-tenant.integration.ts +159 -0
- package/src/db/__tests__/compound-types.test.ts +114 -0
- package/src/db/__tests__/connection-options.test.ts +68 -0
- package/src/db/__tests__/cursor.test.ts +41 -0
- package/src/db/__tests__/db-helpers.test.ts +369 -0
- package/src/db/__tests__/dialect-instant.test.ts +50 -0
- package/src/db/__tests__/drizzle-helpers.integration.ts +186 -0
- package/src/db/__tests__/drizzle-table-types.test.ts +162 -0
- package/src/db/__tests__/encryption.test.ts +39 -0
- package/src/db/__tests__/event-store-executor-list.integration.ts +313 -0
- package/src/db/__tests__/event-store-executor.integration.ts +235 -0
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +304 -0
- package/src/db/__tests__/located-timestamp.test.ts +184 -0
- package/src/db/__tests__/money.test.ts +199 -0
- package/src/db/__tests__/multi-row-insert.integration.ts +76 -0
- package/src/db/__tests__/parse-auto-verb.test.ts +70 -0
- package/src/db/__tests__/required-not-null-migration-safety.integration.ts +105 -0
- package/src/db/__tests__/row-helpers.test.ts +59 -0
- package/src/db/__tests__/schema-migration.integration.ts +273 -0
- package/src/db/__tests__/table-builder-indexes.test.ts +153 -0
- package/src/db/__tests__/table-builder-required.test.ts +216 -0
- package/src/db/__tests__/tenant-db.integration.ts +606 -0
- package/src/db/__tests__/unique-violation-mapping.integration.ts +166 -0
- package/src/db/apply-entity-event.ts +188 -0
- package/src/db/assert-exists-in.ts +59 -0
- package/src/db/compound-types.ts +47 -0
- package/src/db/connection.ts +104 -0
- package/src/db/cursor.ts +83 -0
- package/src/db/dialect.ts +109 -0
- package/src/db/eagerload.ts +174 -0
- package/src/db/encryption.ts +39 -0
- package/src/db/event-store-executor.ts +906 -0
- package/src/db/index.ts +55 -0
- package/src/db/located-timestamp.ts +114 -0
- package/src/db/money.ts +120 -0
- package/src/db/pg-error.ts +46 -0
- package/src/db/reference-data.ts +77 -0
- package/src/db/row-helpers.ts +53 -0
- package/src/db/schema-inspection.ts +25 -0
- package/src/db/table-builder.ts +475 -0
- package/src/db/tenant-db.ts +434 -0
- package/src/engine/__tests__/auth-claims-registrar.test.ts +74 -0
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +108 -0
- package/src/engine/__tests__/boot-validator.test.ts +1865 -0
- package/src/engine/__tests__/build-app-schema.test.ts +154 -0
- package/src/engine/__tests__/claim-keys.test.ts +274 -0
- package/src/engine/__tests__/config-helpers.test.ts +236 -0
- package/src/engine/__tests__/effective-features.test.ts +86 -0
- package/src/engine/__tests__/engine.test.ts +1461 -0
- package/src/engine/__tests__/entity-handlers.test.ts +274 -0
- package/src/engine/__tests__/event-helpers.test.ts +68 -0
- package/src/engine/__tests__/extends-registrar.test.ts +159 -0
- package/src/engine/__tests__/factories-long-text.test.ts +84 -0
- package/src/engine/__tests__/factories-time.test.ts +158 -0
- package/src/engine/__tests__/field-predicates.test.ts +48 -0
- package/src/engine/__tests__/hook-phases.test.ts +132 -0
- package/src/engine/__tests__/identifiers.test.ts +35 -0
- package/src/engine/__tests__/lifecycle-hooks.test.ts +237 -0
- package/src/engine/__tests__/nav.test.ts +267 -0
- package/src/engine/__tests__/ownership.test.ts +421 -0
- package/src/engine/__tests__/parse-ref-target.test.ts +43 -0
- package/src/engine/__tests__/projection-helpers.test.ts +62 -0
- package/src/engine/__tests__/projection.test.ts +191 -0
- package/src/engine/__tests__/qualified-name.test.ts +264 -0
- package/src/engine/__tests__/resolve-config-or-param.test.ts +315 -0
- package/src/engine/__tests__/run-in.test.ts +38 -0
- package/src/engine/__tests__/schema-builder.test.ts +380 -0
- package/src/engine/__tests__/screen.test.ts +408 -0
- package/src/engine/__tests__/state-machine.test.ts +148 -0
- package/src/engine/__tests__/system-user.test.ts +57 -0
- package/src/engine/__tests__/validation-hooks.test.ts +71 -0
- package/src/engine/access.ts +23 -0
- package/src/engine/boot-validator.ts +1528 -0
- package/src/engine/build-app-schema.ts +125 -0
- package/src/engine/config-helpers.ts +115 -0
- package/src/engine/constants.ts +85 -0
- package/src/engine/create-app.ts +98 -0
- package/src/engine/define-feature.ts +702 -0
- package/src/engine/define-handler.ts +78 -0
- package/src/engine/define-roles.ts +19 -0
- package/src/engine/effective-features.ts +87 -0
- package/src/engine/entity-handlers.ts +364 -0
- package/src/engine/event-helpers.ts +73 -0
- package/src/engine/factories.ts +328 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +416 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +197 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +128 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +888 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +360 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +469 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +287 -0
- package/src/engine/feature-ast/extractors.ts +2562 -0
- package/src/engine/feature-ast/index.ts +105 -0
- package/src/engine/feature-ast/parse.ts +369 -0
- package/src/engine/feature-ast/patch.ts +525 -0
- package/src/engine/feature-ast/patcher.ts +518 -0
- package/src/engine/feature-ast/patterns.ts +434 -0
- package/src/engine/feature-ast/render.ts +602 -0
- package/src/engine/feature-ast/source-location.ts +45 -0
- package/src/engine/field-access.ts +120 -0
- package/src/engine/index.ts +254 -0
- package/src/engine/ownership.ts +337 -0
- package/src/engine/parse-ref-target.ts +22 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +351 -0
- package/src/engine/pattern-library/index.ts +24 -0
- package/src/engine/pattern-library/library.ts +1117 -0
- package/src/engine/pattern-library/types.ts +255 -0
- package/src/engine/projection-helpers.ts +85 -0
- package/src/engine/qualified-name.ts +122 -0
- package/src/engine/read-claim.ts +31 -0
- package/src/engine/registry.ts +1325 -0
- package/src/engine/resolve-config-or-param.ts +153 -0
- package/src/engine/run-in.ts +29 -0
- package/src/engine/schema-builder.ts +175 -0
- package/src/engine/screen-filter-ops.ts +51 -0
- package/src/engine/state-machine.ts +70 -0
- package/src/engine/system-user.ts +32 -0
- package/src/engine/types/config.ts +306 -0
- package/src/engine/types/event-type-map.ts +37 -0
- package/src/engine/types/feature.ts +574 -0
- package/src/engine/types/fields.ts +422 -0
- package/src/engine/types/handlers.ts +742 -0
- package/src/engine/types/hooks.ts +142 -0
- package/src/engine/types/http-route.ts +54 -0
- package/src/engine/types/identifiers.ts +47 -0
- package/src/engine/types/index.ts +208 -0
- package/src/engine/types/nav.ts +46 -0
- package/src/engine/types/projection.ts +132 -0
- package/src/engine/types/relations.ts +51 -0
- package/src/engine/types/screen.ts +452 -0
- package/src/engine/types/workspace.ts +42 -0
- package/src/engine/validation.ts +33 -0
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.ts +173 -0
- package/src/entrypoint/__tests__/split-deploy.integration.ts +297 -0
- package/src/entrypoint/index.ts +442 -0
- package/src/errors/__tests__/classes.test.ts +371 -0
- package/src/errors/__tests__/write-failures.test.ts +109 -0
- package/src/errors/classes.ts +249 -0
- package/src/errors/i18n/de.yaml +83 -0
- package/src/errors/i18n/en.yaml +80 -0
- package/src/errors/index.ts +41 -0
- package/src/errors/kumiko-error.ts +67 -0
- package/src/errors/reasons.ts +36 -0
- package/src/errors/serialize.ts +136 -0
- package/src/errors/transition-details.ts +30 -0
- package/src/errors/write-error-info.ts +123 -0
- package/src/errors/zod-bridge.ts +49 -0
- package/src/event-store/__tests__/admin-api.integration.ts +361 -0
- package/src/event-store/__tests__/event-store.integration.ts +584 -0
- package/src/event-store/__tests__/get-stream-version-perf.integration.ts +83 -0
- package/src/event-store/__tests__/perf.integration.ts +255 -0
- package/src/event-store/__tests__/snapshot.integration.ts +267 -0
- package/src/event-store/__tests__/upcaster-dead-letter.integration.ts +204 -0
- package/src/event-store/__tests__/upcaster.integration.ts +460 -0
- package/src/event-store/admin-api.ts +257 -0
- package/src/event-store/archive.ts +106 -0
- package/src/event-store/errors.ts +35 -0
- package/src/event-store/event-store.ts +405 -0
- package/src/event-store/events-schema.ts +90 -0
- package/src/event-store/index.ts +50 -0
- package/src/event-store/snapshot.ts +210 -0
- package/src/event-store/upcaster-dead-letter.ts +119 -0
- package/src/event-store/upcaster.ts +147 -0
- package/src/files/__tests__/content-disposition.test.ts +123 -0
- package/src/files/__tests__/file-field-column.integration.ts +103 -0
- package/src/files/__tests__/file-field-pipeline.integration.ts +211 -0
- package/src/files/__tests__/file-handle.test.ts +122 -0
- package/src/files/__tests__/files.integration.ts +830 -0
- package/src/files/__tests__/storage-tracking.integration.ts +153 -0
- package/src/files/content-disposition.ts +55 -0
- package/src/files/file-handle.ts +63 -0
- package/src/files/file-ref-table.ts +22 -0
- package/src/files/file-routes.ts +353 -0
- package/src/files/in-memory-provider.ts +62 -0
- package/src/files/index.ts +29 -0
- package/src/files/local-provider.ts +35 -0
- package/src/files/storage-tracking.ts +60 -0
- package/src/files/types.ts +118 -0
- package/src/i18n/__tests__/i18n.test.ts +72 -0
- package/src/i18n/index.ts +29 -0
- package/src/jobs/__tests__/job-event-trigger.integration.ts +172 -0
- package/src/jobs/__tests__/job-multi-trigger.integration.ts +144 -0
- package/src/jobs/__tests__/jobs.integration.ts +566 -0
- package/src/jobs/index.ts +2 -0
- package/src/jobs/job-runner.ts +574 -0
- package/src/lifecycle/__tests__/create-test-lifecycle.ts +19 -0
- package/src/lifecycle/__tests__/lifecycle-server.integration.ts +108 -0
- package/src/lifecycle/__tests__/lifecycle.test.ts +212 -0
- package/src/lifecycle/__tests__/signal-handlers.test.ts +106 -0
- package/src/lifecycle/index.ts +13 -0
- package/src/lifecycle/lifecycle.ts +160 -0
- package/src/lifecycle/signal-handlers.ts +62 -0
- package/src/logging/__tests__/pino-trace-bridge.test.ts +50 -0
- package/src/logging/index.ts +3 -0
- package/src/logging/pino-logger.ts +64 -0
- package/src/logging/types.ts +7 -0
- package/src/migrations/__tests__/compare-snapshots.test.ts +150 -0
- package/src/migrations/__tests__/detect-drift.integration.ts +320 -0
- package/src/migrations/__tests__/detect-projections-to-rebuild.integration.ts +134 -0
- package/src/migrations/__tests__/rebuild-marker.test.ts +79 -0
- package/src/migrations/index.ts +28 -0
- package/src/migrations/projection-detection.ts +149 -0
- package/src/migrations/rebuild-marker.ts +64 -0
- package/src/migrations/schema-drift.ts +395 -0
- package/src/observability/__tests__/console-provider.test.ts +67 -0
- package/src/observability/__tests__/metric-validator.test.ts +87 -0
- package/src/observability/__tests__/noop-provider.test.ts +82 -0
- package/src/observability/__tests__/observability.integration.ts +559 -0
- package/src/observability/__tests__/prometheus-meter.test.ts +144 -0
- package/src/observability/__tests__/recording-meter.test.ts +101 -0
- package/src/observability/__tests__/recording-tracer.test.ts +110 -0
- package/src/observability/__tests__/sensitive-filter.test.ts +98 -0
- package/src/observability/console-provider.ts +130 -0
- package/src/observability/context.ts +26 -0
- package/src/observability/fallback.ts +34 -0
- package/src/observability/ids.ts +25 -0
- package/src/observability/index.ts +79 -0
- package/src/observability/metric-validator.ts +86 -0
- package/src/observability/metrics-handle.ts +56 -0
- package/src/observability/noop-provider.ts +146 -0
- package/src/observability/prometheus-meter.ts +284 -0
- package/src/observability/recording-meter.ts +156 -0
- package/src/observability/recording-tracer.ts +198 -0
- package/src/observability/redis-wrapper.ts +132 -0
- package/src/observability/sensitive-filter.ts +108 -0
- package/src/observability/standard-metrics.ts +213 -0
- package/src/observability/types/index.ts +29 -0
- package/src/observability/types/metric.ts +56 -0
- package/src/observability/types/provider.ts +32 -0
- package/src/observability/types/span.ts +64 -0
- package/src/pipeline/__tests__/archive-stream.integration.ts +220 -0
- package/src/pipeline/__tests__/auth-claims-resolver.test.ts +279 -0
- package/src/pipeline/__tests__/cascade-handler.integration.ts +419 -0
- package/src/pipeline/__tests__/cascade-handler.test.ts +52 -0
- package/src/pipeline/__tests__/causation-chain.integration.ts +206 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +234 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +379 -0
- package/src/pipeline/__tests__/distributed-lock.integration.ts +67 -0
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +323 -0
- package/src/pipeline/__tests__/event-dedup.integration.ts +153 -0
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +220 -0
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +423 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +123 -0
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +202 -0
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +290 -0
- package/src/pipeline/__tests__/event-dispatcher-strict.test.ts +65 -0
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +287 -0
- package/src/pipeline/__tests__/event-retention.integration.ts +239 -0
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +281 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +430 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +266 -0
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +149 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +228 -0
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +368 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +341 -0
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +147 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +551 -0
- package/src/pipeline/__tests__/query-projection.integration.ts +201 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.ts +306 -0
- package/src/pipeline/append-event-core.ts +117 -0
- package/src/pipeline/auth-claims-resolver.ts +103 -0
- package/src/pipeline/cascade-handler.ts +113 -0
- package/src/pipeline/dispatcher.ts +1585 -0
- package/src/pipeline/distributed-lock.ts +37 -0
- package/src/pipeline/entity-cache.ts +113 -0
- package/src/pipeline/event-consumer-state.ts +108 -0
- package/src/pipeline/event-dedup.ts +23 -0
- package/src/pipeline/event-dispatcher.ts +1016 -0
- package/src/pipeline/event-retention.ts +154 -0
- package/src/pipeline/idempotency.ts +76 -0
- package/src/pipeline/index.ts +66 -0
- package/src/pipeline/lifecycle-pipeline.ts +409 -0
- package/src/pipeline/msp-rebuild.ts +242 -0
- package/src/pipeline/multi-stream-apply-context.ts +115 -0
- package/src/pipeline/projection-rebuild.ts +334 -0
- package/src/pipeline/projection-state.ts +72 -0
- package/src/pipeline/projections-runner.ts +56 -0
- package/src/pipeline/redis-keys.ts +11 -0
- package/src/pipeline/system-hooks.ts +190 -0
- package/src/random/__tests__/generate.test.ts +149 -0
- package/src/random/generate.ts +141 -0
- package/src/random/index.ts +8 -0
- package/src/random/words.ts +392 -0
- package/src/rate-limit/__tests__/dispatcher-l3.integration.ts +111 -0
- package/src/rate-limit/__tests__/middleware.integration.ts +189 -0
- package/src/rate-limit/__tests__/resolver.integration.ts +189 -0
- package/src/rate-limit/bucket.ts +36 -0
- package/src/rate-limit/index.ts +14 -0
- package/src/rate-limit/middleware.ts +152 -0
- package/src/rate-limit/resolver.ts +267 -0
- package/src/redis/__tests__/redis-options.test.ts +54 -0
- package/src/redis/index.ts +74 -0
- package/src/search/__tests__/meilisearch-adapter.integration.ts +236 -0
- package/src/search/__tests__/search-adapter.test.ts +256 -0
- package/src/search/in-memory-adapter.ts +123 -0
- package/src/search/index.ts +12 -0
- package/src/search/meilisearch-adapter.ts +106 -0
- package/src/search/types.ts +39 -0
- package/src/secrets/__tests__/dek-cache.test.ts +213 -0
- package/src/secrets/__tests__/env-master-key-provider.test.ts +119 -0
- package/src/secrets/__tests__/envelope.test.ts +74 -0
- package/src/secrets/__tests__/leak-guard.test.ts +92 -0
- package/src/secrets/__tests__/rotation.test.ts +149 -0
- package/src/secrets/dek-cache.ts +116 -0
- package/src/secrets/env-master-key-provider.ts +162 -0
- package/src/secrets/envelope.ts +55 -0
- package/src/secrets/index.ts +19 -0
- package/src/secrets/leak-guard.ts +87 -0
- package/src/secrets/rotation.ts +34 -0
- package/src/secrets/types.ts +107 -0
- package/src/stack/db.ts +104 -0
- package/src/stack/event-collector.ts +23 -0
- package/src/stack/index.ts +32 -0
- package/src/stack/redis.ts +44 -0
- package/src/stack/request-helper.ts +168 -0
- package/src/stack/table-helpers.ts +104 -0
- package/src/stack/test-stack.ts +357 -0
- package/src/stack/test-users.ts +37 -0
- package/src/testing/__tests__/e2e-generator.test.ts +230 -0
- package/src/testing/__tests__/ensure-entity-table.integration.ts +54 -0
- package/src/testing/access-assertions.ts +15 -0
- package/src/testing/assertions.ts +35 -0
- package/src/testing/e2e-generator.ts +465 -0
- package/src/testing/expect-error.ts +25 -0
- package/src/testing/handler-context.ts +125 -0
- package/src/testing/http-cookies.ts +52 -0
- package/src/testing/index.ts +41 -0
- package/src/testing/late-bound.ts +39 -0
- package/src/testing/mutable-master-key-provider.ts +31 -0
- package/src/testing/observability-recorder.ts +54 -0
- package/src/testing/shared-entities.ts +49 -0
- package/src/testing/utils.ts +1 -0
- package/src/testing/wait-for.ts +31 -0
- package/src/time/__tests__/polyfill.test.ts +73 -0
- package/src/time/__tests__/tz-context.test.ts +121 -0
- package/src/time/index.ts +21 -0
- package/src/time/polyfill.ts +70 -0
- package/src/time/tz-context.ts +107 -0
- package/src/ui-types/app-schema.ts +57 -0
- package/src/ui-types/index.ts +65 -0
- package/src/utils/__tests__/assert.test.ts +17 -0
- package/src/utils/__tests__/env-parse.test.ts +54 -0
- package/src/utils/assert.ts +18 -0
- package/src/utils/env-parse.ts +16 -0
- package/src/utils/ids.ts +16 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/safe-json.ts +30 -0
- package/src/utils/serialization.ts +7 -0
|
@@ -0,0 +1,1461 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createTestUser } from "../../stack";
|
|
4
|
+
import { rolesOf } from "../../testing/access-assertions";
|
|
5
|
+
import { hasAccess } from "../access";
|
|
6
|
+
import { createSystemConfig, createTenantConfig, createUserConfig } from "../config-helpers";
|
|
7
|
+
import { defineQueryHandler, defineWriteHandler } from "../define-handler";
|
|
8
|
+
import {
|
|
9
|
+
createBooleanField,
|
|
10
|
+
createEmbeddedField,
|
|
11
|
+
createEntity,
|
|
12
|
+
createMoneyField,
|
|
13
|
+
createSelectField,
|
|
14
|
+
createTextField,
|
|
15
|
+
} from "../factories";
|
|
16
|
+
import { createApp, createRegistry, defineFeature } from "../index";
|
|
17
|
+
|
|
18
|
+
// --- defineFeature ---
|
|
19
|
+
|
|
20
|
+
describe("defineFeature", () => {
|
|
21
|
+
test("creates a feature with name", () => {
|
|
22
|
+
const feature = defineFeature("test", () => {});
|
|
23
|
+
expect(feature.name).toBe("test");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("collects entities", () => {
|
|
27
|
+
const feature = defineFeature("test", (r) => {
|
|
28
|
+
r.entity(
|
|
29
|
+
"user",
|
|
30
|
+
createEntity({
|
|
31
|
+
table: "Users",
|
|
32
|
+
fields: {
|
|
33
|
+
email: createTextField({ searchable: true }),
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(feature.entities["user"]).toBeDefined();
|
|
40
|
+
expect(feature.entities["user"]?.table).toBe("Users");
|
|
41
|
+
expect(feature.entities["user"]?.fields["email"]?.type).toBe("text");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("collects write handlers with inferred types", () => {
|
|
45
|
+
const schema = z.object({ email: z.email() });
|
|
46
|
+
|
|
47
|
+
const feature = defineFeature("test", (r) => {
|
|
48
|
+
r.writeHandler(
|
|
49
|
+
"user:invite",
|
|
50
|
+
schema,
|
|
51
|
+
async (event) => {
|
|
52
|
+
// event.payload.email is inferred as string
|
|
53
|
+
const _email: string = event.payload.email;
|
|
54
|
+
return { isSuccess: true, data: { id: 1 } };
|
|
55
|
+
},
|
|
56
|
+
{ access: { openToAll: true } },
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(feature.writeHandlers["user:invite"]).toBeDefined();
|
|
61
|
+
expect(feature.writeHandlers["user:invite"]?.name).toBe("user:invite");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("writeHandler returns typed HandlerRef", () => {
|
|
65
|
+
let ref: { name: string } | undefined;
|
|
66
|
+
defineFeature("test", (r) => {
|
|
67
|
+
ref = r.writeHandler(
|
|
68
|
+
"order:create",
|
|
69
|
+
z.object({}),
|
|
70
|
+
async () => ({
|
|
71
|
+
isSuccess: true,
|
|
72
|
+
data: null,
|
|
73
|
+
}),
|
|
74
|
+
{ access: { openToAll: true } },
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
expect(ref?.name).toBe("order:create");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("queryHandler returns typed HandlerRef", () => {
|
|
81
|
+
let ref: { name: string } | undefined;
|
|
82
|
+
defineFeature("test", (r) => {
|
|
83
|
+
ref = r.queryHandler("order:list", z.object({}), async () => [], {
|
|
84
|
+
access: { openToAll: true },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
expect(ref?.name).toBe("order:list");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("r.defineEvent returns typed EventDef and registers on feature", () => {
|
|
91
|
+
let eventRef: { name: string } | undefined;
|
|
92
|
+
const feature = defineFeature("orders", (r) => {
|
|
93
|
+
eventRef = r.defineEvent("order:created", z.object({ orderId: z.number() }));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// E.3: defineEvent returns the fully-qualified name so callers can
|
|
97
|
+
// pass it straight to ctx.appendEvent without building the qn manually.
|
|
98
|
+
expect(eventRef?.name).toBe("orders:event:order:created");
|
|
99
|
+
expect(feature.events["order:created"]).toBeDefined();
|
|
100
|
+
expect(feature.events["order:created"]?.schema).toBeDefined();
|
|
101
|
+
// The stored def carries the qualified name too (registry will confirm).
|
|
102
|
+
expect(feature.events["order:created"]?.name).toBe("orders:event:order:created");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("setup-callback return is exposed as feature.exports (cross-feature pull-down)", () => {
|
|
106
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
107
|
+
const config = r.config({
|
|
108
|
+
keys: {
|
|
109
|
+
defaultVat: createTenantConfig("number", { default: 19 }),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
return { config };
|
|
113
|
+
});
|
|
114
|
+
// Type-narrow access — `feature.exports.config.defaultVat` is typed
|
|
115
|
+
// through the defineFeature<TExports> generic; if the generic regressed
|
|
116
|
+
// to void, .exports would be `unknown` and these reads wouldn't compile.
|
|
117
|
+
expect(feature.exports.config.defaultVat.name).toBe("invoicing:config:default-vat");
|
|
118
|
+
expect(feature.exports.config.defaultVat.type).toBe("number");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("setup with no return leaves exports undefined", () => {
|
|
122
|
+
const feature = defineFeature("noop", () => {});
|
|
123
|
+
expect(feature.exports).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("registry prefixes event names with feature name", () => {
|
|
127
|
+
const feature = defineFeature("orders", (r) => {
|
|
128
|
+
r.defineEvent("order:created", z.object({ orderId: z.number() }));
|
|
129
|
+
});
|
|
130
|
+
const registry = createRegistry([feature]);
|
|
131
|
+
expect(registry.getEvent("orders:event:order:created")).toBeDefined();
|
|
132
|
+
expect(registry.getEvent("order:created")).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("collects write handlers via object form (defineWriteHandler)", () => {
|
|
136
|
+
const handler = defineWriteHandler({
|
|
137
|
+
name: "user:create",
|
|
138
|
+
schema: z.object({ email: z.email() }),
|
|
139
|
+
access: { roles: ["Admin"] },
|
|
140
|
+
handler: async (event) => {
|
|
141
|
+
return { isSuccess: true, data: { id: 1, email: event.payload.email } };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const feature = defineFeature("test", (r) => {
|
|
146
|
+
r.writeHandler(handler);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(feature.writeHandlers["user:create"]).toBeDefined();
|
|
150
|
+
expect(feature.writeHandlers["user:create"]?.name).toBe("user:create");
|
|
151
|
+
expect(rolesOf(feature.writeHandlers["user:create"]?.access)).toEqual(["Admin"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("collects query handlers with inferred types", () => {
|
|
155
|
+
const schema = z.object({ userId: z.number() });
|
|
156
|
+
|
|
157
|
+
const feature = defineFeature("test", (r) => {
|
|
158
|
+
r.queryHandler(
|
|
159
|
+
"user:detail",
|
|
160
|
+
schema,
|
|
161
|
+
async (query) => {
|
|
162
|
+
const _id: number = query.payload.userId;
|
|
163
|
+
return { id: _id, email: "test@test.de" };
|
|
164
|
+
},
|
|
165
|
+
{ access: { openToAll: true } },
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(feature.queryHandlers["user:detail"]).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("collects query handlers via object form (defineQueryHandler)", () => {
|
|
173
|
+
const handler = defineQueryHandler({
|
|
174
|
+
name: "user:list",
|
|
175
|
+
schema: z.object({ limit: z.number().optional() }),
|
|
176
|
+
handler: async () => {
|
|
177
|
+
return [{ id: 1, email: "test@test.de" }];
|
|
178
|
+
},
|
|
179
|
+
access: { openToAll: true },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const feature = defineFeature("test", (r) => {
|
|
183
|
+
r.queryHandler(handler);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(feature.queryHandlers["user:list"]).toBeDefined();
|
|
187
|
+
expect(feature.queryHandlers["user:list"]?.name).toBe("user:list");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("collects translations", () => {
|
|
191
|
+
const feature = defineFeature("test", (r) => {
|
|
192
|
+
r.translations({
|
|
193
|
+
keys: {
|
|
194
|
+
"nav.title": { de: "Benutzer", en: "Users" },
|
|
195
|
+
"field.email": { de: "E-Mail", en: "Email" },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(feature.translations["nav.title"]).toEqual({ de: "Benutzer", en: "Users" });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("handlers can have access rules", () => {
|
|
204
|
+
const feature = defineFeature("test", (r) => {
|
|
205
|
+
r.writeHandler(
|
|
206
|
+
"user:invite",
|
|
207
|
+
z.object({ email: z.string() }),
|
|
208
|
+
async () => ({ isSuccess: true, data: null }),
|
|
209
|
+
{ access: { roles: ["Admin", "SystemAdmin"] } },
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(rolesOf(feature.writeHandlers["user:invite"]?.access)).toEqual(["Admin", "SystemAdmin"]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// --- Field Factories ---
|
|
218
|
+
|
|
219
|
+
describe("field factories", () => {
|
|
220
|
+
test("createTextField has sensible defaults", () => {
|
|
221
|
+
const field = createTextField();
|
|
222
|
+
expect(field.type).toBe("text");
|
|
223
|
+
expect(field.maxLength).toBe(200);
|
|
224
|
+
expect(field.searchable).toBe(false);
|
|
225
|
+
expect(field.required).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("createTextField accepts overrides", () => {
|
|
229
|
+
const field = createTextField({ searchable: true, maxLength: 500, format: "email" });
|
|
230
|
+
expect(field.searchable).toBe(true);
|
|
231
|
+
expect(field.maxLength).toBe(500);
|
|
232
|
+
expect(field.format).toBe("email");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("createBooleanField has sensible defaults", () => {
|
|
236
|
+
const field = createBooleanField();
|
|
237
|
+
expect(field.type).toBe("boolean");
|
|
238
|
+
expect(field.default).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("createSelectField requires options", () => {
|
|
242
|
+
const field = createSelectField({ options: ["A", "B", "C"] as const });
|
|
243
|
+
expect(field.type).toBe("select");
|
|
244
|
+
expect(field.options).toEqual(["A", "B", "C"]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- createRegistry ---
|
|
249
|
+
|
|
250
|
+
describe("createRegistry", () => {
|
|
251
|
+
test("creates registry from features", () => {
|
|
252
|
+
const feature = defineFeature("admin", (r) => {
|
|
253
|
+
r.entity(
|
|
254
|
+
"user",
|
|
255
|
+
createEntity({
|
|
256
|
+
table: "Users",
|
|
257
|
+
fields: { email: createTextField({ searchable: true }) },
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const registry = createRegistry([feature]);
|
|
263
|
+
expect(registry.getFeature("admin")).toBeDefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("looks up entities across features", () => {
|
|
267
|
+
const f1 = defineFeature("admin", (r) => {
|
|
268
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
269
|
+
});
|
|
270
|
+
const f2 = defineFeature("blog", (r) => {
|
|
271
|
+
r.entity("post", createEntity({ table: "Posts", fields: {} }));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const registry = createRegistry([f1, f2]);
|
|
275
|
+
expect(registry.getEntity("user")?.table).toBe("Users");
|
|
276
|
+
expect(registry.getEntity("post")?.table).toBe("Posts");
|
|
277
|
+
expect(registry.getEntity("nonexistent")).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("looks up handlers across features", () => {
|
|
281
|
+
const f1 = defineFeature("admin", (r) => {
|
|
282
|
+
r.writeHandler("user:invite", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
283
|
+
access: { openToAll: true },
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
const f2 = defineFeature("profile", (r) => {
|
|
287
|
+
r.queryHandler("profile:me", z.object({}), async () => ({ id: 1 }), {
|
|
288
|
+
access: { openToAll: true },
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const registry = createRegistry([f1, f2]);
|
|
293
|
+
expect(registry.getWriteHandler("admin:write:user:invite")).toBeDefined();
|
|
294
|
+
expect(registry.getQueryHandler("profile:query:profile:me")).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("throws on duplicate entity names (same feature name)", () => {
|
|
298
|
+
const f1 = defineFeature("shared", (r) => {
|
|
299
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
300
|
+
});
|
|
301
|
+
const f2 = defineFeature("shared", (r) => {
|
|
302
|
+
r.entity("user", createEntity({ table: "Users2", fields: {} }));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Duplicate feature name throws first
|
|
306
|
+
expect(() => createRegistry([f1, f2])).toThrow(/duplicate feature.*shared/i);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("duplicate entity name across features throws", () => {
|
|
310
|
+
const f1 = defineFeature("a", (r) => {
|
|
311
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
312
|
+
});
|
|
313
|
+
const f2 = defineFeature("b", (r) => {
|
|
314
|
+
r.entity("user", createEntity({ table: "Users2", fields: {} }));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(() => createRegistry([f1, f2])).toThrow(/duplicate entity.*user/i);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("different features can have same handler short name (prefixed differently)", () => {
|
|
321
|
+
const f1 = defineFeature("a", (r) => {
|
|
322
|
+
r.writeHandler("user:invite", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
323
|
+
access: { openToAll: true },
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
const f2 = defineFeature("b", (r) => {
|
|
327
|
+
r.writeHandler("user:invite", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
328
|
+
access: { openToAll: true },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// No error — "a:write:user:invite" and "b:write:user:invite" are distinct
|
|
333
|
+
const registry = createRegistry([f1, f2]);
|
|
334
|
+
expect(registry.getWriteHandler("a:write:user:invite")).toBeDefined();
|
|
335
|
+
expect(registry.getWriteHandler("b:write:user:invite")).toBeDefined();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("throws on duplicate feature names", () => {
|
|
339
|
+
const f1 = defineFeature("admin", () => {});
|
|
340
|
+
const f2 = defineFeature("admin", () => {});
|
|
341
|
+
|
|
342
|
+
expect(() => createRegistry([f1, f2])).toThrow(/duplicate feature.*admin/i);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("merges translations with feature prefix (i18next namespace)", () => {
|
|
346
|
+
const f1 = defineFeature("admin", (r) => {
|
|
347
|
+
r.translations({ keys: { "nav.title": { de: "Admin", en: "Admin" } } });
|
|
348
|
+
});
|
|
349
|
+
const f2 = defineFeature("profile", (r) => {
|
|
350
|
+
r.translations({ keys: { "nav.title": { de: "Profil", en: "Profile" } } });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const registry = createRegistry([f1, f2]);
|
|
354
|
+
const all = registry.getAllTranslations();
|
|
355
|
+
// Keys prefixed with featureName: (colon = i18next namespace)
|
|
356
|
+
expect(all["admin:nav.title"]).toEqual({ de: "Admin", en: "Admin" });
|
|
357
|
+
expect(all["profile:nav.title"]).toEqual({ de: "Profil", en: "Profile" });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("throws when write handler is not entity-mapped in feature with field-access", () => {
|
|
361
|
+
const feature = defineFeature("hr", (r) => {
|
|
362
|
+
r.entity(
|
|
363
|
+
"employee",
|
|
364
|
+
createEntity({
|
|
365
|
+
table: "employees",
|
|
366
|
+
fields: {
|
|
367
|
+
name: createTextField(),
|
|
368
|
+
salary: createTextField({ access: { write: ["Admin"] } }),
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
// Handler name "promote" has no entity prefix → can't be mapped
|
|
373
|
+
r.writeHandler("promote", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
374
|
+
access: { openToAll: true },
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(() => createRegistry([feature])).toThrow(/hr:write:promote.*not mapped.*entity:action/i);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("allows unmapped write handlers when feature has no field-access rules", () => {
|
|
382
|
+
const feature = defineFeature("admin", (r) => {
|
|
383
|
+
r.entity("setting", createEntity({ table: "settings", fields: { key: createTextField() } }));
|
|
384
|
+
// No field-access rules on entity → "reset" without entity prefix is fine
|
|
385
|
+
r.writeHandler("reset", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
386
|
+
access: { openToAll: true },
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("entity-mapped handlers pass validation with field-access", () => {
|
|
394
|
+
const feature = defineFeature("hr", (r) => {
|
|
395
|
+
r.entity(
|
|
396
|
+
"employee",
|
|
397
|
+
createEntity({
|
|
398
|
+
table: "employees",
|
|
399
|
+
fields: {
|
|
400
|
+
name: createTextField(),
|
|
401
|
+
salary: createTextField({ access: { write: ["Admin"] } }),
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
// "employee:promote" follows convention → mapped to entity "employee"
|
|
406
|
+
r.writeHandler(
|
|
407
|
+
"employee:promote",
|
|
408
|
+
z.object({}),
|
|
409
|
+
async () => ({
|
|
410
|
+
isSuccess: true,
|
|
411
|
+
data: null,
|
|
412
|
+
}),
|
|
413
|
+
{ access: { openToAll: true } },
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("throws when dotted query handler references unknown entity (typo protection)", () => {
|
|
421
|
+
const feature = defineFeature("hr", (r) => {
|
|
422
|
+
r.entity(
|
|
423
|
+
"employee",
|
|
424
|
+
createEntity({
|
|
425
|
+
table: "employees",
|
|
426
|
+
fields: {
|
|
427
|
+
salary: createTextField({ access: { read: ["Admin"] } }),
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
r.writeHandler(
|
|
432
|
+
"employee:create",
|
|
433
|
+
z.object({}),
|
|
434
|
+
async () => ({
|
|
435
|
+
isSuccess: true,
|
|
436
|
+
data: null,
|
|
437
|
+
}),
|
|
438
|
+
{ access: { openToAll: true } },
|
|
439
|
+
);
|
|
440
|
+
// Typo: "emp" instead of "employee"
|
|
441
|
+
r.queryHandler("emp:list", z.object({}), async () => [], { access: { openToAll: true } });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(() => createRegistry([feature])).toThrow(/emp:list.*entity-bound.*no matching entity/i);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("allows standalone query handlers without dot in features with field-access", () => {
|
|
448
|
+
const feature = defineFeature("hr", (r) => {
|
|
449
|
+
r.entity(
|
|
450
|
+
"employee",
|
|
451
|
+
createEntity({
|
|
452
|
+
table: "employees",
|
|
453
|
+
fields: {
|
|
454
|
+
salary: createTextField({ access: { read: ["Admin"] } }),
|
|
455
|
+
},
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
r.writeHandler(
|
|
459
|
+
"employee:create",
|
|
460
|
+
z.object({}),
|
|
461
|
+
async () => ({
|
|
462
|
+
isSuccess: true,
|
|
463
|
+
data: null,
|
|
464
|
+
}),
|
|
465
|
+
{ access: { openToAll: true } },
|
|
466
|
+
);
|
|
467
|
+
// Standalone queries — no dot, intentionally not entity-bound
|
|
468
|
+
r.queryHandler("dashboard", z.object({}), async () => ({ total: 42 }), {
|
|
469
|
+
access: { openToAll: true },
|
|
470
|
+
});
|
|
471
|
+
r.queryHandler("orgChart", z.object({}), async () => [], { access: { openToAll: true } });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("returns searchable fields for entity", () => {
|
|
478
|
+
const feature = defineFeature("admin", (r) => {
|
|
479
|
+
r.entity(
|
|
480
|
+
"user",
|
|
481
|
+
createEntity({
|
|
482
|
+
table: "Users",
|
|
483
|
+
fields: {
|
|
484
|
+
email: createTextField({ searchable: true }),
|
|
485
|
+
firstName: createTextField(),
|
|
486
|
+
lastName: createTextField({ searchable: true }),
|
|
487
|
+
isEnabled: createBooleanField(),
|
|
488
|
+
},
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const registry = createRegistry([feature]);
|
|
494
|
+
expect(registry.getSearchableFields("user")).toEqual(["email", "lastName"]);
|
|
495
|
+
expect(registry.getSearchableFields("nonexistent")).toEqual([]);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// --- Access ---
|
|
500
|
+
|
|
501
|
+
describe("hasAccess", () => {
|
|
502
|
+
test.each([
|
|
503
|
+
{ userRoles: ["Admin"], requiredRoles: ["Admin"], expected: true },
|
|
504
|
+
{ userRoles: ["Admin"], requiredRoles: ["Admin", "SystemAdmin"], expected: true },
|
|
505
|
+
{ userRoles: ["Employee"], requiredRoles: ["Admin", "SystemAdmin"], expected: false },
|
|
506
|
+
{ userRoles: ["Admin", "Employee"], requiredRoles: ["Employee"], expected: true },
|
|
507
|
+
{ userRoles: [], requiredRoles: ["Admin"], expected: false },
|
|
508
|
+
// Empty required-roles list denies everyone under default-deny.
|
|
509
|
+
{ userRoles: ["Admin"], requiredRoles: [], expected: false },
|
|
510
|
+
])("user $userRoles vs required $requiredRoles → $expected", ({
|
|
511
|
+
userRoles,
|
|
512
|
+
requiredRoles,
|
|
513
|
+
expected,
|
|
514
|
+
}) => {
|
|
515
|
+
const user = createTestUser({ roles: userRoles });
|
|
516
|
+
expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("missing access rule denies access (default-deny)", () => {
|
|
520
|
+
const user = createTestUser({ roles: ["Employee"] });
|
|
521
|
+
expect(hasAccess(user, undefined)).toBe(false);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("openToAll grants access to any authenticated user", () => {
|
|
525
|
+
const user = createTestUser({ roles: ["Employee"] });
|
|
526
|
+
expect(hasAccess(user, { openToAll: true })).toBe(true);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("openToAll grants access even to user with no roles", () => {
|
|
530
|
+
const user = createTestUser({ roles: [] });
|
|
531
|
+
expect(hasAccess(user, { openToAll: true })).toBe(true);
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// --- Entity with softDelete ---
|
|
536
|
+
|
|
537
|
+
describe("entity options", () => {
|
|
538
|
+
test("softDelete defaults to false", () => {
|
|
539
|
+
const entity = createEntity({ table: "Users", fields: {} });
|
|
540
|
+
expect(entity.softDelete).toBe(false);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("softDelete can be enabled", () => {
|
|
544
|
+
const entity = createEntity({ table: "Users", fields: {}, softDelete: true });
|
|
545
|
+
expect(entity.softDelete).toBe(true);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// --- Sortable fields ---
|
|
550
|
+
|
|
551
|
+
describe("sortable fields", () => {
|
|
552
|
+
test("createTextField supports sortable property", () => {
|
|
553
|
+
const field = createTextField({ sortable: true });
|
|
554
|
+
expect(field.sortable).toBe(true);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("sortable defaults to false", () => {
|
|
558
|
+
const field = createTextField();
|
|
559
|
+
expect(field.sortable).toBe(false);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("registry returns sortable fields for entity", () => {
|
|
563
|
+
const feature = defineFeature("sortTest", (r) => {
|
|
564
|
+
r.entity(
|
|
565
|
+
"item",
|
|
566
|
+
createEntity({
|
|
567
|
+
table: "Items",
|
|
568
|
+
fields: {
|
|
569
|
+
name: createTextField({ sortable: true }),
|
|
570
|
+
email: createTextField(),
|
|
571
|
+
rank: createTextField({ sortable: true }),
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const registry = createRegistry([feature]);
|
|
578
|
+
expect(registry.getSortableFields("item")).toEqual(["name", "rank"]);
|
|
579
|
+
expect(registry.getSortableFields("nonexistent")).toEqual([]);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// --- createApp with role validation ---
|
|
584
|
+
|
|
585
|
+
describe("createApp", () => {
|
|
586
|
+
test("validates feature roles against app-defined roles", () => {
|
|
587
|
+
const feature = defineFeature("admin", (r) => {
|
|
588
|
+
r.writeHandler("admin.action", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
589
|
+
access: { roles: ["SuperAdmin"] },
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(() =>
|
|
594
|
+
createApp({
|
|
595
|
+
roles: ["Admin", "User"] as const,
|
|
596
|
+
features: [feature],
|
|
597
|
+
}),
|
|
598
|
+
).toThrow(/unknown role.*SuperAdmin/i);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("passes when all roles are valid", () => {
|
|
602
|
+
const feature = defineFeature("admin", (r) => {
|
|
603
|
+
r.writeHandler("admin.action", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
604
|
+
access: { roles: ["Admin"] },
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(() =>
|
|
609
|
+
createApp({
|
|
610
|
+
roles: ["Admin", "User"] as const,
|
|
611
|
+
features: [feature],
|
|
612
|
+
}),
|
|
613
|
+
).not.toThrow();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("createApp returns registry", () => {
|
|
617
|
+
const feature = defineFeature("test", () => {});
|
|
618
|
+
const app = createApp({ roles: ["Admin"] as const, features: [feature] });
|
|
619
|
+
expect(app.registry.getFeature("test")).toBeDefined();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("softDeleteDefault is true by default, configurable via softDelete", () => {
|
|
623
|
+
const feature = defineFeature("test", (r) => {
|
|
624
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const app1 = createApp({ roles: ["Admin"], features: [feature] });
|
|
628
|
+
expect(app1.softDeleteDefault).toBe(true);
|
|
629
|
+
|
|
630
|
+
const app2 = createApp({ roles: ["Admin"], features: [feature], softDelete: false });
|
|
631
|
+
expect(app2.softDeleteDefault).toBe(false);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("currencies includes defaults and custom additions", () => {
|
|
635
|
+
const feature = defineFeature("test", () => {});
|
|
636
|
+
const app = createApp({
|
|
637
|
+
roles: ["Admin"],
|
|
638
|
+
features: [feature],
|
|
639
|
+
currencies: ["BHD", "SAR"],
|
|
640
|
+
});
|
|
641
|
+
expect(app.currencies).toContain("EUR");
|
|
642
|
+
expect(app.currencies).toContain("BHD");
|
|
643
|
+
expect(app.currencies).toContain("SAR");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("rejects money field without defaultCurrency on entity", () => {
|
|
647
|
+
const feature = defineFeature("test", (r) => {
|
|
648
|
+
r.entity(
|
|
649
|
+
"invoice",
|
|
650
|
+
createEntity({
|
|
651
|
+
table: "Invoices",
|
|
652
|
+
fields: { total: createMoneyField({ required: true }) },
|
|
653
|
+
}),
|
|
654
|
+
);
|
|
655
|
+
});
|
|
656
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
657
|
+
"has money fields but no defaultCurrency",
|
|
658
|
+
);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("rejects unknown defaultCurrency", () => {
|
|
662
|
+
const feature = defineFeature("test", (r) => {
|
|
663
|
+
r.entity(
|
|
664
|
+
"invoice",
|
|
665
|
+
createEntity({
|
|
666
|
+
table: "Invoices",
|
|
667
|
+
fields: { total: createMoneyField() },
|
|
668
|
+
defaultCurrency: "FAKE",
|
|
669
|
+
}),
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
673
|
+
'defaultCurrency "FAKE" which is not in the currencies list',
|
|
674
|
+
);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("accepts money field with valid defaultCurrency", () => {
|
|
678
|
+
const feature = defineFeature("test", (r) => {
|
|
679
|
+
r.entity(
|
|
680
|
+
"invoice",
|
|
681
|
+
createEntity({
|
|
682
|
+
table: "Invoices",
|
|
683
|
+
fields: { total: createMoneyField({ required: true }) },
|
|
684
|
+
defaultCurrency: "EUR",
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("accepts custom currency when added to app config", () => {
|
|
692
|
+
const feature = defineFeature("test", (r) => {
|
|
693
|
+
r.entity(
|
|
694
|
+
"invoice",
|
|
695
|
+
createEntity({
|
|
696
|
+
table: "Invoices",
|
|
697
|
+
fields: { total: createMoneyField() },
|
|
698
|
+
defaultCurrency: "BHD",
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
});
|
|
702
|
+
expect(() =>
|
|
703
|
+
createApp({ roles: ["Admin"], features: [feature], currencies: ["BHD"] }),
|
|
704
|
+
).not.toThrow();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("rejects embedded field with empty schema", () => {
|
|
708
|
+
const feature = defineFeature("test", (r) => {
|
|
709
|
+
r.entity(
|
|
710
|
+
"doc",
|
|
711
|
+
createEntity({
|
|
712
|
+
table: "Docs",
|
|
713
|
+
fields: {
|
|
714
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
715
|
+
meta: createEmbeddedField({} as any),
|
|
716
|
+
},
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
});
|
|
720
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow("empty schema");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("rejects embedded sub-field with invalid type", () => {
|
|
724
|
+
const feature = defineFeature("test", (r) => {
|
|
725
|
+
r.entity(
|
|
726
|
+
"doc",
|
|
727
|
+
createEntity({
|
|
728
|
+
table: "Docs",
|
|
729
|
+
fields: {
|
|
730
|
+
address: createEmbeddedField({
|
|
731
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
732
|
+
street: { type: "embedded" as any },
|
|
733
|
+
}),
|
|
734
|
+
},
|
|
735
|
+
}),
|
|
736
|
+
);
|
|
737
|
+
});
|
|
738
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
739
|
+
'invalid type "embedded"',
|
|
740
|
+
);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("rejects embedded sub-field with unknown type", () => {
|
|
744
|
+
const feature = defineFeature("test", (r) => {
|
|
745
|
+
r.entity(
|
|
746
|
+
"doc",
|
|
747
|
+
createEntity({
|
|
748
|
+
table: "Docs",
|
|
749
|
+
fields: {
|
|
750
|
+
address: createEmbeddedField({
|
|
751
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
752
|
+
street: { type: "money" as any },
|
|
753
|
+
}),
|
|
754
|
+
},
|
|
755
|
+
}),
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
759
|
+
'invalid type "money"',
|
|
760
|
+
);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("accepts valid embedded field with all sub-field types", () => {
|
|
764
|
+
const feature = defineFeature("test", (r) => {
|
|
765
|
+
r.entity(
|
|
766
|
+
"doc",
|
|
767
|
+
createEntity({
|
|
768
|
+
table: "Docs",
|
|
769
|
+
fields: {
|
|
770
|
+
meta: createEmbeddedField({
|
|
771
|
+
label: { type: "text", required: true },
|
|
772
|
+
count: { type: "number" },
|
|
773
|
+
active: { type: "boolean" },
|
|
774
|
+
created: { type: "date" },
|
|
775
|
+
}),
|
|
776
|
+
},
|
|
777
|
+
}),
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("rejects transitions on non-select field", () => {
|
|
784
|
+
const feature = defineFeature("test", (r) => {
|
|
785
|
+
r.entity(
|
|
786
|
+
"doc",
|
|
787
|
+
createEntity({
|
|
788
|
+
table: "Docs",
|
|
789
|
+
fields: { title: createTextField() },
|
|
790
|
+
transitions: {
|
|
791
|
+
title: { a: ["b"] },
|
|
792
|
+
},
|
|
793
|
+
}),
|
|
794
|
+
);
|
|
795
|
+
});
|
|
796
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
797
|
+
'type is "text" (must be "select")',
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("rejects transitions with unknown state", () => {
|
|
802
|
+
const feature = defineFeature("test", (r) => {
|
|
803
|
+
r.entity(
|
|
804
|
+
"invoice",
|
|
805
|
+
createEntity({
|
|
806
|
+
table: "Invoices",
|
|
807
|
+
fields: {
|
|
808
|
+
status: createSelectField({ options: ["draft", "sent", "paid"] as const }),
|
|
809
|
+
},
|
|
810
|
+
transitions: {
|
|
811
|
+
status: {
|
|
812
|
+
draft: ["sent"],
|
|
813
|
+
sent: ["paid"],
|
|
814
|
+
paid: ["unknown_state"],
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow('"unknown_state"');
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("rejects transitions for unknown field", () => {
|
|
824
|
+
const feature = defineFeature("test", (r) => {
|
|
825
|
+
r.entity(
|
|
826
|
+
"doc",
|
|
827
|
+
createEntity({
|
|
828
|
+
table: "Docs",
|
|
829
|
+
fields: { title: createTextField() },
|
|
830
|
+
transitions: {
|
|
831
|
+
nonExistent: { a: ["b"] },
|
|
832
|
+
},
|
|
833
|
+
}),
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
837
|
+
'unknown field "nonExistent"',
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("accepts valid transitions matching select options", () => {
|
|
842
|
+
const feature = defineFeature("test", (r) => {
|
|
843
|
+
r.entity(
|
|
844
|
+
"invoice",
|
|
845
|
+
createEntity({
|
|
846
|
+
table: "Invoices",
|
|
847
|
+
fields: {
|
|
848
|
+
status: createSelectField({
|
|
849
|
+
options: ["draft", "sent", "paid", "cancelled"] as const,
|
|
850
|
+
}),
|
|
851
|
+
},
|
|
852
|
+
transitions: {
|
|
853
|
+
status: {
|
|
854
|
+
draft: ["sent"],
|
|
855
|
+
sent: ["paid", "cancelled"],
|
|
856
|
+
paid: [],
|
|
857
|
+
cancelled: [],
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
}),
|
|
861
|
+
);
|
|
862
|
+
});
|
|
863
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// --- r.requires() ---
|
|
868
|
+
|
|
869
|
+
describe("r.requires()", () => {
|
|
870
|
+
test("feature with satisfied dependency boots fine", () => {
|
|
871
|
+
const config = defineFeature("config", () => {});
|
|
872
|
+
const invoicing = defineFeature("invoicing", (r) => {
|
|
873
|
+
r.requires("config");
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
expect(() => createRegistry([config, invoicing])).not.toThrow();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
test("feature with missing dependency fails at boot", () => {
|
|
880
|
+
const invoicing = defineFeature("invoicing", (r) => {
|
|
881
|
+
r.requires("config");
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
expect(() => createRegistry([invoicing])).toThrow(
|
|
885
|
+
/feature "invoicing" requires feature "config" which is not registered/i,
|
|
886
|
+
);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("multiple requires all validated", () => {
|
|
890
|
+
const invoicing = defineFeature("invoicing", (r) => {
|
|
891
|
+
r.requires("config", "files");
|
|
892
|
+
});
|
|
893
|
+
const config = defineFeature("config", () => {});
|
|
894
|
+
|
|
895
|
+
expect(() => createRegistry([config, invoicing])).toThrow(
|
|
896
|
+
/feature "invoicing" requires feature "files" which is not registered/i,
|
|
897
|
+
);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("requires stores dependency names on feature", () => {
|
|
901
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
902
|
+
r.requires("config", "files");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(feature.requires).toEqual(["config", "files"]);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("optionalRequires stores optional dependency names", () => {
|
|
909
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
910
|
+
r.requires("config");
|
|
911
|
+
r.optionalRequires("tags", "customFields");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
expect(feature.requires).toEqual(["config"]);
|
|
915
|
+
expect(feature.optionalRequires).toEqual(["tags", "customFields"]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("missing optionalRequires does not throw in registry", () => {
|
|
919
|
+
const f1 = defineFeature("a", (r) => {
|
|
920
|
+
r.optionalRequires("nonexistent");
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// No error — optional dependency is not enforced
|
|
924
|
+
expect(() => createRegistry([f1])).not.toThrow();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("missing required feature still throws in registry", () => {
|
|
928
|
+
const f1 = defineFeature("a", (r) => {
|
|
929
|
+
r.requires("nonexistent");
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
expect(() => createRegistry([f1])).toThrow(/requires.*nonexistent/i);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// --- r.config() ---
|
|
937
|
+
|
|
938
|
+
describe("r.config()", () => {
|
|
939
|
+
test("registers config keys on feature", () => {
|
|
940
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
941
|
+
r.config({
|
|
942
|
+
keys: {
|
|
943
|
+
defaultVat: {
|
|
944
|
+
type: "number",
|
|
945
|
+
default: 19,
|
|
946
|
+
scope: "tenant",
|
|
947
|
+
access: { write: ["Admin"], read: ["all"] },
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
expect(feature.configKeys["defaultVat"]).toBeDefined();
|
|
954
|
+
expect(feature.configKeys["defaultVat"]?.type).toBe("number");
|
|
955
|
+
expect(feature.configKeys["defaultVat"]?.scope).toBe("tenant");
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("registry stores config keys with feature prefix", () => {
|
|
959
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
960
|
+
r.config({
|
|
961
|
+
keys: {
|
|
962
|
+
defaultVat: {
|
|
963
|
+
type: "number",
|
|
964
|
+
default: 19,
|
|
965
|
+
scope: "tenant",
|
|
966
|
+
access: { write: ["Admin"], read: ["all"] },
|
|
967
|
+
},
|
|
968
|
+
showNetPrices: {
|
|
969
|
+
type: "boolean",
|
|
970
|
+
default: true,
|
|
971
|
+
scope: "user",
|
|
972
|
+
access: { write: ["all"], read: ["all"] },
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const registry = createRegistry([feature]);
|
|
979
|
+
expect(registry.getConfigKey("invoicing:config:default-vat")).toBeDefined();
|
|
980
|
+
expect(registry.getConfigKey("invoicing:config:default-vat")?.type).toBe("number");
|
|
981
|
+
expect(registry.getConfigKey("invoicing:config:show-net-prices")?.scope).toBe("user");
|
|
982
|
+
expect(registry.getConfigKey("nonexistent:config:key")).toBeUndefined();
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("getAllConfigKeys returns all keys across features", () => {
|
|
986
|
+
const f1 = defineFeature("invoicing", (r) => {
|
|
987
|
+
r.config({
|
|
988
|
+
keys: {
|
|
989
|
+
vat: {
|
|
990
|
+
type: "number",
|
|
991
|
+
default: 19,
|
|
992
|
+
scope: "tenant",
|
|
993
|
+
access: { write: ["Admin"], read: ["all"] },
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
const f2 = defineFeature("notifications", (r) => {
|
|
999
|
+
r.config({
|
|
1000
|
+
keys: {
|
|
1001
|
+
push: {
|
|
1002
|
+
type: "boolean",
|
|
1003
|
+
default: true,
|
|
1004
|
+
scope: "user",
|
|
1005
|
+
access: { write: ["all"], read: ["all"] },
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const registry = createRegistry([f1, f2]);
|
|
1012
|
+
const all = registry.getAllConfigKeys();
|
|
1013
|
+
expect(all.size).toBe(2);
|
|
1014
|
+
expect(all.has("invoicing:config:vat")).toBe(true);
|
|
1015
|
+
expect(all.has("notifications:config:push")).toBe(true);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test("throws on duplicate config key across features", () => {
|
|
1019
|
+
const f1 = defineFeature("a", (r) => {
|
|
1020
|
+
r.config({
|
|
1021
|
+
keys: {
|
|
1022
|
+
key1: { type: "text", scope: "system", access: { write: ["Admin"], read: ["all"] } },
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
// Same feature name = duplicate feature error (already tested)
|
|
1027
|
+
// Different feature name but same qualified key is impossible since prefix differs
|
|
1028
|
+
// So this test verifies the same feature can't register twice
|
|
1029
|
+
const f2 = defineFeature("a", (r) => {
|
|
1030
|
+
r.config({
|
|
1031
|
+
keys: {
|
|
1032
|
+
key1: { type: "text", scope: "system", access: { write: ["Admin"], read: ["all"] } },
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
expect(() => createRegistry([f1, f2])).toThrow(/duplicate feature/i);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test("encrypted config key is stored", () => {
|
|
1041
|
+
const feature = defineFeature("integration", (r) => {
|
|
1042
|
+
r.config({
|
|
1043
|
+
keys: {
|
|
1044
|
+
apiSecret: {
|
|
1045
|
+
type: "text",
|
|
1046
|
+
scope: "tenant",
|
|
1047
|
+
encrypted: true,
|
|
1048
|
+
access: { write: ["SystemAdmin"], read: ["SystemAdmin"] },
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const registry = createRegistry([feature]);
|
|
1055
|
+
expect(registry.getConfigKey("integration:config:api-secret")?.encrypted).toBe(true);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("returns typed handles with qualified names", () => {
|
|
1059
|
+
// Capture in the setup closure — that's where the generic resolves
|
|
1060
|
+
// to the literal key shape.
|
|
1061
|
+
let handles!: {
|
|
1062
|
+
readonly defaultVat: { readonly name: string; readonly type: "number" };
|
|
1063
|
+
readonly showNetPrices: { readonly name: string; readonly type: "boolean" };
|
|
1064
|
+
};
|
|
1065
|
+
defineFeature("invoicing", (r) => {
|
|
1066
|
+
handles = r.config({
|
|
1067
|
+
keys: {
|
|
1068
|
+
defaultVat: createTenantConfig("number", { default: 19 }),
|
|
1069
|
+
showNetPrices: createUserConfig("boolean", { default: true }),
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
expect(handles.defaultVat.name).toBe("invoicing:config:default-vat");
|
|
1075
|
+
expect(handles.defaultVat.type).toBe("number");
|
|
1076
|
+
expect(handles.showNetPrices.name).toBe("invoicing:config:show-net-prices");
|
|
1077
|
+
expect(handles.showNetPrices.type).toBe("boolean");
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("camelCase feature + key are kebab-cased in the handle name", () => {
|
|
1081
|
+
let handles!: { readonly monthlyTotalCents: { readonly name: string } };
|
|
1082
|
+
defineFeature("billingCore", (r) => {
|
|
1083
|
+
handles = r.config({
|
|
1084
|
+
keys: {
|
|
1085
|
+
monthlyTotalCents: createSystemConfig("number", { default: 0 }),
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
expect(handles.monthlyTotalCents.name).toBe("billing-core:config:monthly-total-cents");
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
test("createApp validates config key roles", () => {
|
|
1093
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
1094
|
+
r.config({
|
|
1095
|
+
keys: {
|
|
1096
|
+
vat: {
|
|
1097
|
+
type: "number",
|
|
1098
|
+
default: 19,
|
|
1099
|
+
scope: "tenant",
|
|
1100
|
+
access: { write: ["FakeRole"], read: ["all"] },
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
expect(() => createApp({ roles: ["Admin"] as const, features: [feature] })).toThrow(
|
|
1107
|
+
/unknown role.*FakeRole/i,
|
|
1108
|
+
);
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("createApp allows 'all' and 'system' as special roles", () => {
|
|
1112
|
+
const feature = defineFeature("invoicing", (r) => {
|
|
1113
|
+
r.config({
|
|
1114
|
+
keys: {
|
|
1115
|
+
vat: {
|
|
1116
|
+
type: "number",
|
|
1117
|
+
default: 19,
|
|
1118
|
+
scope: "tenant",
|
|
1119
|
+
access: { write: ["system"], read: ["all"] },
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
expect(() => createApp({ roles: ["Admin"] as const, features: [feature] })).not.toThrow();
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// --- Relations ---
|
|
1130
|
+
|
|
1131
|
+
describe("r.relation()", () => {
|
|
1132
|
+
test("registers belongsTo relation", () => {
|
|
1133
|
+
const feature = defineFeature("test", (r) => {
|
|
1134
|
+
r.entity(
|
|
1135
|
+
"user",
|
|
1136
|
+
createEntity({ table: "Users", fields: { departmentId: createTextField() } }),
|
|
1137
|
+
);
|
|
1138
|
+
r.entity(
|
|
1139
|
+
"department",
|
|
1140
|
+
createEntity({ table: "Departments", fields: { name: createTextField() } }),
|
|
1141
|
+
);
|
|
1142
|
+
r.relation("user", "department", {
|
|
1143
|
+
type: "belongsTo",
|
|
1144
|
+
target: "department",
|
|
1145
|
+
foreignKey: "departmentId",
|
|
1146
|
+
searchInclude: ["name"],
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
expect(feature.relations["user"]).toBeDefined();
|
|
1151
|
+
expect(feature.relations["user"]?.["department"]?.type).toBe("belongsTo");
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
test("registers manyToMany relation", () => {
|
|
1155
|
+
const feature = defineFeature("test", (r) => {
|
|
1156
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1157
|
+
r.entity("role", createEntity({ table: "Roles", fields: { name: createTextField() } }));
|
|
1158
|
+
r.relation("user", "roles", {
|
|
1159
|
+
type: "manyToMany",
|
|
1160
|
+
target: "role",
|
|
1161
|
+
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1162
|
+
searchInclude: ["name"],
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const rel = feature.relations["user"]?.["roles"];
|
|
1167
|
+
expect(rel?.type).toBe("manyToMany");
|
|
1168
|
+
if (rel?.type === "manyToMany") {
|
|
1169
|
+
expect(rel.through.table).toBe("UserRoles");
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
test("registers hasMany relation", () => {
|
|
1174
|
+
const feature = defineFeature("test", (r) => {
|
|
1175
|
+
r.entity("department", createEntity({ table: "Departments", fields: {} }));
|
|
1176
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1177
|
+
r.relation("department", "users", {
|
|
1178
|
+
type: "hasMany",
|
|
1179
|
+
target: "user",
|
|
1180
|
+
foreignKey: "departmentId",
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
expect(feature.relations["department"]?.["users"]?.type).toBe("hasMany");
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
describe("registry relations", () => {
|
|
1189
|
+
test("getRelations returns relations within same feature", () => {
|
|
1190
|
+
const f1 = defineFeature("users", (r) => {
|
|
1191
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1192
|
+
r.entity("role", createEntity({ table: "Roles", fields: { name: createTextField() } }));
|
|
1193
|
+
r.relation("user", "roles", {
|
|
1194
|
+
type: "manyToMany",
|
|
1195
|
+
target: "role",
|
|
1196
|
+
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1197
|
+
searchInclude: ["name"],
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
const registry = createRegistry([f1]);
|
|
1202
|
+
const rels = registry.getRelations("user");
|
|
1203
|
+
expect(rels["roles"]).toBeDefined();
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
test("getSearchIncludes returns fields to index from relations", () => {
|
|
1207
|
+
const feature = defineFeature("test", (r) => {
|
|
1208
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1209
|
+
r.entity("role", createEntity({ table: "Roles", fields: { name: createTextField() } }));
|
|
1210
|
+
r.entity("department", createEntity({ table: "Depts", fields: { name: createTextField() } }));
|
|
1211
|
+
r.relation("user", "roles", {
|
|
1212
|
+
type: "manyToMany",
|
|
1213
|
+
target: "role",
|
|
1214
|
+
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1215
|
+
searchInclude: ["name"],
|
|
1216
|
+
});
|
|
1217
|
+
r.relation("user", "department", {
|
|
1218
|
+
type: "belongsTo",
|
|
1219
|
+
target: "department",
|
|
1220
|
+
foreignKey: "departmentId",
|
|
1221
|
+
searchInclude: ["name"],
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
const registry = createRegistry([feature]);
|
|
1226
|
+
const includes = registry.getSearchIncludes("user");
|
|
1227
|
+
expect(includes.get("roles")).toEqual(["name"]);
|
|
1228
|
+
expect(includes.get("department")).toEqual(["name"]);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test("throws on relation to non-existent entity", () => {
|
|
1232
|
+
const feature = defineFeature("test", (r) => {
|
|
1233
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1234
|
+
r.relation("user", "ghost", {
|
|
1235
|
+
type: "belongsTo",
|
|
1236
|
+
target: "nonexistent",
|
|
1237
|
+
foreignKey: "ghostId",
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
expect(() => createRegistry([feature])).toThrow(/nonexistent.*does not exist/i);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("second relation with same name on same entity overwrites in feature definition", () => {
|
|
1245
|
+
// Within a single feature, the second r.relation() call overwrites the first
|
|
1246
|
+
const f1 = defineFeature("a", (r) => {
|
|
1247
|
+
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1248
|
+
r.entity("role", createEntity({ table: "Roles", fields: {} }));
|
|
1249
|
+
r.relation("user", "roles", {
|
|
1250
|
+
type: "manyToMany",
|
|
1251
|
+
target: "role",
|
|
1252
|
+
through: { table: "UR1", sourceKey: "userId", targetKey: "roleId" },
|
|
1253
|
+
});
|
|
1254
|
+
r.relation("user", "roles", {
|
|
1255
|
+
type: "manyToMany",
|
|
1256
|
+
target: "role",
|
|
1257
|
+
through: { table: "UR2", sourceKey: "userId", targetKey: "roleId" },
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
const registry = createRegistry([f1]);
|
|
1262
|
+
const rels = registry.getRelations("user");
|
|
1263
|
+
// Last write wins
|
|
1264
|
+
expect((rels["roles"] as { through: { table: string } }).through.table).toBe("UR2");
|
|
1265
|
+
});
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
// --- Global Search (new tenant-based API) ---
|
|
1269
|
+
|
|
1270
|
+
describe("global search", () => {
|
|
1271
|
+
test("searches across entity types in same tenant", async () => {
|
|
1272
|
+
const { createInMemorySearchAdapter } = await import("../../search");
|
|
1273
|
+
const adapter = createInMemorySearchAdapter();
|
|
1274
|
+
await adapter.configure("00000000-0000-4000-8000-000000000001", {
|
|
1275
|
+
searchableFields: ["email", "name", "title"],
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
await adapter.index("00000000-0000-4000-8000-000000000001", {
|
|
1279
|
+
entityType: "user",
|
|
1280
|
+
entityId: 1,
|
|
1281
|
+
weight: 10,
|
|
1282
|
+
fields: { email: "marc@test.de", name: "Marc" },
|
|
1283
|
+
});
|
|
1284
|
+
await adapter.index("00000000-0000-4000-8000-000000000001", {
|
|
1285
|
+
entityType: "project",
|
|
1286
|
+
entityId: 10,
|
|
1287
|
+
weight: 5,
|
|
1288
|
+
fields: { title: "Marc's Project" },
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
const results = await adapter.search("00000000-0000-4000-8000-000000000001", "marc");
|
|
1292
|
+
expect(results).toHaveLength(2);
|
|
1293
|
+
expect(results[0]?.entityType).toBe("user"); // higher weight
|
|
1294
|
+
expect(results[1]?.entityType).toBe("project");
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test("no filter = all types, filterType = one type", async () => {
|
|
1298
|
+
const { createInMemorySearchAdapter } = await import("../../search");
|
|
1299
|
+
const adapter = createInMemorySearchAdapter();
|
|
1300
|
+
await adapter.configure("00000000-0000-4000-8000-000000000001", { searchableFields: ["name"] });
|
|
1301
|
+
|
|
1302
|
+
await adapter.index("00000000-0000-4000-8000-000000000001", {
|
|
1303
|
+
entityType: "user",
|
|
1304
|
+
entityId: 1,
|
|
1305
|
+
weight: 1,
|
|
1306
|
+
fields: { name: "Marc" },
|
|
1307
|
+
});
|
|
1308
|
+
await adapter.index("00000000-0000-4000-8000-000000000001", {
|
|
1309
|
+
entityType: "project",
|
|
1310
|
+
entityId: 10,
|
|
1311
|
+
weight: 1,
|
|
1312
|
+
fields: { name: "Kumiko" },
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
const all = await adapter.search("00000000-0000-4000-8000-000000000001", "marc");
|
|
1316
|
+
expect(all).toHaveLength(1);
|
|
1317
|
+
expect(all[0]?.entityType).toBe("user");
|
|
1318
|
+
|
|
1319
|
+
const projects = await adapter.search("00000000-0000-4000-8000-000000000001", "kumiko", {
|
|
1320
|
+
filterType: "project",
|
|
1321
|
+
});
|
|
1322
|
+
expect(projects).toHaveLength(1);
|
|
1323
|
+
expect(projects[0]?.entityType).toBe("project");
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
// --- Boot Validation: dangling references ---
|
|
1328
|
+
|
|
1329
|
+
describe("registry boot validation", () => {
|
|
1330
|
+
test("throws for lifecycle hook targeting non-existent handler", () => {
|
|
1331
|
+
const feature = defineFeature("test", (r) => {
|
|
1332
|
+
r.hook("postSave", "nonexistent.handler", async () => {});
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
expect(() => createRegistry([feature])).toThrow(/postSave.*nonexistent.*never fire/i);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("throws for job event trigger targeting non-existent handler", () => {
|
|
1339
|
+
const feature = defineFeature("test", (r) => {
|
|
1340
|
+
r.job("myJob", { trigger: { on: "ghost-handler" } }, async () => {});
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
expect(() => createRegistry([feature])).toThrow(/my-job.*ghost-handler.*no handler/i);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test("multi-trigger: r.job akzeptiert ein Array von Trigger-Refs", () => {
|
|
1347
|
+
const feature = defineFeature("shop", (r) => {
|
|
1348
|
+
r.entity("order", createEntity({ table: "Orders", fields: {} }));
|
|
1349
|
+
r.writeHandler("order:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
1350
|
+
access: { openToAll: true },
|
|
1351
|
+
});
|
|
1352
|
+
r.writeHandler("order:cancel", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
1353
|
+
access: { openToAll: true },
|
|
1354
|
+
});
|
|
1355
|
+
// Ein Job-Body, zwei Trigger — DRY-Pattern für Fanout-Cases.
|
|
1356
|
+
r.job(
|
|
1357
|
+
"fanout",
|
|
1358
|
+
{ trigger: { on: ["shop:write:order:create", "shop:write:order:cancel"] } },
|
|
1359
|
+
async () => {},
|
|
1360
|
+
);
|
|
1361
|
+
});
|
|
1362
|
+
const registry = createRegistry([feature]);
|
|
1363
|
+
const job = registry.getJob("shop:job:fanout");
|
|
1364
|
+
expect(job).toBeDefined();
|
|
1365
|
+
if (job && "on" in job.trigger) {
|
|
1366
|
+
expect(job.trigger.on).toEqual(["shop:write:order:create", "shop:write:order:cancel"]);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("multi-trigger: einer der Targets fehlt → Boot-Reject", () => {
|
|
1371
|
+
const feature = defineFeature("shop", (r) => {
|
|
1372
|
+
r.entity("order", createEntity({ table: "Orders", fields: {} }));
|
|
1373
|
+
r.writeHandler("order:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
1374
|
+
access: { openToAll: true },
|
|
1375
|
+
});
|
|
1376
|
+
// create existiert, cancel nicht — zweiter Trigger ist Geist
|
|
1377
|
+
r.job(
|
|
1378
|
+
"fanout",
|
|
1379
|
+
{ trigger: { on: ["shop:write:order:create", "shop:write:order:ghost"] } },
|
|
1380
|
+
async () => {},
|
|
1381
|
+
);
|
|
1382
|
+
});
|
|
1383
|
+
expect(() => createRegistry([feature])).toThrow(/fanout.*ghost.*no handler/i);
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("throws for extension usage referencing non-existent extension", () => {
|
|
1387
|
+
const feature = defineFeature("test", (r) => {
|
|
1388
|
+
r.useExtension("nonexistent", "user");
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
expect(() => createRegistry([feature])).toThrow(/nonexistent.*does not exist/i);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("allows valid hook targets", () => {
|
|
1395
|
+
const feature = defineFeature("test", (r) => {
|
|
1396
|
+
r.entity("item", createEntity({ table: "Items", fields: {} }));
|
|
1397
|
+
r.writeHandler("item.create", z.object({}), async () => ({ isSuccess: true, data: null }), {
|
|
1398
|
+
access: { openToAll: true },
|
|
1399
|
+
});
|
|
1400
|
+
r.hook("postSave", "item.create", async () => {});
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("allows cron and manual job triggers (no handler reference)", () => {
|
|
1407
|
+
const feature = defineFeature("test", (r) => {
|
|
1408
|
+
r.job("cleanup", { trigger: { cron: "0 * * * *" } }, async () => {});
|
|
1409
|
+
r.job("sync", { trigger: { manual: true } }, async () => {});
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
test("runIn flows through r.job into the registry", () => {
|
|
1416
|
+
const feature = defineFeature("test", (r) => {
|
|
1417
|
+
r.job("cleanup-api", { trigger: { manual: true }, runIn: "api" }, async () => {});
|
|
1418
|
+
r.job("cleanup-worker", { trigger: { manual: true }, runIn: "worker" }, async () => {});
|
|
1419
|
+
r.job("cleanup-default", { trigger: { manual: true } }, async () => {});
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
const registry = createRegistry([feature]);
|
|
1423
|
+
const jobs = registry.getAllJobs();
|
|
1424
|
+
expect(jobs.get("test:job:cleanup-api")?.runIn).toBe("api");
|
|
1425
|
+
expect(jobs.get("test:job:cleanup-worker")?.runIn).toBe("worker");
|
|
1426
|
+
// Omitted runIn stays undefined in the registry — the consumer (JobRunner)
|
|
1427
|
+
// resolves the default to "worker" at dispatch time, not at registration.
|
|
1428
|
+
expect(jobs.get("test:job:cleanup-default")?.runIn).toBeUndefined();
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
test("registry rejects job runIn='both' (Lane-Queue would over-dispatch)", () => {
|
|
1432
|
+
// TS-level JobRunIn = Exclude<RunIn, "both"> already rejects this; the
|
|
1433
|
+
// runtime guard exists for config-driven or cast-through paths.
|
|
1434
|
+
const feature = defineFeature("test", (r) => {
|
|
1435
|
+
r.job(
|
|
1436
|
+
"bad",
|
|
1437
|
+
{ trigger: { manual: true }, runIn: "both" as unknown as "api" | "worker" },
|
|
1438
|
+
async () => {},
|
|
1439
|
+
);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
expect(() => createRegistry([feature])).toThrow(
|
|
1443
|
+
/runIn "both".*must be pinned to a single lane/i,
|
|
1444
|
+
);
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
test("registry rejects MSP runIn with an unknown literal", () => {
|
|
1448
|
+
const feature = defineFeature("test", (r) => {
|
|
1449
|
+
r.multiStreamProjection({
|
|
1450
|
+
name: "msp",
|
|
1451
|
+
runIn: "everywhere" as unknown as "api",
|
|
1452
|
+
// defineFeature refuses an empty apply-map, so declare a dummy event
|
|
1453
|
+
// handler — the test is about the runIn-literal guard in registry.ts,
|
|
1454
|
+
// not about MSP-empty-apply.
|
|
1455
|
+
apply: { "some:event": async () => {} },
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
expect(() => createRegistry([feature])).toThrow(/invalid runIn "everywhere"/i);
|
|
1460
|
+
});
|
|
1461
|
+
});
|