@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,357 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { AuthRoutesConfig } from "../api/auth-routes";
|
|
3
|
+
import type { JwtHelper } from "../api/jwt";
|
|
4
|
+
import { buildServer } from "../api/server";
|
|
5
|
+
import { createSseBroker } from "../api/sse-broker";
|
|
6
|
+
import type { DbConnection } from "../db/connection";
|
|
7
|
+
import { createRegistry } from "../engine/registry";
|
|
8
|
+
import type { FeatureDefinition, Registry, TenantId } from "../engine/types";
|
|
9
|
+
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
10
|
+
import type { Lifecycle } from "../lifecycle";
|
|
11
|
+
import type { ObservabilityProvider } from "../observability";
|
|
12
|
+
import type { EventDispatcher } from "../pipeline";
|
|
13
|
+
import { createEntityCache, createEventDedup, createIdempotencyGuard } from "../pipeline";
|
|
14
|
+
import { createInMemorySearchAdapter } from "../search";
|
|
15
|
+
import type { SearchAdapter } from "../search/types";
|
|
16
|
+
import { createTestDb } from "./db";
|
|
17
|
+
import { createEventCollector, type EventCollector } from "./event-collector";
|
|
18
|
+
import { createTestRedis, type TestRedis } from "./redis";
|
|
19
|
+
import { createRequestHelper, type RequestHelper } from "./request-helper";
|
|
20
|
+
import { pushTables } from "./table-helpers";
|
|
21
|
+
|
|
22
|
+
export type TestStack = {
|
|
23
|
+
app: Hono;
|
|
24
|
+
jwt: JwtHelper;
|
|
25
|
+
registry: Registry;
|
|
26
|
+
/** Drizzle connection — the test DB's lifecycle (name, raw pg client,
|
|
27
|
+
* drop) lives inside setupTestStack and is released via stack.cleanup(). */
|
|
28
|
+
db: DbConnection;
|
|
29
|
+
redis: TestRedis;
|
|
30
|
+
search: SearchAdapter;
|
|
31
|
+
events: EventCollector;
|
|
32
|
+
http: RequestHelper;
|
|
33
|
+
observability: ObservabilityProvider;
|
|
34
|
+
// Present whenever a system consumer (SSE, Search) or
|
|
35
|
+
// r.multiStreamProjection is wired. Tests drain it via runOnce() for
|
|
36
|
+
// deterministic assertion — no timer-induced flakiness.
|
|
37
|
+
eventDispatcher?: EventDispatcher;
|
|
38
|
+
// Only set when the caller passed `lifecycle` via options. Tests that
|
|
39
|
+
// exercise drain() / /health/ready wire one in; ordinary suites ignore it.
|
|
40
|
+
lifecycle?: Lifecycle;
|
|
41
|
+
cleanup: () => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type TestStackOptions = {
|
|
45
|
+
features: readonly FeatureDefinition[];
|
|
46
|
+
/** System hooks to wire up. Default: all (sse, search) */
|
|
47
|
+
systemHooks?: ("sse" | "search")[];
|
|
48
|
+
/** Search config per tenant — defaults to tenant 1 with all text fields */
|
|
49
|
+
searchConfig?: {
|
|
50
|
+
tenantId: TenantId;
|
|
51
|
+
searchableFields: string[];
|
|
52
|
+
rankingFields: string[];
|
|
53
|
+
};
|
|
54
|
+
jwtSecret?: string;
|
|
55
|
+
/** Extra fields merged into the AppContext (e.g. _notifyFactory, configResolver).
|
|
56
|
+
* Can be a function receiving (registry, db, sseBroker) for late binding. */
|
|
57
|
+
extraContext?:
|
|
58
|
+
| Record<string, unknown>
|
|
59
|
+
| ((deps: {
|
|
60
|
+
registry: Registry;
|
|
61
|
+
db: import("../db/connection").DbConnection;
|
|
62
|
+
sseBroker: import("../api/sse-broker").SseBroker;
|
|
63
|
+
redis: import("ioredis").default;
|
|
64
|
+
}) => Record<string, unknown>);
|
|
65
|
+
/** Wire up auth routes (login, tenant-switch). Leave undefined to skip. */
|
|
66
|
+
authConfig?: AuthRoutesConfig;
|
|
67
|
+
/** Register a file storage provider so uploads via POST /api/files work and
|
|
68
|
+
* `ctx.files.ref(key)` is available to hooks/MSPs. Omit to skip — tests
|
|
69
|
+
* without file handling don't need it. */
|
|
70
|
+
files?: { storageProvider: import("../files").FileStorageProvider };
|
|
71
|
+
/** Observability provider — omit for NoopProvider (no spans/metrics).
|
|
72
|
+
* Pass a ConsoleProvider to see the span tree in stdout, or a custom
|
|
73
|
+
* provider (e.g. a recording provider for assertions in tests). */
|
|
74
|
+
observability?: ObservabilityProvider;
|
|
75
|
+
/** Inject a process lifecycle so tests can drain() and observe
|
|
76
|
+
* /health/ready flipping to 503. Omit if the suite doesn't care. */
|
|
77
|
+
lifecycle?: Lifecycle;
|
|
78
|
+
/** Wire L1 (global-IP) and/or L2 (auth-endpoint) rate-limit middleware.
|
|
79
|
+
* The resolver is auto-built from the test Redis. Mirrors
|
|
80
|
+
* buildServer's `rateLimit` option 1:1 — see there for shape. */
|
|
81
|
+
rateLimit?: import("../api/server").ServerOptions["rateLimit"];
|
|
82
|
+
/** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
|
|
83
|
+
* AppContext — set/delete/get + rotation job pick it up. Omit for
|
|
84
|
+
* suites that don't touch secrets. */
|
|
85
|
+
masterKeyProvider?: import("../secrets").MasterKeyProvider;
|
|
86
|
+
/** Feature-toggle resolver. When present the dispatcher's feature-gate,
|
|
87
|
+
* hook-filter, and MSP-filter all consult it; absent = every feature
|
|
88
|
+
* treated as always-on. Pass the callback from
|
|
89
|
+
* GlobalFeatureToggleRuntime.effectiveFeatures for real DB-backed
|
|
90
|
+
* toggles, or a plain `() => new Set<string>(registry.features.keys())`
|
|
91
|
+
* to force a specific snapshot in a unit-style setup. */
|
|
92
|
+
effectiveFeatures?: () => ReadonlySet<string>;
|
|
93
|
+
/** Pin the underlying Postgres DB name instead of the default
|
|
94
|
+
* `kumiko_test_<8chars>`. Forwarded to createTestDb. Primary use
|
|
95
|
+
* case: dev servers that want persistent storage across restarts —
|
|
96
|
+
* combine with `persistentDb: true`. */
|
|
97
|
+
dbName?: string;
|
|
98
|
+
/** When true, cleanup() keeps the Postgres DB around — the caller
|
|
99
|
+
* owns its lifecycle. Default false (test contract). Used by
|
|
100
|
+
* dev-server wiring to survive hot-reloads. */
|
|
101
|
+
persistentDb?: boolean;
|
|
102
|
+
/** Forwarded to buildServer — when set, requests without a JWT pass
|
|
103
|
+
* through as anonymous instead of 401. See AnonymousAccessConfig.
|
|
104
|
+
* Akzeptiert entweder einen statischen Config-Object ODER eine Factory
|
|
105
|
+
* `({registry, db, sseBroker, redis}) => Config` — gleiches Pattern wie
|
|
106
|
+
* `extraContext`. Die Factory wird einmal beim Boot aufgerufen, der
|
|
107
|
+
* TenantResolver darin closure'd typischerweise `db` für Subdomain-
|
|
108
|
+
* Lookups. */
|
|
109
|
+
anonymousAccess?:
|
|
110
|
+
| import("../api/server").ServerOptions["anonymousAccess"]
|
|
111
|
+
| ((deps: {
|
|
112
|
+
registry: Registry;
|
|
113
|
+
db: import("../db/connection").DbConnection;
|
|
114
|
+
sseBroker: import("../api/sse-broker").SseBroker;
|
|
115
|
+
redis: import("ioredis").default;
|
|
116
|
+
}) => import("../api/server").ServerOptions["anonymousAccess"]);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
|
|
120
|
+
|
|
121
|
+
export async function setupTestStack(options: TestStackOptions): Promise<TestStack> {
|
|
122
|
+
const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
|
|
123
|
+
const enabledHooks = options.systemHooks ?? ["sse", "search"];
|
|
124
|
+
|
|
125
|
+
// Temporal-Polyfill installieren bevor Feature-Code läuft. Idempotent —
|
|
126
|
+
// Production-Server-Boot ruft das gleich. Auf Runtimes mit nativem
|
|
127
|
+
// Temporal ein No-Op.
|
|
128
|
+
const { ensureTemporalPolyfill } = await import("../time/polyfill");
|
|
129
|
+
await ensureTemporalPolyfill();
|
|
130
|
+
|
|
131
|
+
// Forward db-name/persistent-flag through to createTestDb. The
|
|
132
|
+
// defaults (undefined dbName, persistent:false) keep the legacy
|
|
133
|
+
// test contract: fresh kumiko_test_<random> DB per setup, dropped
|
|
134
|
+
// on cleanup.
|
|
135
|
+
const [testDb, testRedis] = await Promise.all([
|
|
136
|
+
createTestDb({
|
|
137
|
+
...(options.dbName !== undefined && { dbName: options.dbName }),
|
|
138
|
+
...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
|
|
139
|
+
}),
|
|
140
|
+
createTestRedis(),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
// Every ES-entity writes events via createEventStoreExecutor in the
|
|
144
|
+
// feature's write handlers. Auto-create the events table so every
|
|
145
|
+
// setupTestStack call is ready for writes without needing a manual
|
|
146
|
+
// createEventsTable().
|
|
147
|
+
await createEventsTable(testDb.db);
|
|
148
|
+
// Archive-stream metadata — needed by ctx.appendEvent's archive guard and
|
|
149
|
+
// loadAggregate's default-skip. Idempotent, so production boot running
|
|
150
|
+
// the same call is fine.
|
|
151
|
+
await createArchivedStreamsTable(testDb.db);
|
|
152
|
+
|
|
153
|
+
// Framework state for projection rebuild/status + event-consumer cursors.
|
|
154
|
+
// Idempotent — production boot flows run the same calls.
|
|
155
|
+
const { createProjectionStateTable, createEventConsumerStateTable } = await import("../pipeline");
|
|
156
|
+
await createProjectionStateTable(testDb.db);
|
|
157
|
+
await createEventConsumerStateTable(testDb.db);
|
|
158
|
+
|
|
159
|
+
// Files support: when a provider is registered, the fileRefs table must
|
|
160
|
+
// exist before the first upload. Skipped when no provider — the table
|
|
161
|
+
// stays off tenant test DBs that never touch files.
|
|
162
|
+
if (options.files) {
|
|
163
|
+
const { fileRefsTable } = await import("../files");
|
|
164
|
+
await pushTables(testDb.db, { fileRefsTable });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Projection tables: the executor writes into them in the same TX as the
|
|
168
|
+
// event-append, so they have to exist before the first write. Auto-push
|
|
169
|
+
// everything registered via r.projection() — keeps tests from having to
|
|
170
|
+
// know which projections a feature happens to declare. Two projections
|
|
171
|
+
// backed by the same physical table (e.g. an alternative apply-shape for
|
|
172
|
+
// the same read-model in a test feature) are deduped by Drizzle-table
|
|
173
|
+
// reference so drizzle-kit doesn't emit duplicate CREATE TABLE statements.
|
|
174
|
+
const projectionTables: Record<string, unknown> = {};
|
|
175
|
+
const seenTables = new Set<unknown>();
|
|
176
|
+
for (const feature of options.features) {
|
|
177
|
+
for (const [projName, proj] of Object.entries(feature.projections)) {
|
|
178
|
+
if (seenTables.has(proj.table)) continue;
|
|
179
|
+
seenTables.add(proj.table);
|
|
180
|
+
projectionTables[projName] = proj.table;
|
|
181
|
+
}
|
|
182
|
+
// Multi-stream projection tables follow the same auto-push rule — the
|
|
183
|
+
// async dispatcher writes to them as soon as the first matching event
|
|
184
|
+
// flows through, so the DDL must exist before setupTestStack returns.
|
|
185
|
+
// skip: MSPs without a table are pure side-effect consumers.
|
|
186
|
+
for (const [mspName, msp] of Object.entries(feature.multiStreamProjections)) {
|
|
187
|
+
if (!msp.table) continue;
|
|
188
|
+
if (seenTables.has(msp.table)) continue;
|
|
189
|
+
seenTables.add(msp.table);
|
|
190
|
+
projectionTables[`msp_${mspName}`] = msp.table;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (Object.keys(projectionTables).length > 0) {
|
|
194
|
+
// pushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
|
|
195
|
+
// collides on re-boot against a persistent DB whose projection tables
|
|
196
|
+
// were created during a previous run. Filter out the ones that already
|
|
197
|
+
// exist; drizzle-kit's diff machinery would otherwise emit CREATE for
|
|
198
|
+
// them again.
|
|
199
|
+
const { tableExists } = await import("../db/schema-inspection");
|
|
200
|
+
const { getTableName } = await import("drizzle-orm");
|
|
201
|
+
const missing: Record<string, unknown> = {};
|
|
202
|
+
for (const [key, tbl] of Object.entries(projectionTables)) {
|
|
203
|
+
const physical = getTableName(tbl as Parameters<typeof getTableName>[0]);
|
|
204
|
+
if (await tableExists(testDb.db, `public.${physical}`)) continue;
|
|
205
|
+
missing[key] = tbl;
|
|
206
|
+
}
|
|
207
|
+
if (Object.keys(missing).length > 0) {
|
|
208
|
+
await pushTables(testDb.db, missing);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
213
|
+
const events = createEventCollector();
|
|
214
|
+
const registry = createRegistry([...options.features]);
|
|
215
|
+
|
|
216
|
+
// Auto-configure search for tenant 1 based on registry
|
|
217
|
+
if (enabledHooks.includes("search")) {
|
|
218
|
+
const searchableFields: string[] = [];
|
|
219
|
+
for (const feature of options.features) {
|
|
220
|
+
for (const [, entity] of Object.entries(feature.entities)) {
|
|
221
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
222
|
+
if (field.type === "text" && field.searchable) {
|
|
223
|
+
searchableFields.push(fieldName);
|
|
224
|
+
}
|
|
225
|
+
if (field.type === "embedded") {
|
|
226
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
227
|
+
if (subField.searchable) {
|
|
228
|
+
searchableFields.push(`${fieldName}_${subName}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.searchConfig) {
|
|
237
|
+
await searchAdapter.configure(options.searchConfig.tenantId, {
|
|
238
|
+
searchableFields: options.searchConfig.searchableFields,
|
|
239
|
+
rankingFields: options.searchConfig.rankingFields,
|
|
240
|
+
});
|
|
241
|
+
} else if (searchableFields.length > 0) {
|
|
242
|
+
await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
|
|
243
|
+
searchableFields,
|
|
244
|
+
rankingFields: searchableFields,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Wire SSE broker with event collector
|
|
250
|
+
const sseBroker = createSseBroker();
|
|
251
|
+
sseBroker.addClient(
|
|
252
|
+
"tenant:00000000-0000-4000-8000-000000000001",
|
|
253
|
+
(event) => events.sse.push(event),
|
|
254
|
+
() => {},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
|
|
258
|
+
const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
|
|
259
|
+
const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
|
|
260
|
+
|
|
261
|
+
const server = buildServer({
|
|
262
|
+
registry,
|
|
263
|
+
context: {
|
|
264
|
+
db: testDb.db,
|
|
265
|
+
redis: testRedis.redis,
|
|
266
|
+
searchAdapter,
|
|
267
|
+
entityCache,
|
|
268
|
+
registry,
|
|
269
|
+
...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
|
|
270
|
+
...(typeof options.extraContext === "function"
|
|
271
|
+
? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
|
|
272
|
+
: options.extraContext),
|
|
273
|
+
},
|
|
274
|
+
jwtSecret,
|
|
275
|
+
dispatcherOptions: {
|
|
276
|
+
idempotency,
|
|
277
|
+
...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
|
|
278
|
+
},
|
|
279
|
+
eventDedup,
|
|
280
|
+
sseBroker,
|
|
281
|
+
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
282
|
+
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
283
|
+
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
284
|
+
// plumbs through the LISTEN wake-up for tests that want to measure
|
|
285
|
+
// post-commit latency (Sprint E.4).
|
|
286
|
+
eventDispatcher: {
|
|
287
|
+
pollIntervalMs: 50,
|
|
288
|
+
pgClient: testDb.client,
|
|
289
|
+
systemConsumers: {
|
|
290
|
+
sse: enabledHooks.includes("sse"),
|
|
291
|
+
search: enabledHooks.includes("search"),
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
// Default tests to no login rate-limiter so existing suites that loop
|
|
295
|
+
// over logins don't hit a 429 after 10 attempts. Suites specifically
|
|
296
|
+
// testing the limiter can override via authConfig.loginRateLimit.
|
|
297
|
+
...(options.authConfig
|
|
298
|
+
? {
|
|
299
|
+
auth: {
|
|
300
|
+
...options.authConfig,
|
|
301
|
+
...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: {}),
|
|
305
|
+
...(options.observability ? { observability: options.observability } : {}),
|
|
306
|
+
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
307
|
+
...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
|
|
308
|
+
...(options.anonymousAccess
|
|
309
|
+
? {
|
|
310
|
+
anonymousAccess:
|
|
311
|
+
typeof options.anonymousAccess === "function"
|
|
312
|
+
? options.anonymousAccess({
|
|
313
|
+
registry,
|
|
314
|
+
db: testDb.db,
|
|
315
|
+
sseBroker,
|
|
316
|
+
redis: testRedis.redis,
|
|
317
|
+
})
|
|
318
|
+
: options.anonymousAccess,
|
|
319
|
+
}
|
|
320
|
+
: {}),
|
|
321
|
+
// Wire the upload routes + ctx.files only when the caller registered a
|
|
322
|
+
// provider. Tests that don't touch files skip both without extra setup.
|
|
323
|
+
...(options.files
|
|
324
|
+
? { files: { db: testDb.db, storageProvider: options.files.storageProvider } }
|
|
325
|
+
: {}),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
|
|
329
|
+
|
|
330
|
+
// Pre-register consumer state rows so tests can call runOnce() directly
|
|
331
|
+
// without a preceding explicit start(). Timer fires at pollIntervalMs=50
|
|
332
|
+
// but passInFlight serialises concurrent passes — tests that drain via
|
|
333
|
+
// runOnce() remain deterministic. Tests that specifically exercise the
|
|
334
|
+
// timer loop call start() again (idempotent) after setup.
|
|
335
|
+
if (eventDispatcher) await eventDispatcher.ensureRegistered();
|
|
336
|
+
|
|
337
|
+
const http = createRequestHelper(server.app, server.jwt);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
app: server.app,
|
|
341
|
+
jwt: server.jwt,
|
|
342
|
+
registry,
|
|
343
|
+
db: testDb.db,
|
|
344
|
+
redis: testRedis,
|
|
345
|
+
search: searchAdapter,
|
|
346
|
+
events,
|
|
347
|
+
http,
|
|
348
|
+
observability: server.observability,
|
|
349
|
+
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
350
|
+
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
351
|
+
cleanup: async () => {
|
|
352
|
+
if (eventDispatcher) await eventDispatcher.stop();
|
|
353
|
+
await server.observability.shutdown();
|
|
354
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SessionUser, TenantId } from "../engine/types";
|
|
2
|
+
|
|
3
|
+
// Zero-padded UUIDs used across the test suite. `testTenantId(1)` /
|
|
4
|
+
// `testUserId(1)` read cleaner in assertions than the full UUID literals,
|
|
5
|
+
// and keep all tests on a single shape — if the UUID layout ever changes,
|
|
6
|
+
// it changes here.
|
|
7
|
+
export function testTenantId(n: number): TenantId {
|
|
8
|
+
return `00000000-0000-4000-8000-${n.toString().padStart(12, "0")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Distinct prefix from tenantId so debug output visibly differentiates the
|
|
12
|
+
// two when a user-id accidentally lands in a tenant-id slot.
|
|
13
|
+
export function testUserId(n: number): string {
|
|
14
|
+
return `11111111-0000-4000-8000-${n.toString().padStart(12, "0")}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const TestUsers = {
|
|
18
|
+
admin: { id: testUserId(1), tenantId: testTenantId(1), roles: ["Admin"] },
|
|
19
|
+
systemAdmin: { id: testUserId(1), tenantId: testTenantId(1), roles: ["SystemAdmin"] },
|
|
20
|
+
user: { id: testUserId(2), tenantId: testTenantId(1), roles: ["User"] },
|
|
21
|
+
driver: { id: testUserId(3), tenantId: testTenantId(1), roles: ["Driver"] },
|
|
22
|
+
otherTenant: { id: testUserId(10), tenantId: testTenantId(2), roles: ["Admin"] },
|
|
23
|
+
} as const satisfies Record<string, SessionUser>;
|
|
24
|
+
|
|
25
|
+
// Accept numeric shortcuts for legacy call sites — stringify to a UUID so the
|
|
26
|
+
// SessionUser type stays aligned. `createTestUser({ id: 42 })` gives you
|
|
27
|
+
// `testUserId(42)`. Explicit strings pass through untouched.
|
|
28
|
+
export function createTestUser(
|
|
29
|
+
overrides?: Partial<Omit<SessionUser, "id">> & { id?: string | number },
|
|
30
|
+
): SessionUser {
|
|
31
|
+
const normalizedId =
|
|
32
|
+
typeof overrides?.id === "number"
|
|
33
|
+
? testUserId(overrides.id)
|
|
34
|
+
: (overrides?.id ?? TestUsers.admin.id);
|
|
35
|
+
const { id: _id, ...rest } = overrides ?? {};
|
|
36
|
+
return { ...TestUsers.admin, ...rest, id: normalizedId };
|
|
37
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import {
|
|
4
|
+
createBooleanField,
|
|
5
|
+
createEntity,
|
|
6
|
+
createRegistry,
|
|
7
|
+
createSelectField,
|
|
8
|
+
createTextField,
|
|
9
|
+
defineEntityCreateHandler,
|
|
10
|
+
defineFeature,
|
|
11
|
+
} from "../../engine";
|
|
12
|
+
import { generateE2ESpec, generateZodFixture } from "../e2e-generator";
|
|
13
|
+
|
|
14
|
+
const taskEntity = createEntity({
|
|
15
|
+
table: "tasks",
|
|
16
|
+
fields: {
|
|
17
|
+
title: createTextField({ required: true, maxLength: 200 }),
|
|
18
|
+
done: createBooleanField({ default: false }),
|
|
19
|
+
status: createSelectField({ options: ["todo", "doing", "done"] as const }),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Minimal-Feature mit beiden Screen-Typen UND einem Create-Handler —
|
|
24
|
+
// abhängig davon was im Screen steht, emittiert der Generator andere
|
|
25
|
+
// Kind-Kombinationen, siehe buildListSpecs/buildEditSpecs.
|
|
26
|
+
function createTasksFeature() {
|
|
27
|
+
return defineFeature("tasks", (r) => {
|
|
28
|
+
r.systemScope();
|
|
29
|
+
r.entity("task", taskEntity);
|
|
30
|
+
r.writeHandler(defineEntityCreateHandler("task", taskEntity));
|
|
31
|
+
r.screen({
|
|
32
|
+
id: "task-list",
|
|
33
|
+
type: "entityList",
|
|
34
|
+
entity: "task",
|
|
35
|
+
columns: ["title", "status", "done"],
|
|
36
|
+
});
|
|
37
|
+
r.screen({
|
|
38
|
+
id: "task-edit",
|
|
39
|
+
type: "entityEdit",
|
|
40
|
+
entity: "task",
|
|
41
|
+
layout: {
|
|
42
|
+
sections: [{ title: "tasks:section.basics", fields: ["title", "status", "done"] }],
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("generateE2ESpec", () => {
|
|
49
|
+
test("emits list-renders + list-has-fixture-row for entityList screens", () => {
|
|
50
|
+
const registry = createRegistry([createTasksFeature()]);
|
|
51
|
+
const specs = generateE2ESpec(registry);
|
|
52
|
+
|
|
53
|
+
const listSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-list");
|
|
54
|
+
expect(listSpecs.map((s) => s.kind)).toEqual(["list-renders", "list-has-fixture-row"]);
|
|
55
|
+
|
|
56
|
+
const fixtureSpec = listSpecs.find((s) => s.kind === "list-has-fixture-row");
|
|
57
|
+
if (fixtureSpec?.kind !== "list-has-fixture-row") throw new Error("unreachable");
|
|
58
|
+
expect(fixtureSpec.writeHandlerQn).toBe("tasks:write:task:create");
|
|
59
|
+
expect(fixtureSpec.urlPath).toBe("/t/{tenant}/tasks/task-list");
|
|
60
|
+
expect(fixtureSpec.fixture["title"]).toBe("e2e title");
|
|
61
|
+
expect(fixtureSpec.identifyingValue).toBe("e2e title");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("emits edit-validates-required + edit-save-persists for entityEdit screens", () => {
|
|
65
|
+
const registry = createRegistry([createTasksFeature()]);
|
|
66
|
+
const specs = generateE2ESpec(registry);
|
|
67
|
+
|
|
68
|
+
const editSpecs = specs.filter((s) => s.screenQn === "tasks:screen:task-edit");
|
|
69
|
+
expect(editSpecs.map((s) => s.kind)).toEqual(["edit-validates-required", "edit-save-persists"]);
|
|
70
|
+
|
|
71
|
+
const validates = editSpecs.find((s) => s.kind === "edit-validates-required");
|
|
72
|
+
if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
|
|
73
|
+
expect(validates.requiredFields).toEqual(["title"]);
|
|
74
|
+
|
|
75
|
+
const persists = editSpecs.find((s) => s.kind === "edit-save-persists");
|
|
76
|
+
if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
|
|
77
|
+
expect(persists.listUrlPath).toBe("/t/{tenant}/tasks/task-list");
|
|
78
|
+
expect(persists.identifyingField).toBe("title");
|
|
79
|
+
// Select-Field muss "select" bekommen, Boolean "check", Text "fill" —
|
|
80
|
+
// sonst emittiert der Renderer .fill() für ein Dropdown und Playwright
|
|
81
|
+
// zerschellt am ersten Sample mit Select-Feld.
|
|
82
|
+
expect(persists.fills).toEqual([
|
|
83
|
+
{ kind: "fill", field: "title", value: "e2e title" },
|
|
84
|
+
{ kind: "select", field: "status", value: "todo" },
|
|
85
|
+
{ kind: "check", field: "done", value: true },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("accepts tenant-slug override", () => {
|
|
90
|
+
const registry = createRegistry([createTasksFeature()]);
|
|
91
|
+
const specs = generateE2ESpec(registry, { tenantPlaceholder: "acme" });
|
|
92
|
+
expect(specs[0]?.urlPath).toMatch(/^\/t\/acme\//);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("skips custom screens", () => {
|
|
96
|
+
const feature = defineFeature("audit", (r) => {
|
|
97
|
+
r.systemScope();
|
|
98
|
+
r.screen({
|
|
99
|
+
id: "log",
|
|
100
|
+
type: "custom",
|
|
101
|
+
renderer: { react: { __component: "X" } },
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
const specs = generateE2ESpec(createRegistry([feature]));
|
|
105
|
+
expect(specs).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("skips list-has-fixture-row when no create-handler is registered", () => {
|
|
109
|
+
// Feature hat Screen + Entity aber keinen Create-Handler (z.B. weil
|
|
110
|
+
// Writes noch in einer anderen Feature-Variante landen). Ohne Handler
|
|
111
|
+
// kann der Generator nicht seeden — list-renders bleibt, der Fixture-
|
|
112
|
+
// Test wird gespart statt falsch generiert.
|
|
113
|
+
const readOnly = defineFeature("read-only", (r) => {
|
|
114
|
+
r.systemScope();
|
|
115
|
+
r.entity("task", taskEntity);
|
|
116
|
+
r.screen({ id: "list", type: "entityList", entity: "task", columns: ["title"] });
|
|
117
|
+
});
|
|
118
|
+
const specs = generateE2ESpec(createRegistry([readOnly]));
|
|
119
|
+
expect(specs.map((s) => s.kind)).toEqual(["list-renders"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("edit-save-persists has undefined listUrlPath when no matching list-screen exists", () => {
|
|
123
|
+
// entityEdit ohne entityList — z.B. Detail-Seite die über SSE Refresh
|
|
124
|
+
// statt Navigation validiert wird. Der Generator muss trotzdem eine
|
|
125
|
+
// edit-save-persists-Spec emittieren, nur ohne listUrlPath (Renderer
|
|
126
|
+
// verifiziert dann im Edit-View selbst).
|
|
127
|
+
const editOnly = defineFeature("edit-only", (r) => {
|
|
128
|
+
r.systemScope();
|
|
129
|
+
r.entity("task", taskEntity);
|
|
130
|
+
r.writeHandler(defineEntityCreateHandler("task", taskEntity));
|
|
131
|
+
r.screen({
|
|
132
|
+
id: "edit",
|
|
133
|
+
type: "entityEdit",
|
|
134
|
+
entity: "task",
|
|
135
|
+
layout: { sections: [{ title: "s", fields: ["title"] }] },
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
const specs = generateE2ESpec(createRegistry([editOnly]));
|
|
139
|
+
const persists = specs.find((s) => s.kind === "edit-save-persists");
|
|
140
|
+
if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
|
|
141
|
+
expect(persists.listUrlPath).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("text-field formats (email/url) produce format-specific fixtures", () => {
|
|
145
|
+
// createTextField({ format: "email" }) muss einen Mail-artigen Fixture
|
|
146
|
+
// liefern — sonst schlägt die Zod-Validation am Server fehl, sobald
|
|
147
|
+
// der Generator-Output gegen eine echte API läuft.
|
|
148
|
+
const contactEntity = createEntity({
|
|
149
|
+
table: "contacts",
|
|
150
|
+
fields: {
|
|
151
|
+
name: createTextField({ required: true }),
|
|
152
|
+
email: createTextField({ required: true, format: "email" }),
|
|
153
|
+
homepage: createTextField({ format: "url" }),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
const feature = defineFeature("contacts", (r) => {
|
|
157
|
+
r.systemScope();
|
|
158
|
+
r.entity("contact", contactEntity);
|
|
159
|
+
r.writeHandler(defineEntityCreateHandler("contact", contactEntity));
|
|
160
|
+
r.screen({
|
|
161
|
+
id: "list",
|
|
162
|
+
type: "entityList",
|
|
163
|
+
entity: "contact",
|
|
164
|
+
columns: ["name", "email", "homepage"],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
const specs = generateE2ESpec(createRegistry([feature]));
|
|
168
|
+
const fixtureSpec = specs.find((s) => s.kind === "list-has-fixture-row");
|
|
169
|
+
if (fixtureSpec?.kind !== "list-has-fixture-row") throw new Error("unreachable");
|
|
170
|
+
expect(fixtureSpec.fixture["email"]).toMatch(/^e2e-email@/);
|
|
171
|
+
expect(fixtureSpec.fixture["homepage"]).toBe("https://example.com");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("mixed feature (list + edit + custom) — generates for list/edit, skips custom", () => {
|
|
175
|
+
// Deckt das Shape ab das ein echtes Sample hat: ein Feature mit allen
|
|
176
|
+
// drei Screen-Typen. Custom wird übersprungen, List + Edit liefern
|
|
177
|
+
// ihre jeweiligen Spec-Kinds.
|
|
178
|
+
const mixed = defineFeature("mixed", (r) => {
|
|
179
|
+
r.systemScope();
|
|
180
|
+
r.entity("task", taskEntity);
|
|
181
|
+
r.writeHandler(defineEntityCreateHandler("task", taskEntity));
|
|
182
|
+
r.screen({ id: "list", type: "entityList", entity: "task", columns: ["title"] });
|
|
183
|
+
r.screen({
|
|
184
|
+
id: "edit",
|
|
185
|
+
type: "entityEdit",
|
|
186
|
+
entity: "task",
|
|
187
|
+
layout: { sections: [{ title: "mixed:s", fields: ["title"] }] },
|
|
188
|
+
});
|
|
189
|
+
r.screen({
|
|
190
|
+
id: "dashboard",
|
|
191
|
+
type: "custom",
|
|
192
|
+
renderer: { react: { __component: "X" } },
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
const specs = generateE2ESpec(createRegistry([mixed]));
|
|
196
|
+
const screens = new Set(specs.map((s) => s.screenQn));
|
|
197
|
+
expect(screens).toEqual(new Set(["mixed:screen:list", "mixed:screen:edit"]));
|
|
198
|
+
expect(specs.map((s) => s.kind).sort()).toEqual([
|
|
199
|
+
"edit-save-persists",
|
|
200
|
+
"edit-validates-required",
|
|
201
|
+
"list-has-fixture-row",
|
|
202
|
+
"list-renders",
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("generateZodFixture", () => {
|
|
208
|
+
test("primitives", () => {
|
|
209
|
+
expect(generateZodFixture(z.string())).toBe("e2e-fixture");
|
|
210
|
+
expect(generateZodFixture(z.number())).toBe(1);
|
|
211
|
+
expect(generateZodFixture(z.boolean())).toBe(true);
|
|
212
|
+
expect(generateZodFixture(z.enum(["a", "b"]))).toBe("a");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("string formats", () => {
|
|
216
|
+
expect(generateZodFixture(z.email())).toBe("e2e@example.com");
|
|
217
|
+
expect(generateZodFixture(z.url())).toBe("https://example.com");
|
|
218
|
+
expect(generateZodFixture(z.uuid())).toBe("00000000-0000-4000-8000-000000000000");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("optional + default unwrap", () => {
|
|
222
|
+
expect(generateZodFixture(z.string().optional())).toBe("e2e-fixture");
|
|
223
|
+
expect(generateZodFixture(z.number().default(42))).toBe(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("unsupported types throw", () => {
|
|
227
|
+
expect(() => generateZodFixture(z.object({}))).toThrow(/not supported yet/);
|
|
228
|
+
expect(() => generateZodFixture(z.array(z.string()))).toThrow(/not supported yet/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
import type { EntityDefinition } from "../../engine/types";
|
|
4
|
+
import { createEntityTable, createTestDb, ensureEntityTable, type TestDb } from "../../stack";
|
|
5
|
+
|
|
6
|
+
// ensureEntityTable ist die idempotente Variante von createEntityTable —
|
|
7
|
+
// existiert wegen des dev-server-Boot-Pfads (persistente DB, Table von
|
|
8
|
+
// letztem Run). createEntityTable bleibt strict, damit Tests ein
|
|
9
|
+
// falsches Schema nicht stillschweigend akzeptieren.
|
|
10
|
+
|
|
11
|
+
const tenantEntity: EntityDefinition = {
|
|
12
|
+
fields: {
|
|
13
|
+
title: { type: "text", required: true },
|
|
14
|
+
},
|
|
15
|
+
table: "ensure_entity_table_probe",
|
|
16
|
+
} as unknown as EntityDefinition;
|
|
17
|
+
|
|
18
|
+
let db: TestDb;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
db = await createTestDb();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await db.cleanup();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("ensureEntityTable", () => {
|
|
29
|
+
test("legt die Tabelle beim ersten Aufruf an (returnt true)", async () => {
|
|
30
|
+
const created = await ensureEntityTable(db.db, tenantEntity, "probe");
|
|
31
|
+
expect(created).toBe(true);
|
|
32
|
+
const rows = await db.db.execute<{ exists: boolean }>(
|
|
33
|
+
sql`SELECT to_regclass('public.ensure_entity_table_probe') IS NOT NULL AS exists`,
|
|
34
|
+
);
|
|
35
|
+
expect(rows[0]?.exists).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("ist beim zweiten Aufruf ein No-Op (returnt false, kein Fehler)", async () => {
|
|
39
|
+
const created = await ensureEntityTable(db.db, tenantEntity, "probe");
|
|
40
|
+
expect(created).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("createEntityTable bleibt strict — wirft bei existierender Tabelle", async () => {
|
|
44
|
+
// Gleiche Entity zweimal via createEntityTable → postgres 42P07
|
|
45
|
+
// (relation already exists). Drizzle wrappt den PG-Error in
|
|
46
|
+
// DrizzleQueryError; der echte Code steckt in .cause. Sicherstellt,
|
|
47
|
+
// dass ensureEntityTable nicht versehentlich das strict-Verhalten
|
|
48
|
+
// verändert.
|
|
49
|
+
await expect(createEntityTable(db.db, tenantEntity, "probe")).rejects.toSatisfy((err) => {
|
|
50
|
+
const cause = (err as { cause?: { code?: string } }).cause;
|
|
51
|
+
return cause?.code === "42P07";
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|