@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,1865 @@
|
|
|
1
|
+
import { pgTable, text } from "drizzle-orm/pg-core";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { validateBoot } from "../boot-validator";
|
|
5
|
+
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
6
|
+
import {
|
|
7
|
+
createEntity,
|
|
8
|
+
createMultiSelectField,
|
|
9
|
+
createTextField,
|
|
10
|
+
defineFeature,
|
|
11
|
+
from,
|
|
12
|
+
} from "../index";
|
|
13
|
+
|
|
14
|
+
describe("boot-validator", () => {
|
|
15
|
+
test("passes for valid features with no issues", () => {
|
|
16
|
+
const features = [
|
|
17
|
+
defineFeature("a", (r) => {
|
|
18
|
+
r.entity("user", createEntity({ table: "Users", fields: { name: createTextField() } }));
|
|
19
|
+
}),
|
|
20
|
+
];
|
|
21
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// --- Circular dependencies ---
|
|
25
|
+
|
|
26
|
+
test("detects circular requires: A → B → A", () => {
|
|
27
|
+
const features = [
|
|
28
|
+
defineFeature("a", (r) => {
|
|
29
|
+
r.requires("b");
|
|
30
|
+
}),
|
|
31
|
+
defineFeature("b", (r) => {
|
|
32
|
+
r.requires("a");
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
expect(() => validateBoot(features)).toThrow(/circular dependency.*a.*b/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("detects circular requires: A → B → C → A", () => {
|
|
39
|
+
const features = [
|
|
40
|
+
defineFeature("a", (r) => {
|
|
41
|
+
r.requires("b");
|
|
42
|
+
}),
|
|
43
|
+
defineFeature("b", (r) => {
|
|
44
|
+
r.requires("c");
|
|
45
|
+
}),
|
|
46
|
+
defineFeature("c", (r) => {
|
|
47
|
+
r.requires("a");
|
|
48
|
+
}),
|
|
49
|
+
];
|
|
50
|
+
expect(() => validateBoot(features)).toThrow(/circular dependency/i);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no circular dependency for diamond shape: A → B, A → C, B → D, C → D", () => {
|
|
54
|
+
const features = [
|
|
55
|
+
defineFeature("d", () => {}),
|
|
56
|
+
defineFeature("b", (r) => {
|
|
57
|
+
r.requires("d");
|
|
58
|
+
}),
|
|
59
|
+
defineFeature("c", (r) => {
|
|
60
|
+
r.requires("d");
|
|
61
|
+
}),
|
|
62
|
+
defineFeature("a", (r) => {
|
|
63
|
+
r.requires("b", "c");
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// --- encrypted + searchable ---
|
|
70
|
+
|
|
71
|
+
test("rejects encrypted + searchable field", () => {
|
|
72
|
+
const features = [
|
|
73
|
+
defineFeature("a", (r) => {
|
|
74
|
+
r.entity(
|
|
75
|
+
"secret",
|
|
76
|
+
createEntity({
|
|
77
|
+
table: "Secrets",
|
|
78
|
+
fields: {
|
|
79
|
+
apiKey: { type: "text", encrypted: true, searchable: true },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
expect(() => validateBoot(features)).toThrow(
|
|
86
|
+
/apiKey.*cannot be both encrypted and searchable/i,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("rejects encrypted + sortable field", () => {
|
|
91
|
+
const features = [
|
|
92
|
+
defineFeature("a", (r) => {
|
|
93
|
+
r.entity(
|
|
94
|
+
"secret",
|
|
95
|
+
createEntity({
|
|
96
|
+
table: "Secrets",
|
|
97
|
+
fields: {
|
|
98
|
+
token: { type: "text", encrypted: true, sortable: true },
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}),
|
|
103
|
+
];
|
|
104
|
+
expect(() => validateBoot(features)).toThrow(/token.*cannot be both encrypted and sortable/i);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("allows encrypted field when ENCRYPTION_KEY is set", () => {
|
|
108
|
+
process.env["ENCRYPTION_KEY"] = "test-key";
|
|
109
|
+
try {
|
|
110
|
+
const features = [
|
|
111
|
+
defineFeature("a", (r) => {
|
|
112
|
+
r.entity(
|
|
113
|
+
"secret",
|
|
114
|
+
createEntity({
|
|
115
|
+
table: "Secrets",
|
|
116
|
+
fields: {
|
|
117
|
+
apiKey: { type: "text", encrypted: true },
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
124
|
+
} finally {
|
|
125
|
+
delete process.env["ENCRYPTION_KEY"];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("throws when encrypted fields exist but ENCRYPTION_KEY not set", () => {
|
|
130
|
+
delete process.env["ENCRYPTION_KEY"];
|
|
131
|
+
const features = [
|
|
132
|
+
defineFeature("a", (r) => {
|
|
133
|
+
r.entity(
|
|
134
|
+
"secret",
|
|
135
|
+
createEntity({
|
|
136
|
+
table: "Secrets",
|
|
137
|
+
fields: {
|
|
138
|
+
apiKey: { type: "text", encrypted: true },
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("throws when longText encrypted field exists but ENCRYPTION_KEY not set", () => {
|
|
148
|
+
// Drift-pin Sprint-5b-vorab-Audit Issue 1: validateEncryptedFields
|
|
149
|
+
// hatte `if (field.type !== "text") continue;` und ignorierte
|
|
150
|
+
// longText-encrypted-fields silently — ENCRYPTION_KEY-check wurde
|
|
151
|
+
// nie getriggert, encryption silent broken. Jetzt: beide string-
|
|
152
|
+
// typed fields werden gechecked.
|
|
153
|
+
delete process.env["ENCRYPTION_KEY"];
|
|
154
|
+
const features = [
|
|
155
|
+
defineFeature("a", (r) => {
|
|
156
|
+
r.entity(
|
|
157
|
+
"doc",
|
|
158
|
+
createEntity({
|
|
159
|
+
table: "Docs",
|
|
160
|
+
fields: {
|
|
161
|
+
body: { type: "longText", encrypted: true },
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}),
|
|
166
|
+
];
|
|
167
|
+
expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// --- index-validator longText block ---
|
|
171
|
+
|
|
172
|
+
test("rejects longText field in entity.indexes (longText is not indexable)", () => {
|
|
173
|
+
// Drift-pin Sprint-5b-vorab-Audit Issue 2: ohne den Check würde
|
|
174
|
+
// ein BTREE-Index auf einer 1-MB-text-Spalte gebaut werden
|
|
175
|
+
// (Performance-Disaster mit TOAST-page-Dereferenzierung). longText
|
|
176
|
+
// ist semantisch non-indexierbar, konsistent zum type-level
|
|
177
|
+
// sortable=false.
|
|
178
|
+
const features = [
|
|
179
|
+
defineFeature("a", (r) => {
|
|
180
|
+
r.entity(
|
|
181
|
+
"doc",
|
|
182
|
+
createEntity({
|
|
183
|
+
table: "Docs",
|
|
184
|
+
fields: {
|
|
185
|
+
body: { type: "longText" },
|
|
186
|
+
title: { type: "text" },
|
|
187
|
+
},
|
|
188
|
+
indexes: [{ columns: ["title", "body"] }],
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
}),
|
|
192
|
+
];
|
|
193
|
+
expect(() => validateBoot(features)).toThrow(/body.*longText.*cannot be indexed/i);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// --- Extension usage without requires ---
|
|
197
|
+
|
|
198
|
+
test("warns when extension used without requires", () => {
|
|
199
|
+
const ext = defineFeature("tags", (r) => {
|
|
200
|
+
r.extendsRegistrar("tags", { onRegister: () => {} });
|
|
201
|
+
});
|
|
202
|
+
const consumer = defineFeature("fleet", (r) => {
|
|
203
|
+
r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
|
|
204
|
+
r.useExtension("tags", "vehicle");
|
|
205
|
+
// Missing: r.requires("tags")
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(() => validateBoot([ext, consumer])).toThrow(/fleet.*uses extension "tags".*requires/i);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("passes when extension used with requires", () => {
|
|
212
|
+
const ext = defineFeature("tags", (r) => {
|
|
213
|
+
r.extendsRegistrar("tags", { onRegister: () => {} });
|
|
214
|
+
});
|
|
215
|
+
const consumer = defineFeature("fleet", (r) => {
|
|
216
|
+
r.requires("tags");
|
|
217
|
+
r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
|
|
218
|
+
r.useExtension("tags", "vehicle");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(() => validateBoot([ext, consumer])).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("passes when extension used with optionalRequires", () => {
|
|
225
|
+
const ext = defineFeature("tags", (r) => {
|
|
226
|
+
r.extendsRegistrar("tags", { onRegister: () => {} });
|
|
227
|
+
});
|
|
228
|
+
const consumer = defineFeature("fleet", (r) => {
|
|
229
|
+
r.optionalRequires("tags");
|
|
230
|
+
r.entity("vehicle", createEntity({ table: "Vehicles", fields: {} }));
|
|
231
|
+
r.useExtension("tags", "vehicle");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(() => validateBoot([ext, consumer])).not.toThrow();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// --- FILE_STORAGE_PROVIDER ---
|
|
238
|
+
|
|
239
|
+
test("throws when file fields exist but FILE_STORAGE_PROVIDER not set", () => {
|
|
240
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
241
|
+
const features = [
|
|
242
|
+
defineFeature("a", (r) => {
|
|
243
|
+
r.entity(
|
|
244
|
+
"doc",
|
|
245
|
+
createEntity({
|
|
246
|
+
table: "Docs",
|
|
247
|
+
fields: { contract: { type: "file" } },
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
expect(() => validateBoot(features)).toThrow(/FILE_STORAGE_PROVIDER.*required/i);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("passes when file fields exist and FILE_STORAGE_PROVIDER is set", () => {
|
|
256
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
257
|
+
try {
|
|
258
|
+
const features = [
|
|
259
|
+
defineFeature("a", (r) => {
|
|
260
|
+
r.entity(
|
|
261
|
+
"doc",
|
|
262
|
+
createEntity({
|
|
263
|
+
table: "Docs",
|
|
264
|
+
fields: { photo: { type: "image" } },
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}),
|
|
268
|
+
];
|
|
269
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
270
|
+
} finally {
|
|
271
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// --- extendSchema column collision ---
|
|
276
|
+
|
|
277
|
+
test("throws when extendSchema column conflicts with existing field", () => {
|
|
278
|
+
const features = [
|
|
279
|
+
defineFeature("a", (r) => {
|
|
280
|
+
r.entity(
|
|
281
|
+
"item",
|
|
282
|
+
createEntity({
|
|
283
|
+
table: "Items",
|
|
284
|
+
fields: { name: createTextField() },
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
r.extendsRegistrar("custom", {
|
|
288
|
+
extendSchema: () => ({ name: { type: "text" as const } }),
|
|
289
|
+
});
|
|
290
|
+
}),
|
|
291
|
+
];
|
|
292
|
+
expect(() => validateBoot(features)).toThrow(
|
|
293
|
+
/extendSchema column "name" conflicts.*entity "item"/i,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// --- Config key cross-feature references ---
|
|
298
|
+
|
|
299
|
+
test("throws when readsConfig references non-existent key", () => {
|
|
300
|
+
const features = [
|
|
301
|
+
defineFeature("invoicing", (r) => {
|
|
302
|
+
r.readsConfig("payments.gateway");
|
|
303
|
+
}),
|
|
304
|
+
];
|
|
305
|
+
expect(() => validateBoot(features)).toThrow(
|
|
306
|
+
/invoicing.*reads config "payments.gateway".*no feature defines/i,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("passes when readsConfig references existing key", () => {
|
|
311
|
+
const features = [
|
|
312
|
+
defineFeature("payments", (r) => {
|
|
313
|
+
r.config({
|
|
314
|
+
keys: {
|
|
315
|
+
gateway: {
|
|
316
|
+
type: "text",
|
|
317
|
+
scope: "tenant",
|
|
318
|
+
access: { read: ["all"], write: ["Admin"] },
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}),
|
|
323
|
+
defineFeature("invoicing", (r) => {
|
|
324
|
+
r.requires("payments");
|
|
325
|
+
r.readsConfig("payments.gateway");
|
|
326
|
+
}),
|
|
327
|
+
];
|
|
328
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("passes when extendSchema adds non-conflicting column", () => {
|
|
332
|
+
const features = [
|
|
333
|
+
defineFeature("a", (r) => {
|
|
334
|
+
r.entity(
|
|
335
|
+
"item",
|
|
336
|
+
createEntity({
|
|
337
|
+
table: "Items",
|
|
338
|
+
fields: { name: createTextField() },
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
r.extendsRegistrar("custom", {
|
|
342
|
+
extendSchema: () => ({ extra: { type: "text" as const } }),
|
|
343
|
+
});
|
|
344
|
+
}),
|
|
345
|
+
];
|
|
346
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// --- Handler access validation (default-deny) ---
|
|
350
|
+
|
|
351
|
+
test("throws when a write handler has no access rule", () => {
|
|
352
|
+
const features = [
|
|
353
|
+
defineFeature("a", (r) => {
|
|
354
|
+
r.writeHandler("createThing", z.object({ name: z.string() }), async () => ({
|
|
355
|
+
isSuccess: true as const,
|
|
356
|
+
data: {},
|
|
357
|
+
}));
|
|
358
|
+
}),
|
|
359
|
+
];
|
|
360
|
+
expect(() => validateBoot(features)).toThrow(/a:write:createThing.*missing an access rule/i);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("throws when a query handler has no access rule", () => {
|
|
364
|
+
const features = [
|
|
365
|
+
defineFeature("a", (r) => {
|
|
366
|
+
r.queryHandler("list", z.object({}), async () => []);
|
|
367
|
+
}),
|
|
368
|
+
];
|
|
369
|
+
expect(() => validateBoot(features)).toThrow(/a:query:list.*missing an access rule/i);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("accepts role-based access rule", () => {
|
|
373
|
+
const features = [
|
|
374
|
+
defineFeature("a", (r) => {
|
|
375
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
376
|
+
access: { roles: ["Admin"] },
|
|
377
|
+
});
|
|
378
|
+
}),
|
|
379
|
+
];
|
|
380
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("accepts openToAll access rule", () => {
|
|
384
|
+
const features = [
|
|
385
|
+
defineFeature("a", (r) => {
|
|
386
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
387
|
+
access: { openToAll: true },
|
|
388
|
+
});
|
|
389
|
+
}),
|
|
390
|
+
];
|
|
391
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("config key bounds consistency", () => {
|
|
395
|
+
test("accepts number key with consistent bounds + default", () => {
|
|
396
|
+
const features = [
|
|
397
|
+
defineFeature("files", (r) => {
|
|
398
|
+
r.config({
|
|
399
|
+
keys: {
|
|
400
|
+
maxUploadMB: createTenantConfig("number", {
|
|
401
|
+
default: 10,
|
|
402
|
+
bounds: { min: 1, max: 100 },
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}),
|
|
407
|
+
];
|
|
408
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("rejects min > max", () => {
|
|
412
|
+
const features = [
|
|
413
|
+
defineFeature("files", (r) => {
|
|
414
|
+
r.config({
|
|
415
|
+
keys: {
|
|
416
|
+
weird: createTenantConfig("number", { bounds: { min: 100, max: 10 } }),
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}),
|
|
420
|
+
];
|
|
421
|
+
expect(() => validateBoot(features)).toThrow(/bounds\.min.*>.*bounds\.max/i);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("rejects default below min", () => {
|
|
425
|
+
const features = [
|
|
426
|
+
defineFeature("files", (r) => {
|
|
427
|
+
r.config({
|
|
428
|
+
keys: {
|
|
429
|
+
tooLow: createTenantConfig("number", {
|
|
430
|
+
default: 0,
|
|
431
|
+
bounds: { min: 1, max: 100 },
|
|
432
|
+
}),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
}),
|
|
436
|
+
];
|
|
437
|
+
expect(() => validateBoot(features)).toThrow(/default.*below bounds\.min/i);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("rejects default above max", () => {
|
|
441
|
+
const features = [
|
|
442
|
+
defineFeature("files", (r) => {
|
|
443
|
+
r.config({
|
|
444
|
+
keys: {
|
|
445
|
+
tooHigh: createSystemConfig("number", {
|
|
446
|
+
default: 200,
|
|
447
|
+
bounds: { max: 100 },
|
|
448
|
+
}),
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}),
|
|
452
|
+
];
|
|
453
|
+
expect(() => validateBoot(features)).toThrow(/default.*above bounds\.max/i);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("accepts partial bounds (only min)", () => {
|
|
457
|
+
const features = [
|
|
458
|
+
defineFeature("files", (r) => {
|
|
459
|
+
r.config({
|
|
460
|
+
keys: {
|
|
461
|
+
lowerOnly: createTenantConfig("number", {
|
|
462
|
+
default: 5,
|
|
463
|
+
bounds: { min: 1 },
|
|
464
|
+
}),
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
}),
|
|
468
|
+
];
|
|
469
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("accepts bounds without default (bound-only key)", () => {
|
|
473
|
+
const features = [
|
|
474
|
+
defineFeature("files", (r) => {
|
|
475
|
+
r.config({
|
|
476
|
+
keys: {
|
|
477
|
+
bounded: createTenantConfig("number", { bounds: { min: 1, max: 100 } }),
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}),
|
|
481
|
+
];
|
|
482
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("rejects bounds on non-number key (defence in depth against hand-rolled definitions)", () => {
|
|
486
|
+
const features = [
|
|
487
|
+
defineFeature("files", (r) => {
|
|
488
|
+
// Cast needed because type-level guard rejects this at the call site.
|
|
489
|
+
// Boot validator catches the same class of bug when someone bypasses
|
|
490
|
+
// the helper (e.g. importing a plain ConfigKeyDefinition object).
|
|
491
|
+
r.config({
|
|
492
|
+
keys: {
|
|
493
|
+
textKey: {
|
|
494
|
+
type: "text",
|
|
495
|
+
scope: "tenant",
|
|
496
|
+
access: { read: ["all"], write: ["all"] },
|
|
497
|
+
bounds: { min: 1 },
|
|
498
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type bypass for defence-in-depth test
|
|
499
|
+
} as any,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}),
|
|
503
|
+
];
|
|
504
|
+
expect(() => validateBoot(features)).toThrow(/bounds.*only valid for type="number"/i);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("config key computed + encrypted exclusivity", () => {
|
|
509
|
+
test("rejects encrypted + computed combination", () => {
|
|
510
|
+
const features = [
|
|
511
|
+
defineFeature("files", (r) => {
|
|
512
|
+
r.config({
|
|
513
|
+
keys: {
|
|
514
|
+
mixed: {
|
|
515
|
+
type: "text",
|
|
516
|
+
scope: "tenant",
|
|
517
|
+
access: { read: ["all"], write: ["all"] },
|
|
518
|
+
encrypted: true,
|
|
519
|
+
computed: async () => "x",
|
|
520
|
+
// biome-ignore lint/suspicious/noExplicitAny: hand-rolled definition bypasses helper-level type narrowing
|
|
521
|
+
} as any,
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
}),
|
|
525
|
+
];
|
|
526
|
+
expect(() => validateBoot(features)).toThrow(/encrypted.*computed.*mutually exclusive/i);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("accepts computed without encrypted (normal plan-based use-case)", () => {
|
|
530
|
+
const features = [
|
|
531
|
+
defineFeature("files", (r) => {
|
|
532
|
+
r.config({
|
|
533
|
+
keys: {
|
|
534
|
+
planBased: createTenantConfig("number", {
|
|
535
|
+
default: 10,
|
|
536
|
+
computed: async () => 100,
|
|
537
|
+
}),
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}),
|
|
541
|
+
];
|
|
542
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe("config key allowPerRequest compatibility", () => {
|
|
547
|
+
test("accepts allowPerRequest on number keys", () => {
|
|
548
|
+
const features = [
|
|
549
|
+
defineFeature("files", (r) => {
|
|
550
|
+
r.config({
|
|
551
|
+
keys: {
|
|
552
|
+
maxSize: createTenantConfig("number", {
|
|
553
|
+
default: 10,
|
|
554
|
+
bounds: { min: 1, max: 1000 },
|
|
555
|
+
allowPerRequest: true,
|
|
556
|
+
}),
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}),
|
|
560
|
+
];
|
|
561
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("rejects allowPerRequest on text keys (hand-rolled bypass)", () => {
|
|
565
|
+
const features = [
|
|
566
|
+
defineFeature("files", (r) => {
|
|
567
|
+
r.config({
|
|
568
|
+
keys: {
|
|
569
|
+
hacked: {
|
|
570
|
+
type: "text",
|
|
571
|
+
scope: "tenant",
|
|
572
|
+
access: { read: ["all"], write: ["all"] },
|
|
573
|
+
allowPerRequest: true,
|
|
574
|
+
// biome-ignore lint/suspicious/noExplicitAny: defence-in-depth test for hand-rolled definitions
|
|
575
|
+
} as any,
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
}),
|
|
579
|
+
];
|
|
580
|
+
expect(() => validateBoot(features)).toThrow(/allowPerRequest.*type="text".*ineligible/i);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("rejects allowPerRequest on encrypted keys (secret-value protection)", () => {
|
|
584
|
+
const features = [
|
|
585
|
+
defineFeature("secrets", (r) => {
|
|
586
|
+
r.config({
|
|
587
|
+
keys: {
|
|
588
|
+
apiKey: {
|
|
589
|
+
type: "number",
|
|
590
|
+
scope: "tenant",
|
|
591
|
+
access: { read: ["Admin"], write: ["Admin"] },
|
|
592
|
+
encrypted: true,
|
|
593
|
+
allowPerRequest: true,
|
|
594
|
+
// biome-ignore lint/suspicious/noExplicitAny: defence-in-depth test for hand-rolled definitions
|
|
595
|
+
} as any,
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}),
|
|
599
|
+
];
|
|
600
|
+
expect(() => validateBoot(features)).toThrow(
|
|
601
|
+
/allowPerRequest.*encrypted.*secret values may not be set via query-params/i,
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// --- H.2 Ownership-Rule validation ---
|
|
607
|
+
|
|
608
|
+
describe("ownership rules (H.2)", () => {
|
|
609
|
+
test("passes for entity.access.read with claim-rule whose QN exists", () => {
|
|
610
|
+
const features = [
|
|
611
|
+
defineFeature("teams", (r) => {
|
|
612
|
+
r.claimKey("teamId", { type: "string" });
|
|
613
|
+
}),
|
|
614
|
+
defineFeature("orders", (r) => {
|
|
615
|
+
r.entity(
|
|
616
|
+
"order",
|
|
617
|
+
createEntity({
|
|
618
|
+
table: "orders",
|
|
619
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
620
|
+
access: {
|
|
621
|
+
read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
);
|
|
625
|
+
}),
|
|
626
|
+
];
|
|
627
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("detects claim-QN that no feature declared", () => {
|
|
631
|
+
const features = [
|
|
632
|
+
defineFeature("orders", (r) => {
|
|
633
|
+
r.entity(
|
|
634
|
+
"order",
|
|
635
|
+
createEntity({
|
|
636
|
+
table: "orders",
|
|
637
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
638
|
+
access: {
|
|
639
|
+
// No "teams" feature registered — claim doesn't exist.
|
|
640
|
+
read: { TeamMember: from("claim:teams:teamId") },
|
|
641
|
+
},
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
}),
|
|
645
|
+
];
|
|
646
|
+
expect(() => validateBoot(features)).toThrow(
|
|
647
|
+
/entity "order"\.access\.read.*references unknown claim "teams:teamId"/,
|
|
648
|
+
);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("detects column name that doesn't exist on the entity", () => {
|
|
652
|
+
const features = [
|
|
653
|
+
defineFeature("teams", (r) => {
|
|
654
|
+
r.claimKey("teamId", { type: "string" });
|
|
655
|
+
}),
|
|
656
|
+
defineFeature("orders", (r) => {
|
|
657
|
+
r.entity(
|
|
658
|
+
"order",
|
|
659
|
+
createEntity({
|
|
660
|
+
table: "orders",
|
|
661
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
662
|
+
access: {
|
|
663
|
+
read: {
|
|
664
|
+
// column "nonExistentColumn" not on entity
|
|
665
|
+
TeamMember: from("claim:teams:teamId", "nonExistentColumn"),
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
}),
|
|
671
|
+
];
|
|
672
|
+
expect(() => validateBoot(features)).toThrow(
|
|
673
|
+
/references column "nonExistentColumn" which does not exist/,
|
|
674
|
+
);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("passes for field-level ownership rule with existing claim + column", () => {
|
|
678
|
+
const features = [
|
|
679
|
+
defineFeature("teams", (r) => {
|
|
680
|
+
r.claimKey("teamId", { type: "string" });
|
|
681
|
+
}),
|
|
682
|
+
defineFeature("contracts", (r) => {
|
|
683
|
+
r.entity(
|
|
684
|
+
"contract",
|
|
685
|
+
createEntity({
|
|
686
|
+
table: "contracts",
|
|
687
|
+
fields: {
|
|
688
|
+
teamId: createTextField({ required: true }),
|
|
689
|
+
propC: createTextField({
|
|
690
|
+
access: {
|
|
691
|
+
read: { Admin: "all", TeamMember: from("claim:teams:teamId") },
|
|
692
|
+
write: { Admin: "all", TeamMember: from("claim:teams:teamId") },
|
|
693
|
+
},
|
|
694
|
+
}),
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
);
|
|
698
|
+
}),
|
|
699
|
+
];
|
|
700
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("detects unknown claim on field-level rule", () => {
|
|
704
|
+
const features = [
|
|
705
|
+
defineFeature("contracts", (r) => {
|
|
706
|
+
r.entity(
|
|
707
|
+
"contract",
|
|
708
|
+
createEntity({
|
|
709
|
+
table: "contracts",
|
|
710
|
+
fields: {
|
|
711
|
+
teamId: createTextField({ required: true }),
|
|
712
|
+
propC: createTextField({
|
|
713
|
+
access: {
|
|
714
|
+
// claim not declared anywhere
|
|
715
|
+
read: { TeamMember: from("claim:nowhere:teamId") },
|
|
716
|
+
},
|
|
717
|
+
}),
|
|
718
|
+
},
|
|
719
|
+
}),
|
|
720
|
+
);
|
|
721
|
+
}),
|
|
722
|
+
];
|
|
723
|
+
expect(() => validateBoot(features)).toThrow(
|
|
724
|
+
/contract\.propC\.access\.read.*references unknown claim "nowhere:teamId"/,
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("user-ref rule with valid column passes", () => {
|
|
729
|
+
const features = [
|
|
730
|
+
defineFeature("orders", (r) => {
|
|
731
|
+
r.entity(
|
|
732
|
+
"order",
|
|
733
|
+
createEntity({
|
|
734
|
+
table: "orders",
|
|
735
|
+
fields: { assigneeId: createTextField() },
|
|
736
|
+
access: {
|
|
737
|
+
read: { Driver: from("user:id", "assigneeId") },
|
|
738
|
+
},
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
}),
|
|
742
|
+
];
|
|
743
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("'all' rule and { where } rule bypass validation (no ref to check)", () => {
|
|
747
|
+
const features = [
|
|
748
|
+
defineFeature("orders", (r) => {
|
|
749
|
+
r.entity(
|
|
750
|
+
"order",
|
|
751
|
+
createEntity({
|
|
752
|
+
table: "orders",
|
|
753
|
+
fields: { assigneeId: createTextField() },
|
|
754
|
+
access: {
|
|
755
|
+
read: {
|
|
756
|
+
Admin: "all",
|
|
757
|
+
Auditor: {
|
|
758
|
+
kind: "where",
|
|
759
|
+
where: () => ({ queryChunks: [] }) as never,
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
}),
|
|
764
|
+
);
|
|
765
|
+
}),
|
|
766
|
+
];
|
|
767
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("framework columns (id, tenantId, version, ...) are acceptable targets", () => {
|
|
771
|
+
const features = [
|
|
772
|
+
defineFeature("teams", (r) => {
|
|
773
|
+
r.claimKey("teamId", { type: "string" });
|
|
774
|
+
}),
|
|
775
|
+
defineFeature("orders", (r) => {
|
|
776
|
+
r.entity(
|
|
777
|
+
"order",
|
|
778
|
+
createEntity({
|
|
779
|
+
table: "orders",
|
|
780
|
+
fields: {},
|
|
781
|
+
access: {
|
|
782
|
+
read: {
|
|
783
|
+
// tenantId is framework-managed — boot-validator should not reject
|
|
784
|
+
TeamMember: from("claim:teams:teamId", "tenantId"),
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
}),
|
|
788
|
+
);
|
|
789
|
+
}),
|
|
790
|
+
];
|
|
791
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// --- Role-name validation ---
|
|
795
|
+
|
|
796
|
+
test("detects role-name typo in OwnershipMap when other handlers declare the real role", () => {
|
|
797
|
+
// One feature runs a handler that declares the real role "Admin"; a
|
|
798
|
+
// second feature has a typo "Admi" in its OwnershipMap. Validator
|
|
799
|
+
// sees "Admin" in the known-role corpus (from handler.access.roles)
|
|
800
|
+
// and flags "Admi" as unknown.
|
|
801
|
+
const features = [
|
|
802
|
+
defineFeature("accounts", (r) => {
|
|
803
|
+
r.writeHandler({
|
|
804
|
+
name: "accounts:create",
|
|
805
|
+
schema: z.object({}),
|
|
806
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
807
|
+
access: { roles: ["Admin"] },
|
|
808
|
+
});
|
|
809
|
+
}),
|
|
810
|
+
defineFeature("orders", (r) => {
|
|
811
|
+
r.entity(
|
|
812
|
+
"order",
|
|
813
|
+
createEntity({
|
|
814
|
+
table: "orders",
|
|
815
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
816
|
+
access: {
|
|
817
|
+
read: { Admi: "all" },
|
|
818
|
+
},
|
|
819
|
+
}),
|
|
820
|
+
);
|
|
821
|
+
}),
|
|
822
|
+
];
|
|
823
|
+
expect(() => validateBoot(features)).toThrow(
|
|
824
|
+
/unknown role "Admi".*Known roles: Admin, all, system/,
|
|
825
|
+
);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("detects role-name typo in legacy string[] field-access", () => {
|
|
829
|
+
const features = [
|
|
830
|
+
defineFeature("accounts", (r) => {
|
|
831
|
+
r.writeHandler({
|
|
832
|
+
name: "accounts:create",
|
|
833
|
+
schema: z.object({}),
|
|
834
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
835
|
+
access: { roles: ["Admin"] },
|
|
836
|
+
});
|
|
837
|
+
}),
|
|
838
|
+
defineFeature("orders", (r) => {
|
|
839
|
+
r.entity(
|
|
840
|
+
"order",
|
|
841
|
+
createEntity({
|
|
842
|
+
table: "orders",
|
|
843
|
+
fields: {
|
|
844
|
+
secret: createTextField({ access: { read: ["Admni"] } }),
|
|
845
|
+
},
|
|
846
|
+
}),
|
|
847
|
+
);
|
|
848
|
+
}),
|
|
849
|
+
];
|
|
850
|
+
expect(() => validateBoot(features)).toThrow(
|
|
851
|
+
/order\.secret\.access\.read.*unknown role "Admni"/,
|
|
852
|
+
);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("passes when all OwnershipMap roles are referenced by handler access rules too", () => {
|
|
856
|
+
const features = [
|
|
857
|
+
defineFeature("accounts", (r) => {
|
|
858
|
+
r.writeHandler({
|
|
859
|
+
name: "accounts:create",
|
|
860
|
+
schema: z.object({}),
|
|
861
|
+
handler: async () => ({ isSuccess: true as const, data: null }),
|
|
862
|
+
access: { roles: ["Admin", "TeamMember"] },
|
|
863
|
+
});
|
|
864
|
+
}),
|
|
865
|
+
defineFeature("orders", (r) => {
|
|
866
|
+
r.entity(
|
|
867
|
+
"order",
|
|
868
|
+
createEntity({
|
|
869
|
+
table: "orders",
|
|
870
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
871
|
+
access: {
|
|
872
|
+
read: { Admin: "all", TeamMember: "all" },
|
|
873
|
+
},
|
|
874
|
+
}),
|
|
875
|
+
);
|
|
876
|
+
}),
|
|
877
|
+
];
|
|
878
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("skips role validation entirely when no handlers declare non-builtin roles", () => {
|
|
882
|
+
// Apps running only on openToAll / system handlers have no corpus
|
|
883
|
+
// of known roles beyond "all"/"system" — validator must not flag
|
|
884
|
+
// their OwnershipMap roles as unknown. This is the regression test
|
|
885
|
+
// for the shouldValidateRoles gate.
|
|
886
|
+
const features = [
|
|
887
|
+
defineFeature("orders", (r) => {
|
|
888
|
+
r.entity(
|
|
889
|
+
"order",
|
|
890
|
+
createEntity({
|
|
891
|
+
table: "orders",
|
|
892
|
+
fields: { teamId: createTextField({ required: true }) },
|
|
893
|
+
access: {
|
|
894
|
+
read: { AnyRole: "all" },
|
|
895
|
+
},
|
|
896
|
+
}),
|
|
897
|
+
);
|
|
898
|
+
}),
|
|
899
|
+
];
|
|
900
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// --- MultiStreamProjection delivery invariant (Welle 2.7) ---
|
|
905
|
+
|
|
906
|
+
describe("MultiStreamProjection delivery", () => {
|
|
907
|
+
const sinkTable = pgTable("sink", { id: text("id").primaryKey() });
|
|
908
|
+
|
|
909
|
+
test("rejects delivery='per-instance' combined with a backing table", () => {
|
|
910
|
+
const features = [
|
|
911
|
+
defineFeature("sse", (r) => {
|
|
912
|
+
r.multiStreamProjection({
|
|
913
|
+
name: "broadcast",
|
|
914
|
+
table: sinkTable,
|
|
915
|
+
delivery: "per-instance",
|
|
916
|
+
apply: { "some:event": async () => {} },
|
|
917
|
+
});
|
|
918
|
+
}),
|
|
919
|
+
];
|
|
920
|
+
expect(() => validateBoot(features)).toThrow(
|
|
921
|
+
/per-instance.+table.+duplicate INSERTs|cursor divergence/i,
|
|
922
|
+
);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("accepts delivery='per-instance' without a table (side-effect-only)", () => {
|
|
926
|
+
const features = [
|
|
927
|
+
defineFeature("sse", (r) => {
|
|
928
|
+
r.multiStreamProjection({
|
|
929
|
+
name: "broadcast",
|
|
930
|
+
delivery: "per-instance",
|
|
931
|
+
apply: { "some:event": async () => {} },
|
|
932
|
+
});
|
|
933
|
+
}),
|
|
934
|
+
];
|
|
935
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test("accepts delivery='shared' with a table (default, materialized read-model)", () => {
|
|
939
|
+
const features = [
|
|
940
|
+
defineFeature("reports", (r) => {
|
|
941
|
+
r.multiStreamProjection({
|
|
942
|
+
name: "rollup",
|
|
943
|
+
table: sinkTable,
|
|
944
|
+
delivery: "shared",
|
|
945
|
+
apply: { "some:event": async () => {} },
|
|
946
|
+
});
|
|
947
|
+
}),
|
|
948
|
+
];
|
|
949
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// --- MultiSelect-Field-Validation ---
|
|
954
|
+
|
|
955
|
+
describe("multiSelect fields", () => {
|
|
956
|
+
test("accepts multiSelect with non-empty options", () => {
|
|
957
|
+
const features = [
|
|
958
|
+
defineFeature("driver", (r) => {
|
|
959
|
+
r.entity(
|
|
960
|
+
"profile",
|
|
961
|
+
createEntity({
|
|
962
|
+
fields: {
|
|
963
|
+
tags: createMultiSelectField({ options: ["a", "b", "c"] as const }),
|
|
964
|
+
},
|
|
965
|
+
}),
|
|
966
|
+
);
|
|
967
|
+
}),
|
|
968
|
+
];
|
|
969
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("rejects multiSelect with empty options", () => {
|
|
973
|
+
const features = [
|
|
974
|
+
defineFeature("driver", (r) => {
|
|
975
|
+
r.entity(
|
|
976
|
+
"profile",
|
|
977
|
+
createEntity({
|
|
978
|
+
fields: {
|
|
979
|
+
// Cast over the empty-array hole — the factory's generic
|
|
980
|
+
// `as const` widens to `readonly never[]` for `[]`, which
|
|
981
|
+
// is what we want to test against. The validator catches
|
|
982
|
+
// it at boot.
|
|
983
|
+
tags: createMultiSelectField({ options: [] as readonly string[] }),
|
|
984
|
+
},
|
|
985
|
+
}),
|
|
986
|
+
);
|
|
987
|
+
}),
|
|
988
|
+
];
|
|
989
|
+
expect(() => validateBoot(features)).toThrow(/empty options/);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
test("rejects default value not in options", () => {
|
|
993
|
+
const features = [
|
|
994
|
+
defineFeature("driver", (r) => {
|
|
995
|
+
r.entity(
|
|
996
|
+
"profile",
|
|
997
|
+
createEntity({
|
|
998
|
+
fields: {
|
|
999
|
+
tags: createMultiSelectField({
|
|
1000
|
+
options: ["a", "b"] as const,
|
|
1001
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing runtime guard
|
|
1002
|
+
default: ["c"] as any,
|
|
1003
|
+
}),
|
|
1004
|
+
},
|
|
1005
|
+
}),
|
|
1006
|
+
);
|
|
1007
|
+
}),
|
|
1008
|
+
];
|
|
1009
|
+
expect(() => validateBoot(features)).toThrow(/not a valid option/);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
test("accepts default that is a subset of options", () => {
|
|
1013
|
+
const features = [
|
|
1014
|
+
defineFeature("driver", (r) => {
|
|
1015
|
+
r.entity(
|
|
1016
|
+
"profile",
|
|
1017
|
+
createEntity({
|
|
1018
|
+
fields: {
|
|
1019
|
+
tags: createMultiSelectField({
|
|
1020
|
+
options: ["a", "b", "c"] as const,
|
|
1021
|
+
default: ["a", "c"],
|
|
1022
|
+
}),
|
|
1023
|
+
},
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
}),
|
|
1027
|
+
];
|
|
1028
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// --- entityList column-renderer form-check ---
|
|
1033
|
+
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1034
|
+
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
1035
|
+
// strukturell. String-Funktionen, null/undefined, native-only und
|
|
1036
|
+
// andere Formen bleiben opak.
|
|
1037
|
+
describe("entityList column renderer form", () => {
|
|
1038
|
+
function shopFeature(renderer: unknown) {
|
|
1039
|
+
return defineFeature("shop", (r) => {
|
|
1040
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1041
|
+
r.screen({
|
|
1042
|
+
id: "product-list",
|
|
1043
|
+
type: "entityList",
|
|
1044
|
+
entity: "product",
|
|
1045
|
+
// Renderer ist absichtlich unknown — die Validator-Tests pinnen
|
|
1046
|
+
// auch Formen die der TS-Compiler bei sauberer Hand-Schreibe
|
|
1047
|
+
// niemals zulassen würde (leerer __component, number etc.).
|
|
1048
|
+
// kumiko-lint-ignore as-cast renderer ist Test-Fixture für invalid forms
|
|
1049
|
+
columns: [{ field: "name", renderer: renderer as never }],
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
test("function-renderer → kein Throw (Bestand)", () => {
|
|
1055
|
+
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).not.toThrow();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
|
|
1059
|
+
expect(() => validateBoot([shopFeature(undefined)])).not.toThrow();
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("null renderer → kein Throw (skip)", () => {
|
|
1063
|
+
expect(() => validateBoot([shopFeature(null)])).not.toThrow();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test("object ohne react-Branch → kein Throw (z.B. native-only)", () => {
|
|
1067
|
+
expect(() => validateBoot([shopFeature({ native: { __component: "X" } })])).not.toThrow();
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test("react-Branch ist non-object → Throw mit klarer Message", () => {
|
|
1071
|
+
expect(() => validateBoot([shopFeature({ react: 42 })])).toThrow(/non-object `react` branch/);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
test("react-Branch ohne __component-Schlüssel → kein Throw (skip)", () => {
|
|
1075
|
+
// {} oder { __component: undefined } sind nicht unsere String-Key-Form
|
|
1076
|
+
expect(() => validateBoot([shopFeature({ react: {} })])).not.toThrow();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test("react.__component leerer String → Throw", () => {
|
|
1080
|
+
expect(() => validateBoot([shopFeature({ react: { __component: "" } })])).toThrow(
|
|
1081
|
+
/expected a non-empty string/,
|
|
1082
|
+
);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("react.__component non-String (number) → Throw", () => {
|
|
1086
|
+
expect(() => validateBoot([shopFeature({ react: { __component: 42 } })])).toThrow(
|
|
1087
|
+
/expected a non-empty string/,
|
|
1088
|
+
);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
test("react.__component nicht-leerer String → kein Throw (gültige Form)", () => {
|
|
1092
|
+
expect(() =>
|
|
1093
|
+
validateBoot([shopFeature({ react: { __component: "ColorSwatch" } })]),
|
|
1094
|
+
).not.toThrow();
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// --- entityList: pagination + sort validation ---
|
|
1099
|
+
// Author-Fehler vor Production fangen, damit "Screen lädt nichts /
|
|
1100
|
+
// sortiert falsch / crasht beim Pager-Klick" nicht erst zur Laufzeit
|
|
1101
|
+
// bemerkt wird. Die Tests pinnen nur server-side Validierungen —
|
|
1102
|
+
// UI-Verhalten (Pager-Rendering) ist Renderer-Sache.
|
|
1103
|
+
describe("entityList pagination + sort", () => {
|
|
1104
|
+
function makeFeature(
|
|
1105
|
+
override: Partial<{
|
|
1106
|
+
readonly pageSize: number;
|
|
1107
|
+
readonly defaultSort: { readonly field: string; readonly dir: "asc" | "desc" };
|
|
1108
|
+
}>,
|
|
1109
|
+
) {
|
|
1110
|
+
return defineFeature("shop", (r) => {
|
|
1111
|
+
r.entity(
|
|
1112
|
+
"product",
|
|
1113
|
+
createEntity({
|
|
1114
|
+
fields: {
|
|
1115
|
+
name: createTextField({ sortable: true }),
|
|
1116
|
+
// Bewusst NICHT sortable: bestätigt dass Validator das
|
|
1117
|
+
// unterscheidet und nur sortable-Felder als defaultSort
|
|
1118
|
+
// akzeptiert.
|
|
1119
|
+
description: createTextField(),
|
|
1120
|
+
},
|
|
1121
|
+
}),
|
|
1122
|
+
);
|
|
1123
|
+
r.screen({
|
|
1124
|
+
id: "product-list",
|
|
1125
|
+
type: "entityList",
|
|
1126
|
+
entity: "product",
|
|
1127
|
+
columns: [{ field: "name" }],
|
|
1128
|
+
...override,
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
test("pageSize: positiv → kein Throw", () => {
|
|
1134
|
+
expect(() => validateBoot([makeFeature({ pageSize: 100 })])).not.toThrow();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test("pageSize: 0 → Throw mit klarer Message", () => {
|
|
1138
|
+
expect(() => validateBoot([makeFeature({ pageSize: 0 })])).toThrow(
|
|
1139
|
+
/pageSize=0 — must be a positive integer/,
|
|
1140
|
+
);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
test("pageSize: negativ → Throw", () => {
|
|
1144
|
+
expect(() => validateBoot([makeFeature({ pageSize: -10 })])).toThrow(
|
|
1145
|
+
/pageSize=-10 — must be a positive integer/,
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("defaultSort.field: existiert + sortable=true → kein Throw", () => {
|
|
1150
|
+
expect(() =>
|
|
1151
|
+
validateBoot([makeFeature({ defaultSort: { field: "name", dir: "asc" } })]),
|
|
1152
|
+
).not.toThrow();
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test("defaultSort.field: existiert NICHT → Throw", () => {
|
|
1156
|
+
expect(() =>
|
|
1157
|
+
validateBoot([makeFeature({ defaultSort: { field: "ghost", dir: "asc" } })]),
|
|
1158
|
+
).toThrow(/defaultSort references unknown field "ghost"/);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
test("defaultSort.field: existiert aber sortable=false → Throw", () => {
|
|
1162
|
+
expect(() =>
|
|
1163
|
+
validateBoot([makeFeature({ defaultSort: { field: "description", dir: "asc" } })]),
|
|
1164
|
+
).toThrow(/defaultSort\.field "description" is not sortable/);
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// --- Tier 2.7c: Screen-Filter ---
|
|
1169
|
+
// Drei Layer Author-Code-Validation: field-existiert, filterable: true
|
|
1170
|
+
// gesetzt, op passt zum Field-Type. Boot-Fail ist deutlich besser als
|
|
1171
|
+
// silent-leerer Bucket / Drizzle-Crash zur Laufzeit.
|
|
1172
|
+
describe("entityList screen-filter (Tier 2.7c)", () => {
|
|
1173
|
+
function makeFeature(
|
|
1174
|
+
filter: {
|
|
1175
|
+
readonly field: string;
|
|
1176
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
1177
|
+
readonly value: unknown;
|
|
1178
|
+
},
|
|
1179
|
+
fields: Record<string, unknown> = {
|
|
1180
|
+
name: { type: "text", sortable: true, filterable: true },
|
|
1181
|
+
status: { type: "text", filterable: true },
|
|
1182
|
+
secret: { type: "text" },
|
|
1183
|
+
},
|
|
1184
|
+
) {
|
|
1185
|
+
return defineFeature("shop", (r) => {
|
|
1186
|
+
r.entity("product", createEntity({ fields: fields as never }));
|
|
1187
|
+
r.screen({
|
|
1188
|
+
id: "product-list",
|
|
1189
|
+
type: "entityList",
|
|
1190
|
+
entity: "product",
|
|
1191
|
+
columns: [{ field: "name" }],
|
|
1192
|
+
filter,
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
test("filter.field existiert + filterable → kein Throw", () => {
|
|
1198
|
+
expect(() =>
|
|
1199
|
+
validateBoot([makeFeature({ field: "status", op: "eq", value: "active" })]),
|
|
1200
|
+
).not.toThrow();
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
test("filter.field existiert NICHT → Throw mit klarer Message", () => {
|
|
1204
|
+
expect(() => validateBoot([makeFeature({ field: "ghost", op: "eq", value: "x" })])).toThrow(
|
|
1205
|
+
/filter references unknown field "ghost"/,
|
|
1206
|
+
);
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
test("filter.field existiert aber filterable=false → Throw", () => {
|
|
1210
|
+
expect(() => validateBoot([makeFeature({ field: "secret", op: "eq", value: "x" })])).toThrow(
|
|
1211
|
+
/filter references field "secret" which is not filterable/,
|
|
1212
|
+
);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test("filter.op=lt auf text-Feld → Throw (op-vs-Type-Compat)", () => {
|
|
1216
|
+
expect(() => validateBoot([makeFeature({ field: "status", op: "lt", value: "x" })])).toThrow(
|
|
1217
|
+
/filter\.op "lt" is not allowed on field "status" \(type "text"\)/,
|
|
1218
|
+
);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("filter.op=gt auf number-Feld → kein Throw (vergleichbar)", () => {
|
|
1222
|
+
expect(() =>
|
|
1223
|
+
validateBoot([
|
|
1224
|
+
makeFeature(
|
|
1225
|
+
{ field: "rank", op: "gt", value: 5 },
|
|
1226
|
+
{
|
|
1227
|
+
name: { type: "text", filterable: true },
|
|
1228
|
+
rank: { type: "number", filterable: true },
|
|
1229
|
+
},
|
|
1230
|
+
),
|
|
1231
|
+
]),
|
|
1232
|
+
).not.toThrow();
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test('filter.op="in" mit non-array value → Throw', () => {
|
|
1236
|
+
expect(() =>
|
|
1237
|
+
validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
|
|
1238
|
+
).toThrow(/filter\.op "in" requires filter\.value to be a readonly array/);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test('filter.op="in" mit array → kein Throw', () => {
|
|
1242
|
+
expect(() =>
|
|
1243
|
+
validateBoot([makeFeature({ field: "status", op: "in", value: ["active", "pending"] })]),
|
|
1244
|
+
).not.toThrow();
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test("filter.op=ne auf boolean → kein Throw, lt auf boolean → Throw", () => {
|
|
1248
|
+
const fields = {
|
|
1249
|
+
name: { type: "text", filterable: true },
|
|
1250
|
+
flag: { type: "boolean", filterable: true },
|
|
1251
|
+
};
|
|
1252
|
+
expect(() =>
|
|
1253
|
+
validateBoot([makeFeature({ field: "flag", op: "ne", value: true }, fields)]),
|
|
1254
|
+
).not.toThrow();
|
|
1255
|
+
expect(() =>
|
|
1256
|
+
validateBoot([makeFeature({ field: "flag", op: "lt", value: true }, fields)]),
|
|
1257
|
+
).toThrow(/filter\.op "lt" is not allowed on field "flag" \(type "boolean"\)/);
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// --- Tier 2.7d: actionForm-Screen ---
|
|
1262
|
+
// Non-CRUD Write-Handler-driven Form. Sechs Author-Code-Checks am
|
|
1263
|
+
// Boot: handler ist non-empty + registriert, fields non-empty +
|
|
1264
|
+
// jeder mit type, layout konsistent, redirect (wenn gesetzt) zeigt
|
|
1265
|
+
// auf einen registrierten Screen.
|
|
1266
|
+
describe("actionForm screen (Tier 2.7d)", () => {
|
|
1267
|
+
type ActionFormOverride = {
|
|
1268
|
+
readonly handler?: string | undefined;
|
|
1269
|
+
readonly fields?: Record<string, unknown>;
|
|
1270
|
+
readonly sections?: ReadonlyArray<{
|
|
1271
|
+
readonly title: string;
|
|
1272
|
+
readonly fields: readonly string[];
|
|
1273
|
+
}>;
|
|
1274
|
+
readonly redirect?: string;
|
|
1275
|
+
readonly extraScreens?: readonly string[];
|
|
1276
|
+
};
|
|
1277
|
+
|
|
1278
|
+
// Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
|
|
1279
|
+
// damit `r.writeHandler(defineEntityWriteHandler("invoice:approve",...))`
|
|
1280
|
+
// beim Boot ohne Custom-Code registriert werden kann. Plus optional
|
|
1281
|
+
// weitere Screens zum redirect-Test.
|
|
1282
|
+
function makeFeature(override: ActionFormOverride = {}) {
|
|
1283
|
+
const handler = override.handler ?? "shop:write:invoice:approve";
|
|
1284
|
+
const fields = override.fields ?? {
|
|
1285
|
+
note: { type: "text" },
|
|
1286
|
+
priority: { type: "number" },
|
|
1287
|
+
};
|
|
1288
|
+
const sections = override.sections ?? [{ title: "Approval", fields: ["note", "priority"] }];
|
|
1289
|
+
return defineFeature("shop", (r) => {
|
|
1290
|
+
// Registrierter Write-Handler den die actionForm referenzieren
|
|
1291
|
+
// kann. Nicht über defineEntityWriteHandler — das verlangt eine
|
|
1292
|
+
// existente Entity. Direkter Stub reicht für Boot-Validierung.
|
|
1293
|
+
r.writeHandler({
|
|
1294
|
+
name: "invoice:approve",
|
|
1295
|
+
schema: { _type: "stub" } as never,
|
|
1296
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
1297
|
+
access: { openToAll: true },
|
|
1298
|
+
});
|
|
1299
|
+
r.screen({
|
|
1300
|
+
id: "approve-invoice",
|
|
1301
|
+
type: "actionForm",
|
|
1302
|
+
handler,
|
|
1303
|
+
fields: fields as never,
|
|
1304
|
+
layout: { sections: sections as never },
|
|
1305
|
+
...(override.redirect !== undefined && { redirect: override.redirect }),
|
|
1306
|
+
});
|
|
1307
|
+
for (const extra of override.extraScreens ?? []) {
|
|
1308
|
+
r.screen({
|
|
1309
|
+
id: extra,
|
|
1310
|
+
type: "custom",
|
|
1311
|
+
renderer: { react: "stub" },
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
test("happy path: handler + fields + layout konsistent → kein Throw", () => {
|
|
1318
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
test("handler nicht als write-handler registriert → Throw mit Hinweis", () => {
|
|
1322
|
+
expect(() => validateBoot([makeFeature({ handler: "shop:query:invoice:list" })])).toThrow(
|
|
1323
|
+
/handler "shop:query:invoice:list" is not a registered write-handler/,
|
|
1324
|
+
);
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
test("handler leer → Throw", () => {
|
|
1328
|
+
expect(() => validateBoot([makeFeature({ handler: "" })])).toThrow(
|
|
1329
|
+
/has empty or non-string handler/,
|
|
1330
|
+
);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("fields empty-Map → Throw", () => {
|
|
1334
|
+
expect(() => validateBoot([makeFeature({ fields: {} })])).toThrow(
|
|
1335
|
+
/has empty fields map — declare at least one field/,
|
|
1336
|
+
);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
test("field ohne type-Discriminator → Throw", () => {
|
|
1340
|
+
expect(() => validateBoot([makeFeature({ fields: { note: { required: true } } })])).toThrow(
|
|
1341
|
+
/field "note" has no `type` set/,
|
|
1342
|
+
);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
test("layout.sections leer → Throw", () => {
|
|
1346
|
+
expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
|
|
1347
|
+
/has an empty sections list/,
|
|
1348
|
+
);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
test("section.fields leer → Throw", () => {
|
|
1352
|
+
expect(() =>
|
|
1353
|
+
validateBoot([makeFeature({ sections: [{ title: "Empty", fields: [] }] })]),
|
|
1354
|
+
).toThrow(/section "Empty" with zero fields/);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
test("layout referenziert unknown field → Throw", () => {
|
|
1358
|
+
expect(() =>
|
|
1359
|
+
validateBoot([makeFeature({ sections: [{ title: "x", fields: ["ghost"] }] })]),
|
|
1360
|
+
).toThrow(/layout references unknown field "ghost"/);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
1364
|
+
expect(() =>
|
|
1365
|
+
validateBoot([makeFeature({ redirect: "after-form", extraScreens: ["after-form"] })]),
|
|
1366
|
+
).not.toThrow();
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("redirect → unknown screen-id → Throw", () => {
|
|
1370
|
+
expect(() => validateBoot([makeFeature({ redirect: "ghost-screen" })])).toThrow(
|
|
1371
|
+
/redirect "ghost-screen" does not resolve to a registered screen/,
|
|
1372
|
+
);
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
// --- configEdit-Screen ---
|
|
1377
|
+
// Form gegen das bundled config-feature. Boot-Validator prüft:
|
|
1378
|
+
// 1) fields non-empty + jeder mit type-Discriminator
|
|
1379
|
+
// 2) layout konsistent (Sections non-empty, Field-Refs existieren)
|
|
1380
|
+
// 3) jedes Field hat einen Eintrag in configKeys
|
|
1381
|
+
// 4) jeder qualifizierte Config-Key in configKeys ist tatsächlich
|
|
1382
|
+
// via r.config(...) registriert
|
|
1383
|
+
describe("configEdit screen", () => {
|
|
1384
|
+
type ConfigEditOverride = {
|
|
1385
|
+
readonly fields?: Record<string, unknown>;
|
|
1386
|
+
readonly sections?: ReadonlyArray<{
|
|
1387
|
+
readonly title: string;
|
|
1388
|
+
readonly fields: readonly string[];
|
|
1389
|
+
}>;
|
|
1390
|
+
readonly configKeys?: Readonly<Record<string, string>>;
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
function makeFeature(override: ConfigEditOverride = {}) {
|
|
1394
|
+
const fields = override.fields ?? {
|
|
1395
|
+
siteName: { type: "text" },
|
|
1396
|
+
maxUploadMb: { type: "number" },
|
|
1397
|
+
};
|
|
1398
|
+
const sections = override.sections ?? [
|
|
1399
|
+
{ title: "Basics", fields: ["siteName", "maxUploadMb"] },
|
|
1400
|
+
];
|
|
1401
|
+
const configKeys = override.configKeys ?? {
|
|
1402
|
+
siteName: "shop:config:site-name",
|
|
1403
|
+
maxUploadMb: "shop:config:max-upload-mb",
|
|
1404
|
+
};
|
|
1405
|
+
return defineFeature("shop", (r) => {
|
|
1406
|
+
r.config({
|
|
1407
|
+
keys: {
|
|
1408
|
+
"site-name": createTenantConfig("text", { default: "" }),
|
|
1409
|
+
"max-upload-mb": createTenantConfig("number", { default: 10 }),
|
|
1410
|
+
},
|
|
1411
|
+
});
|
|
1412
|
+
r.screen({
|
|
1413
|
+
id: "settings",
|
|
1414
|
+
type: "configEdit",
|
|
1415
|
+
scope: "tenant",
|
|
1416
|
+
configKeys,
|
|
1417
|
+
fields: fields as never,
|
|
1418
|
+
layout: { sections: sections as never },
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
test("happy path: alle 4 Checks bestanden → kein Throw", () => {
|
|
1424
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("fields empty-Map → Throw", () => {
|
|
1428
|
+
expect(() => validateBoot([makeFeature({ fields: {} })])).toThrow(
|
|
1429
|
+
/has empty fields map — declare at least one field/,
|
|
1430
|
+
);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("field ohne type-Discriminator → Throw", () => {
|
|
1434
|
+
expect(() =>
|
|
1435
|
+
validateBoot([makeFeature({ fields: { siteName: { required: true } } })]),
|
|
1436
|
+
).toThrow(/field "siteName" has no `type` set/);
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
test("layout.sections leer → Throw", () => {
|
|
1440
|
+
expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
|
|
1441
|
+
/has an empty sections list/,
|
|
1442
|
+
);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("layout referenziert unknown field → Throw", () => {
|
|
1446
|
+
expect(() =>
|
|
1447
|
+
validateBoot([makeFeature({ sections: [{ title: "x", fields: ["ghost"] }] })]),
|
|
1448
|
+
).toThrow(/layout references unknown field "ghost"/);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("Field ohne configKeys-Eintrag → Throw mit Hinweis auf Mapping", () => {
|
|
1452
|
+
// siteName ist im fields-Map, aber configKeys mappt es nicht.
|
|
1453
|
+
// Boot soll fehlschlagen weil zur Laufzeit kein Wert geladen
|
|
1454
|
+
// werden könnte.
|
|
1455
|
+
expect(() =>
|
|
1456
|
+
validateBoot([
|
|
1457
|
+
makeFeature({
|
|
1458
|
+
configKeys: { maxUploadMb: "shop:config:max-upload-mb" },
|
|
1459
|
+
}),
|
|
1460
|
+
]),
|
|
1461
|
+
).toThrow(/field "siteName" hat keinen Eintrag in configKeys-Map/);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
test("configKeys referenziert unbekannten qualifizierten Key → Throw", () => {
|
|
1465
|
+
expect(() =>
|
|
1466
|
+
validateBoot([
|
|
1467
|
+
makeFeature({
|
|
1468
|
+
configKeys: {
|
|
1469
|
+
siteName: "shop:config:typo-here",
|
|
1470
|
+
maxUploadMb: "shop:config:max-upload-mb",
|
|
1471
|
+
},
|
|
1472
|
+
}),
|
|
1473
|
+
]),
|
|
1474
|
+
).toThrow(/Config-Key "shop:config:typo-here" ist in keiner Feature-Registry deklariert/);
|
|
1475
|
+
});
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
1479
|
+
describe("reference field (Tier 2.7e-3)", () => {
|
|
1480
|
+
// Helper: registriert einen Stub-Query-Handler `<entity>:list`
|
|
1481
|
+
// damit der Boot-Validator den Audit-Fix-#2-Check (Handler-
|
|
1482
|
+
// Existenz auf der target-Entity) durch lässt.
|
|
1483
|
+
function stubListHandler(
|
|
1484
|
+
// biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
|
|
1485
|
+
r: any,
|
|
1486
|
+
entityName: string,
|
|
1487
|
+
): void {
|
|
1488
|
+
r.queryHandler({
|
|
1489
|
+
name: `${entityName}:list`,
|
|
1490
|
+
schema: z.object({}),
|
|
1491
|
+
handler: async () => ({ rows: [], nextCursor: null }) as never,
|
|
1492
|
+
access: { openToAll: true },
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
test("reference auf bestehende Entity → kein Throw", () => {
|
|
1497
|
+
const features = [
|
|
1498
|
+
defineFeature("shop", (r) => {
|
|
1499
|
+
r.entity("customer", createEntity({ fields: { name: createTextField() } }));
|
|
1500
|
+
stubListHandler(r, "customer");
|
|
1501
|
+
r.entity(
|
|
1502
|
+
"order",
|
|
1503
|
+
createEntity({
|
|
1504
|
+
fields: {
|
|
1505
|
+
customerId: { type: "reference", entity: "customer", labelField: "name" },
|
|
1506
|
+
},
|
|
1507
|
+
}),
|
|
1508
|
+
);
|
|
1509
|
+
}),
|
|
1510
|
+
];
|
|
1511
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
test("reference auf unknown Entity → Throw", () => {
|
|
1515
|
+
const features = [
|
|
1516
|
+
defineFeature("shop", (r) => {
|
|
1517
|
+
r.entity(
|
|
1518
|
+
"order",
|
|
1519
|
+
createEntity({
|
|
1520
|
+
fields: {
|
|
1521
|
+
customerId: { type: "reference", entity: "ghost-entity" },
|
|
1522
|
+
},
|
|
1523
|
+
}),
|
|
1524
|
+
);
|
|
1525
|
+
}),
|
|
1526
|
+
];
|
|
1527
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1528
|
+
/Reference field "customerId" on entity "order" targets unknown entity "ghost-entity"/,
|
|
1529
|
+
);
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
test("reference labelField auf unknown Field → Throw", () => {
|
|
1533
|
+
const features = [
|
|
1534
|
+
defineFeature("shop", (r) => {
|
|
1535
|
+
r.entity("customer", createEntity({ fields: { name: createTextField() } }));
|
|
1536
|
+
r.entity(
|
|
1537
|
+
"order",
|
|
1538
|
+
createEntity({
|
|
1539
|
+
fields: {
|
|
1540
|
+
customerId: { type: "reference", entity: "customer", labelField: "ghost-field" },
|
|
1541
|
+
},
|
|
1542
|
+
}),
|
|
1543
|
+
);
|
|
1544
|
+
}),
|
|
1545
|
+
];
|
|
1546
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1547
|
+
/references labelField "ghost-field" which does not exist on entity "customer"/,
|
|
1548
|
+
);
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test("reference labelField=id ist immer ok (PK)", () => {
|
|
1552
|
+
const features = [
|
|
1553
|
+
defineFeature("shop", (r) => {
|
|
1554
|
+
r.entity("customer", createEntity({ fields: { name: createTextField() } }));
|
|
1555
|
+
stubListHandler(r, "customer");
|
|
1556
|
+
r.entity(
|
|
1557
|
+
"order",
|
|
1558
|
+
createEntity({
|
|
1559
|
+
fields: {
|
|
1560
|
+
customerId: { type: "reference", entity: "customer", labelField: "id" },
|
|
1561
|
+
},
|
|
1562
|
+
}),
|
|
1563
|
+
);
|
|
1564
|
+
}),
|
|
1565
|
+
];
|
|
1566
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test("self-reference (entity → entity) → kein Throw", () => {
|
|
1570
|
+
const features = [
|
|
1571
|
+
defineFeature("shop", (r) => {
|
|
1572
|
+
r.entity(
|
|
1573
|
+
"category",
|
|
1574
|
+
createEntity({
|
|
1575
|
+
fields: {
|
|
1576
|
+
name: createTextField(),
|
|
1577
|
+
parentId: { type: "reference", entity: "category", labelField: "name" },
|
|
1578
|
+
},
|
|
1579
|
+
}),
|
|
1580
|
+
);
|
|
1581
|
+
stubListHandler(r, "category");
|
|
1582
|
+
}),
|
|
1583
|
+
];
|
|
1584
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test("reference mit multiple: true → kein Throw (Tier 2.7e-Multi)", () => {
|
|
1588
|
+
const features = [
|
|
1589
|
+
defineFeature("shop", (r) => {
|
|
1590
|
+
r.entity("tag", createEntity({ fields: { name: createTextField() } }));
|
|
1591
|
+
stubListHandler(r, "tag");
|
|
1592
|
+
r.entity(
|
|
1593
|
+
"post",
|
|
1594
|
+
createEntity({
|
|
1595
|
+
fields: {
|
|
1596
|
+
title: createTextField(),
|
|
1597
|
+
tagIds: {
|
|
1598
|
+
type: "reference",
|
|
1599
|
+
entity: "tag",
|
|
1600
|
+
labelField: "name",
|
|
1601
|
+
multiple: true,
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
}),
|
|
1605
|
+
);
|
|
1606
|
+
}),
|
|
1607
|
+
];
|
|
1608
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// --- Tier 2.7e Cross-Feature: "feature:entity"-Form ---
|
|
1612
|
+
test("cross-feature reference (feature:entity) → kein Throw", () => {
|
|
1613
|
+
const features = [
|
|
1614
|
+
defineFeature("users", (r) => {
|
|
1615
|
+
r.entity("user", createEntity({ fields: { email: createTextField() } }));
|
|
1616
|
+
stubListHandler(r, "user");
|
|
1617
|
+
}),
|
|
1618
|
+
defineFeature("shop", (r) => {
|
|
1619
|
+
r.entity(
|
|
1620
|
+
"order",
|
|
1621
|
+
createEntity({
|
|
1622
|
+
fields: {
|
|
1623
|
+
customerId: { type: "reference", entity: "users:user", labelField: "email" },
|
|
1624
|
+
},
|
|
1625
|
+
}),
|
|
1626
|
+
);
|
|
1627
|
+
}),
|
|
1628
|
+
];
|
|
1629
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
test("Audit-Fix #2: cross-feature reference ohne list-handler → Throw", () => {
|
|
1633
|
+
const features = [
|
|
1634
|
+
defineFeature("users", (r) => {
|
|
1635
|
+
r.entity("user", createEntity({ fields: { email: createTextField() } }));
|
|
1636
|
+
// KEINE stubListHandler — das ist der Punkt des Tests
|
|
1637
|
+
}),
|
|
1638
|
+
defineFeature("shop", (r) => {
|
|
1639
|
+
r.entity(
|
|
1640
|
+
"order",
|
|
1641
|
+
createEntity({
|
|
1642
|
+
fields: {
|
|
1643
|
+
customerId: { type: "reference", entity: "users:user" },
|
|
1644
|
+
},
|
|
1645
|
+
}),
|
|
1646
|
+
);
|
|
1647
|
+
}),
|
|
1648
|
+
];
|
|
1649
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1650
|
+
/no list-query-handler is registered there\. Add r\.queryHandler\(defineEntityListHandler\("user"/,
|
|
1651
|
+
);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
test("cross-feature reference auf unknown feature → Throw mit klarer Message", () => {
|
|
1655
|
+
const features = [
|
|
1656
|
+
defineFeature("shop", (r) => {
|
|
1657
|
+
r.entity(
|
|
1658
|
+
"order",
|
|
1659
|
+
createEntity({
|
|
1660
|
+
fields: {
|
|
1661
|
+
customerId: { type: "reference", entity: "ghost-feature:user" },
|
|
1662
|
+
},
|
|
1663
|
+
}),
|
|
1664
|
+
);
|
|
1665
|
+
}),
|
|
1666
|
+
];
|
|
1667
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1668
|
+
/targets unknown feature "ghost-feature" via "ghost-feature:user"/,
|
|
1669
|
+
);
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
test("cross-feature reference auf unknown entity → Throw mit feature-context", () => {
|
|
1673
|
+
const features = [
|
|
1674
|
+
defineFeature("users", (r) => {
|
|
1675
|
+
r.entity("user", createEntity({ fields: { email: createTextField() } }));
|
|
1676
|
+
}),
|
|
1677
|
+
defineFeature("shop", (r) => {
|
|
1678
|
+
r.entity(
|
|
1679
|
+
"order",
|
|
1680
|
+
createEntity({
|
|
1681
|
+
fields: {
|
|
1682
|
+
customerId: { type: "reference", entity: "users:ghost-entity" },
|
|
1683
|
+
},
|
|
1684
|
+
}),
|
|
1685
|
+
);
|
|
1686
|
+
}),
|
|
1687
|
+
];
|
|
1688
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1689
|
+
/targets unknown entity "ghost-entity" in feature "users"/,
|
|
1690
|
+
);
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("cross-feature labelField auf unknown Field → Throw", () => {
|
|
1694
|
+
const features = [
|
|
1695
|
+
defineFeature("users", (r) => {
|
|
1696
|
+
r.entity("user", createEntity({ fields: { email: createTextField() } }));
|
|
1697
|
+
}),
|
|
1698
|
+
defineFeature("shop", (r) => {
|
|
1699
|
+
r.entity(
|
|
1700
|
+
"order",
|
|
1701
|
+
createEntity({
|
|
1702
|
+
fields: {
|
|
1703
|
+
customerId: {
|
|
1704
|
+
type: "reference",
|
|
1705
|
+
entity: "users:user",
|
|
1706
|
+
labelField: "ghost-field",
|
|
1707
|
+
},
|
|
1708
|
+
},
|
|
1709
|
+
}),
|
|
1710
|
+
);
|
|
1711
|
+
}),
|
|
1712
|
+
];
|
|
1713
|
+
expect(() => validateBoot(features)).toThrow(
|
|
1714
|
+
/references labelField "ghost-field" which does not exist on entity "user"/,
|
|
1715
|
+
);
|
|
1716
|
+
});
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
// --- Tier 2.7e-1: rowAction kind="navigate" target-existenz ---
|
|
1720
|
+
describe("entityList rowAction kind=navigate (Tier 2.7e-1)", () => {
|
|
1721
|
+
function makeFeature(targetScreen: string, withTarget: boolean) {
|
|
1722
|
+
return defineFeature("shop", (r) => {
|
|
1723
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1724
|
+
r.screen({
|
|
1725
|
+
id: "product-list",
|
|
1726
|
+
type: "entityList",
|
|
1727
|
+
entity: "product",
|
|
1728
|
+
columns: ["name"],
|
|
1729
|
+
rowActions: [
|
|
1730
|
+
{
|
|
1731
|
+
kind: "navigate",
|
|
1732
|
+
id: "edit",
|
|
1733
|
+
label: "actions.edit",
|
|
1734
|
+
screen: targetScreen,
|
|
1735
|
+
},
|
|
1736
|
+
],
|
|
1737
|
+
});
|
|
1738
|
+
if (withTarget) {
|
|
1739
|
+
r.screen({
|
|
1740
|
+
id: targetScreen,
|
|
1741
|
+
type: "custom",
|
|
1742
|
+
renderer: { react: "stub" },
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
test("navigate-target → registered screen → kein Throw", () => {
|
|
1749
|
+
expect(() => validateBoot([makeFeature("product-edit", true)])).not.toThrow();
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
test("navigate-target → unknown screen → Throw mit klarer Message", () => {
|
|
1753
|
+
expect(() => validateBoot([makeFeature("ghost-screen", false)])).toThrow(
|
|
1754
|
+
/rowAction "edit" navigate-target "ghost-screen" does not resolve/,
|
|
1755
|
+
);
|
|
1756
|
+
});
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
// --- defaultSort funktioniert für ALLE Field-Types die sortable
|
|
1760
|
+
// unterstützen (Tier 2.6b Field-Erweiterung) ---
|
|
1761
|
+
// Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
|
|
1762
|
+
// Number/Money/Date/Timestamp/Boolean/Select/LocatedTimestamp; der
|
|
1763
|
+
// Validator erkennt das via "sortable" in fieldDef. Diese Tests pinnen
|
|
1764
|
+
// dass der per-Field-Type-Roundtrip wirklich greift.
|
|
1765
|
+
describe("entityList defaultSort: alle sortable-Field-Types", () => {
|
|
1766
|
+
function buildFeature(fieldName: string, fields: Record<string, unknown>) {
|
|
1767
|
+
return defineFeature("shop", (r) => {
|
|
1768
|
+
r.entity("product", createEntity({ fields: fields as never }));
|
|
1769
|
+
r.screen({
|
|
1770
|
+
id: "product-list",
|
|
1771
|
+
type: "entityList",
|
|
1772
|
+
entity: "product",
|
|
1773
|
+
columns: [{ field: fieldName }],
|
|
1774
|
+
defaultSort: { field: fieldName, dir: "asc" },
|
|
1775
|
+
});
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
test("number-Field mit sortable: true → kein Throw", () => {
|
|
1780
|
+
expect(() =>
|
|
1781
|
+
validateBoot([buildFeature("rank", { rank: { type: "number", sortable: true } })]),
|
|
1782
|
+
).not.toThrow();
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
test("money-Field mit sortable: true → kein Throw", () => {
|
|
1786
|
+
expect(() =>
|
|
1787
|
+
validateBoot([buildFeature("price", { price: { type: "money", sortable: true } })]),
|
|
1788
|
+
).not.toThrow();
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
test("date-Field mit sortable: true → kein Throw", () => {
|
|
1792
|
+
expect(() =>
|
|
1793
|
+
validateBoot([buildFeature("dueDate", { dueDate: { type: "date", sortable: true } })]),
|
|
1794
|
+
).not.toThrow();
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
test("timestamp-Field mit sortable: true → kein Throw", () => {
|
|
1798
|
+
expect(() =>
|
|
1799
|
+
validateBoot([
|
|
1800
|
+
buildFeature("createdAt", { createdAt: { type: "timestamp", sortable: true } }),
|
|
1801
|
+
]),
|
|
1802
|
+
).not.toThrow();
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
test("boolean-Field mit sortable: true → kein Throw", () => {
|
|
1806
|
+
expect(() =>
|
|
1807
|
+
validateBoot([buildFeature("isActive", { isActive: { type: "boolean", sortable: true } })]),
|
|
1808
|
+
).not.toThrow();
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
test("select-Field mit sortable: true → kein Throw", () => {
|
|
1812
|
+
expect(() =>
|
|
1813
|
+
validateBoot([
|
|
1814
|
+
buildFeature("status", {
|
|
1815
|
+
status: { type: "select", options: ["a", "b"], sortable: true },
|
|
1816
|
+
}),
|
|
1817
|
+
]),
|
|
1818
|
+
).not.toThrow();
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
test("locatedTimestamp-Field mit sortable: true → kein Throw", () => {
|
|
1822
|
+
expect(() =>
|
|
1823
|
+
validateBoot([
|
|
1824
|
+
buildFeature("pickup", { pickup: { type: "locatedTimestamp", sortable: true } }),
|
|
1825
|
+
]),
|
|
1826
|
+
).not.toThrow();
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
test("number-Field OHNE sortable → Throw (sortable: true ist Pflicht)", () => {
|
|
1830
|
+
expect(() => validateBoot([buildFeature("rank", { rank: { type: "number" } })])).toThrow(
|
|
1831
|
+
/is not sortable/,
|
|
1832
|
+
);
|
|
1833
|
+
});
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
// --- screen.id ohne Punkt ---
|
|
1837
|
+
// Renderer nutzt screen.id als URL-Param-Namespace (`<id>.sort=…`).
|
|
1838
|
+
// defineFeature() rejected screen-ids mit '.' bereits über den
|
|
1839
|
+
// kebab-case-Check (define-feature.ts) — bevor der Boot-Validator
|
|
1840
|
+
// dran kommt. Wir pinnen hier nur dass der Reject am Author-API-
|
|
1841
|
+
// Eingangstor passiert, mit klarer Message.
|
|
1842
|
+
describe("screen.id constraints", () => {
|
|
1843
|
+
test('screen.id mit "." → defineFeature throws (kebab-case)', () => {
|
|
1844
|
+
expect(() =>
|
|
1845
|
+
defineFeature("shop", (r) => {
|
|
1846
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1847
|
+
r.screen({
|
|
1848
|
+
id: "product.list",
|
|
1849
|
+
type: "entityList",
|
|
1850
|
+
entity: "product",
|
|
1851
|
+
columns: ["name"],
|
|
1852
|
+
});
|
|
1853
|
+
}),
|
|
1854
|
+
).toThrow(/kebab-case/);
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
test("screen.id im kebab-case → kein Throw", () => {
|
|
1858
|
+
const feature = defineFeature("shop", (r) => {
|
|
1859
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
1860
|
+
r.screen({ id: "product-list", type: "entityList", entity: "product", columns: ["name"] });
|
|
1861
|
+
});
|
|
1862
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
});
|