@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,566 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
2
|
+
import { requestContext } from "../../api/request-context";
|
|
3
|
+
import { createRegistry, defineFeature } from "../../engine";
|
|
4
|
+
import type { AppContext, Registry } from "../../engine/types";
|
|
5
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
6
|
+
import { sleep, waitFor } from "../../testing";
|
|
7
|
+
import { createJobRunner, type JobRunner } from "../job-runner";
|
|
8
|
+
|
|
9
|
+
// --- Shared state ---
|
|
10
|
+
|
|
11
|
+
let testRedis: TestRedis;
|
|
12
|
+
let redisUrl: string;
|
|
13
|
+
|
|
14
|
+
// Track which jobs ran and when
|
|
15
|
+
const jobLog: Array<{ name: string; payload: Record<string, unknown>; timestamp: number }> = [];
|
|
16
|
+
|
|
17
|
+
function clearLog() {
|
|
18
|
+
jobLog.length = 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Feature with test jobs ---
|
|
22
|
+
|
|
23
|
+
const testFeature = defineFeature("test", (r) => {
|
|
24
|
+
// Scenario 1: Boot job
|
|
25
|
+
r.job("bootSync", { trigger: { manual: true }, runOnBoot: true }, async (payload) => {
|
|
26
|
+
jobLog.push({ name: "test:job:boot-sync", payload, timestamp: Date.now() });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Scenario 2: Scheduled job (cron every second for testing)
|
|
30
|
+
r.job("scheduled", { trigger: { cron: "* * * * * *" } }, async (payload) => {
|
|
31
|
+
jobLog.push({ name: "test:job:scheduled", payload, timestamp: Date.now() });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Scenario 3: Manual trigger
|
|
35
|
+
r.job("manualReport", { trigger: { manual: true } }, async (payload) => {
|
|
36
|
+
jobLog.push({ name: "test:job:manual-report", payload, timestamp: Date.now() });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Concurrency: skip — if running, skip new
|
|
40
|
+
r.job("skipJob", { trigger: { manual: true }, concurrency: "skip" }, async (payload) => {
|
|
41
|
+
jobLog.push({ name: "test:job:skip-job", payload, timestamp: Date.now() });
|
|
42
|
+
await sleep(500); // Simulate long-running job
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Concurrency: parallel — multiple can run
|
|
46
|
+
r.job("parallelJob", { trigger: { manual: true }, concurrency: "parallel" }, async (payload) => {
|
|
47
|
+
jobLog.push({ name: "test:job:parallel-job", payload, timestamp: Date.now() });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Concurrency: replace — cancel old, start new
|
|
51
|
+
r.job("replaceJob", { trigger: { manual: true }, concurrency: "replace" }, async (payload) => {
|
|
52
|
+
jobLog.push({ name: "test:job:replace-job", payload, timestamp: Date.now() });
|
|
53
|
+
await sleep(200);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Concurrency: debounce — wait until quiet, then run once
|
|
57
|
+
r.job(
|
|
58
|
+
"debounceJob",
|
|
59
|
+
{ trigger: { manual: true }, concurrency: "debounce", debounceMs: 300 },
|
|
60
|
+
async (payload) => {
|
|
61
|
+
jobLog.push({ name: "test:job:debounce-job", payload, timestamp: Date.now() });
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Concurrency: sequential — same-name dispatches must serialise via the
|
|
66
|
+
// per-name Redis SETNX-lock. Sleep duration sets the gap the assertions
|
|
67
|
+
// measure: parallel mode lands all entries within ~50ms; sequential
|
|
68
|
+
// spaces them by ≥sleep-duration each. If you tweak the sleep here,
|
|
69
|
+
// bump the timestamp deltas in the assertion to match.
|
|
70
|
+
r.job(
|
|
71
|
+
"sequentialJob",
|
|
72
|
+
{ trigger: { manual: true }, concurrency: "sequential" },
|
|
73
|
+
async (payload) => {
|
|
74
|
+
jobLog.push({ name: "test:job:sequential-job", payload, timestamp: Date.now() });
|
|
75
|
+
await sleep(300);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Sequential variant that throws. Used to assert the lock is released in
|
|
80
|
+
// the finally-path (next dispatch must still acquire it). retries=0 so
|
|
81
|
+
// the failure doesn't replay and pollute the log.
|
|
82
|
+
r.job(
|
|
83
|
+
"sequentialFailJob",
|
|
84
|
+
{ trigger: { manual: true }, concurrency: "sequential", retries: 0 },
|
|
85
|
+
async (payload) => {
|
|
86
|
+
jobLog.push({ name: "test:job:sequential-fail-job", payload, timestamp: Date.now() });
|
|
87
|
+
throw new Error("sequential boom");
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// maxPerTenant: cap concurrent + waiting jobs per tenant. Long sleep so
|
|
92
|
+
// the dispatcher checks ALL queued counts (including waiting ones).
|
|
93
|
+
r.job(
|
|
94
|
+
"perTenantLimited",
|
|
95
|
+
{ trigger: { manual: true }, concurrency: "parallel", maxPerTenant: 2 },
|
|
96
|
+
async (payload) => {
|
|
97
|
+
jobLog.push({ name: "test:job:per-tenant-limited", payload, timestamp: Date.now() });
|
|
98
|
+
await sleep(500);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Job that fails
|
|
103
|
+
r.job("failingJob", { trigger: { manual: true }, retries: 1 }, async () => {
|
|
104
|
+
throw new Error("intentional failure");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Correlation propagation probe — records the requestContext it sees at
|
|
108
|
+
// handler-time so tests can assert the scheduling request's correlationId
|
|
109
|
+
// made it through BullMQ into the worker process.
|
|
110
|
+
r.job("correlationProbe", { trigger: { manual: true } }, async (payload) => {
|
|
111
|
+
const seen = requestContext.get();
|
|
112
|
+
jobLog.push({
|
|
113
|
+
name: "test:job:correlation-probe",
|
|
114
|
+
payload: {
|
|
115
|
+
...payload,
|
|
116
|
+
observedCorrelationId: seen?.correlationId ?? null,
|
|
117
|
+
observedRequestId: seen?.requestId ?? null,
|
|
118
|
+
},
|
|
119
|
+
timestamp: Date.now(),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
beforeAll(async () => {
|
|
125
|
+
testRedis = await createTestRedis();
|
|
126
|
+
redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await testRedis.cleanup();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Helper to create a runner, run tests, then stop
|
|
134
|
+
async function withRunner(
|
|
135
|
+
fn: (runner: JobRunner, registry: Registry) => Promise<void>,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const registry = createRegistry([testFeature]);
|
|
138
|
+
const context: AppContext = {};
|
|
139
|
+
// Date.now() alone collided when two tests ran in the same millisecond;
|
|
140
|
+
// adding a random suffix keeps queue names unique across the whole run.
|
|
141
|
+
const queueNamePrefix = `kumiko-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
142
|
+
const runner = createJobRunner({
|
|
143
|
+
registry,
|
|
144
|
+
context,
|
|
145
|
+
redisUrl,
|
|
146
|
+
consumerLane: "worker",
|
|
147
|
+
queueNamePrefix,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await runner.start();
|
|
152
|
+
await fn(runner, registry);
|
|
153
|
+
} finally {
|
|
154
|
+
await runner.stop();
|
|
155
|
+
// Purge any lingering scheduler/repeat keys the worker-lane queue left
|
|
156
|
+
// behind. BullMQ stores them under <queueName>:* — orphaned schedulers
|
|
157
|
+
// from a previous test run would otherwise fire into a now-stopped
|
|
158
|
+
// worker. Only the worker lane is queried because these tests run jobs
|
|
159
|
+
// with the default runIn, which resolves to "worker".
|
|
160
|
+
const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
|
|
161
|
+
if (keys.length > 0) await testRedis.redis.del(...keys);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Scenario 1: Boot job runs on startup ---
|
|
166
|
+
|
|
167
|
+
describe("scenario 1: boot job", () => {
|
|
168
|
+
test("runOnBoot job executes when runner starts", async () => {
|
|
169
|
+
clearLog();
|
|
170
|
+
await withRunner(async () => {
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
const bootEntries = jobLog.filter((e) => e.name === "test:job:boot-sync");
|
|
173
|
+
expect(bootEntries.length).toBeGreaterThanOrEqual(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- Scenario 2: Scheduled (cron) job ---
|
|
180
|
+
|
|
181
|
+
describe("scenario 2: scheduled job", () => {
|
|
182
|
+
test("cron job is registered in registry", () => {
|
|
183
|
+
const registry = createRegistry([testFeature]);
|
|
184
|
+
const job = registry.getJob("test:job:scheduled");
|
|
185
|
+
expect(job).toBeDefined();
|
|
186
|
+
if (job && "cron" in job.trigger) {
|
|
187
|
+
expect(job.trigger.cron).toBe("* * * * * *");
|
|
188
|
+
} else {
|
|
189
|
+
expect.unreachable("Expected cron trigger");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// BullMQ's repeatable scheduler needs a second or two to register its
|
|
194
|
+
// first tick — a generous delay schedule covers the startup window.
|
|
195
|
+
test("cron job fires via BullMQ scheduler", { timeout: 15_000 }, async () => {
|
|
196
|
+
clearLog();
|
|
197
|
+
await withRunner(async () => {
|
|
198
|
+
await waitFor(
|
|
199
|
+
() => {
|
|
200
|
+
const entries = jobLog.filter((e) => e.name === "test:job:scheduled");
|
|
201
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
202
|
+
},
|
|
203
|
+
{ delays: [2000, 3000, 5000] },
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// --- Scenario 3: Manual trigger ---
|
|
210
|
+
|
|
211
|
+
describe("scenario 3: manual trigger", () => {
|
|
212
|
+
test("dispatch runs the job with payload", async () => {
|
|
213
|
+
clearLog();
|
|
214
|
+
await withRunner(async (runner) => {
|
|
215
|
+
await runner.dispatch("test:job:manual-report", { reportId: 42 });
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
const entries = jobLog.filter((e) => e.name === "test:job:manual-report");
|
|
218
|
+
expect(entries.length).toBe(1);
|
|
219
|
+
expect(entries[0]?.payload).toEqual({ reportId: 42 });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("dispatch unknown job throws", async () => {
|
|
225
|
+
await withRunner(async (runner) => {
|
|
226
|
+
await expect(runner.dispatch("nonexistent:job:missing")).rejects.toThrow("Unknown job");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// --- Concurrency modes ---
|
|
232
|
+
|
|
233
|
+
describe("concurrency: parallel", () => {
|
|
234
|
+
test("multiple parallel jobs all run", async () => {
|
|
235
|
+
clearLog();
|
|
236
|
+
await withRunner(async (runner) => {
|
|
237
|
+
await runner.dispatch("test:job:parallel-job", { n: 1 });
|
|
238
|
+
await runner.dispatch("test:job:parallel-job", { n: 2 });
|
|
239
|
+
await runner.dispatch("test:job:parallel-job", { n: 3 });
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
const entries = jobLog.filter((e) => e.name === "test:job:parallel-job");
|
|
242
|
+
expect(entries.length).toBe(3);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("concurrency: skip", () => {
|
|
249
|
+
test("skip mode prevents duplicate execution", async () => {
|
|
250
|
+
clearLog();
|
|
251
|
+
await withRunner(async (runner) => {
|
|
252
|
+
// First job takes 500ms
|
|
253
|
+
await runner.dispatch("test:job:skip-job", { n: 1 });
|
|
254
|
+
|
|
255
|
+
// Try dispatching multiple times while first is running
|
|
256
|
+
let skippedCount = 0;
|
|
257
|
+
for (let i = 0; i < 5; i++) {
|
|
258
|
+
await sleep(50);
|
|
259
|
+
const id = await runner.dispatch("test:job:skip-job", { n: i + 2 });
|
|
260
|
+
if (id === "skipped") skippedCount++;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Wait until the first (running) job finishes so its log entry lands.
|
|
264
|
+
// Skip-mode guarantees max one job at a time, so we only ever need to
|
|
265
|
+
// see a single entry to know the run settled.
|
|
266
|
+
await waitFor(() => {
|
|
267
|
+
const entries = jobLog.filter((e) => e.name === "test:job:skip-job");
|
|
268
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// At least some should have been skipped
|
|
272
|
+
expect(skippedCount).toBeGreaterThan(0);
|
|
273
|
+
// Should not have run all 6 times
|
|
274
|
+
const entries = jobLog.filter((e) => e.name === "test:job:skip-job");
|
|
275
|
+
expect(entries.length).toBeLessThan(6);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("concurrency: sequential", () => {
|
|
281
|
+
test("same-name dispatches run strictly one after the other", { timeout: 15_000 }, async () => {
|
|
282
|
+
clearLog();
|
|
283
|
+
await withRunner(async (runner) => {
|
|
284
|
+
// Three rapid dispatches. Parallel mode would land all entries
|
|
285
|
+
// within a single poll cycle (~50ms apart). The SETNX lock in the
|
|
286
|
+
// job-runner forces them to wait — each picks up only after the
|
|
287
|
+
// previous releases its lock at the end of its 300ms sleep.
|
|
288
|
+
await runner.dispatch("test:job:sequential-job", { n: 1 });
|
|
289
|
+
await runner.dispatch("test:job:sequential-job", { n: 2 });
|
|
290
|
+
await runner.dispatch("test:job:sequential-job", { n: 3 });
|
|
291
|
+
|
|
292
|
+
// Generous polling — re-enqueue with delay 200ms means the third
|
|
293
|
+
// job needs at least ~600ms total to land. Worst case allows for
|
|
294
|
+
// some BullMQ poll overhead.
|
|
295
|
+
await waitFor(
|
|
296
|
+
() => {
|
|
297
|
+
const entries = jobLog.filter((e) => e.name === "test:job:sequential-job");
|
|
298
|
+
expect(entries.length).toBe(3);
|
|
299
|
+
},
|
|
300
|
+
{ delays: [400, 800, 1500, 3000] },
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const entries = jobLog
|
|
304
|
+
.filter((e) => e.name === "test:job:sequential-job")
|
|
305
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
306
|
+
// Each entry must start at least ~250ms after the previous —
|
|
307
|
+
// sleep is 300ms, with slack for poll overhead. If sequential
|
|
308
|
+
// breaks (lock never acquired, group ignored), the deltas
|
|
309
|
+
// collapse to single-digit milliseconds.
|
|
310
|
+
const delta12 = (entries[1]?.timestamp ?? 0) - (entries[0]?.timestamp ?? 0);
|
|
311
|
+
const delta23 = (entries[2]?.timestamp ?? 0) - (entries[1]?.timestamp ?? 0);
|
|
312
|
+
expect(delta12).toBeGreaterThanOrEqual(250);
|
|
313
|
+
expect(delta23).toBeGreaterThanOrEqual(250);
|
|
314
|
+
|
|
315
|
+
// FIFO inside the same lock-name: the dispatch order is preserved
|
|
316
|
+
// even though re-enqueues happen.
|
|
317
|
+
expect(entries[0]?.payload).toEqual({ n: 1 });
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("lock is released even when the handler throws", { timeout: 10_000 }, async () => {
|
|
322
|
+
clearLog();
|
|
323
|
+
await withRunner(async (runner) => {
|
|
324
|
+
// First dispatch fails. If the finally-path didn't release the lock,
|
|
325
|
+
// the second dispatch couldn't acquire it and would loop forever in
|
|
326
|
+
// the re-enqueue path until BullMQ gave up.
|
|
327
|
+
await runner.dispatch("test:job:sequential-fail-job", { n: 1 });
|
|
328
|
+
await waitFor(
|
|
329
|
+
() => {
|
|
330
|
+
const entries = jobLog.filter((e) => e.name === "test:job:sequential-fail-job");
|
|
331
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
332
|
+
},
|
|
333
|
+
{ delays: [200, 400, 800] },
|
|
334
|
+
);
|
|
335
|
+
// Tiny grace so BullMQ marks the failed job done and our finally
|
|
336
|
+
// ran — otherwise the lock-release race could outlast the next
|
|
337
|
+
// dispatch's acquire attempt.
|
|
338
|
+
await sleep(150);
|
|
339
|
+
|
|
340
|
+
// No surviving lock for this job-name in Redis — the value-matched
|
|
341
|
+
// DEL ran in finally.
|
|
342
|
+
const surviving = await testRedis.redis.keys("kumiko:lock:seq:*sequential-fail-job");
|
|
343
|
+
expect(surviving.length).toBe(0);
|
|
344
|
+
|
|
345
|
+
// Fresh dispatch must run — proves the lock isn't blocking new
|
|
346
|
+
// arrivals after the throw.
|
|
347
|
+
await runner.dispatch("test:job:sequential-fail-job", { n: 2 });
|
|
348
|
+
await waitFor(
|
|
349
|
+
() => {
|
|
350
|
+
const entries = jobLog.filter((e) => e.name === "test:job:sequential-fail-job");
|
|
351
|
+
expect(entries.length).toBeGreaterThanOrEqual(2);
|
|
352
|
+
},
|
|
353
|
+
{ delays: [200, 400, 800] },
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("lock release is value-matched: foreign tokens survive expiration races", {
|
|
359
|
+
timeout: 5_000,
|
|
360
|
+
}, async () => {
|
|
361
|
+
// Pin the contract that distributed-lock's release script enforces:
|
|
362
|
+
// a release call from a worker whose token has already expired and
|
|
363
|
+
// been claimed by someone else must NOT delete the new owner's lock.
|
|
364
|
+
// Tested at the lock layer because we can't reliably race a TTL
|
|
365
|
+
// expiration inside the job-runner inside a 5s test budget.
|
|
366
|
+
const { createDistributedLock } = await import("../../pipeline/distributed-lock");
|
|
367
|
+
const prefix = "kumiko:lock:seq:test-vmd:";
|
|
368
|
+
const lock = createDistributedLock(testRedis.redis, prefix);
|
|
369
|
+
|
|
370
|
+
const tokenA = await lock.acquire("contract-key", { ttlSeconds: 10 });
|
|
371
|
+
expect(tokenA).not.toBeNull();
|
|
372
|
+
// Forcibly take it away — simulates the TTL-expired-and-reclaimed
|
|
373
|
+
// sequence without waiting 10s.
|
|
374
|
+
await testRedis.redis.set(`${prefix}contract-key`, "different-token");
|
|
375
|
+
|
|
376
|
+
// Worker A (now stale) tries to release: must be a no-op.
|
|
377
|
+
const releasedByStale = await lock.release("contract-key", tokenA as string);
|
|
378
|
+
expect(releasedByStale).toBe(false);
|
|
379
|
+
const stillHeld = await testRedis.redis.get(`${prefix}contract-key`);
|
|
380
|
+
expect(stillHeld).toBe("different-token");
|
|
381
|
+
|
|
382
|
+
await testRedis.redis.del(`${prefix}contract-key`);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("concurrency: debounce", () => {
|
|
387
|
+
test("rapid dispatches result in fewer executions than dispatches", async () => {
|
|
388
|
+
clearLog();
|
|
389
|
+
await withRunner(async (runner) => {
|
|
390
|
+
// Rapid fire 5 times — debounce should collapse some
|
|
391
|
+
await runner.dispatch("test:job:debounce-job", { n: 1 });
|
|
392
|
+
await runner.dispatch("test:job:debounce-job", { n: 2 });
|
|
393
|
+
await runner.dispatch("test:job:debounce-job", { n: 3 });
|
|
394
|
+
await runner.dispatch("test:job:debounce-job", { n: 4 });
|
|
395
|
+
await runner.dispatch("test:job:debounce-job", { n: 5 });
|
|
396
|
+
|
|
397
|
+
// Debounce (300ms) fires after the last rapid dispatch, then BullMQ
|
|
398
|
+
// picks the job up — first successful poll usually lands around 500ms.
|
|
399
|
+
await waitFor(
|
|
400
|
+
() => {
|
|
401
|
+
const entries = jobLog.filter((e) => e.name === "test:job:debounce-job");
|
|
402
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
403
|
+
},
|
|
404
|
+
{ delays: [500, 1000, 2000] },
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const entries = jobLog.filter((e) => e.name === "test:job:debounce-job");
|
|
408
|
+
// Debounce should result in fewer executions than dispatches
|
|
409
|
+
expect(entries.length).toBeLessThan(5);
|
|
410
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe("concurrency: maxPerTenant", () => {
|
|
416
|
+
test("max=2: third dispatch for same tenant returns skipped, other tenant unaffected", async () => {
|
|
417
|
+
clearLog();
|
|
418
|
+
await withRunner(async (runner) => {
|
|
419
|
+
const tenantA = "tenant-a";
|
|
420
|
+
const tenantB = "tenant-b";
|
|
421
|
+
|
|
422
|
+
// First two for tenantA fill the bucket — both should accept.
|
|
423
|
+
const idA1 = await runner.dispatch("test:job:per-tenant-limited", {
|
|
424
|
+
n: 1,
|
|
425
|
+
_tenantId: tenantA,
|
|
426
|
+
});
|
|
427
|
+
const idA2 = await runner.dispatch("test:job:per-tenant-limited", {
|
|
428
|
+
n: 2,
|
|
429
|
+
_tenantId: tenantA,
|
|
430
|
+
});
|
|
431
|
+
expect(idA1).not.toBe("skipped:max-per-tenant");
|
|
432
|
+
expect(idA2).not.toBe("skipped:max-per-tenant");
|
|
433
|
+
|
|
434
|
+
// Third for tenantA hits the cap before BullMQ drains the first.
|
|
435
|
+
// Small sleep so we don't race the queue.add of the first two.
|
|
436
|
+
await sleep(50);
|
|
437
|
+
const idA3 = await runner.dispatch("test:job:per-tenant-limited", {
|
|
438
|
+
n: 3,
|
|
439
|
+
_tenantId: tenantA,
|
|
440
|
+
});
|
|
441
|
+
expect(idA3).toBe("skipped:max-per-tenant");
|
|
442
|
+
|
|
443
|
+
// tenantB has its own bucket — accepted.
|
|
444
|
+
const idB1 = await runner.dispatch("test:job:per-tenant-limited", {
|
|
445
|
+
n: 4,
|
|
446
|
+
_tenantId: tenantB,
|
|
447
|
+
});
|
|
448
|
+
expect(idB1).not.toBe("skipped:max-per-tenant");
|
|
449
|
+
|
|
450
|
+
// After the 500ms-handlers settle the bucket empties. jobLog.push runs
|
|
451
|
+
// at handler START, so a log entry doesn't mean the job is "done" —
|
|
452
|
+
// it's still in `active` for the rest of the sleep. Wait long enough
|
|
453
|
+
// that the slowest 500ms handler has returned, then a fresh tenantA
|
|
454
|
+
// dispatch lands again.
|
|
455
|
+
await sleep(900);
|
|
456
|
+
const idA4 = await runner.dispatch("test:job:per-tenant-limited", {
|
|
457
|
+
n: 5,
|
|
458
|
+
_tenantId: tenantA,
|
|
459
|
+
});
|
|
460
|
+
expect(idA4).not.toBe("skipped:max-per-tenant");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("missing _tenantId disables the guard (backwards-compatible)", async () => {
|
|
465
|
+
clearLog();
|
|
466
|
+
await withRunner(async (runner) => {
|
|
467
|
+
// No _tenantId in payload — guard inactive, all 4 accepted regardless of cap.
|
|
468
|
+
for (let i = 0; i < 4; i++) {
|
|
469
|
+
const id = await runner.dispatch("test:job:per-tenant-limited", { n: i });
|
|
470
|
+
expect(id).not.toBe("skipped:max-per-tenant");
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// --- Correlation propagation ---
|
|
477
|
+
|
|
478
|
+
describe("correlation propagation", () => {
|
|
479
|
+
test("dispatch inside requestContext.run passes correlationId into the job", async () => {
|
|
480
|
+
clearLog();
|
|
481
|
+
await withRunner(async (runner) => {
|
|
482
|
+
// Enter a synthetic request-context, dispatch → the scheduler should
|
|
483
|
+
// pack the correlationId into the job data; the worker reads it back
|
|
484
|
+
// and re-enters requestContext.run.
|
|
485
|
+
await requestContext.run(
|
|
486
|
+
{ requestId: "req-outer", correlationId: "carry-me-across-bullmq" },
|
|
487
|
+
async () => {
|
|
488
|
+
await runner.dispatch("test:job:correlation-probe", { n: 1 });
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
await waitFor(() => {
|
|
492
|
+
const entries = jobLog.filter((e) => e.name === "test:job:correlation-probe");
|
|
493
|
+
expect(entries.length).toBe(1);
|
|
494
|
+
});
|
|
495
|
+
const entry = jobLog.find((e) => e.name === "test:job:correlation-probe");
|
|
496
|
+
expect(entry?.payload["observedCorrelationId"]).toBe("carry-me-across-bullmq");
|
|
497
|
+
// requestId is fresh per job run, NOT the scheduler's requestId.
|
|
498
|
+
expect(entry?.payload["observedRequestId"]).not.toBe("req-outer");
|
|
499
|
+
expect(typeof entry?.payload["observedRequestId"]).toBe("string");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("dispatch outside any request-context: job gets a fresh correlationId (not null)", async () => {
|
|
504
|
+
clearLog();
|
|
505
|
+
await withRunner(async (runner) => {
|
|
506
|
+
await runner.dispatch("test:job:correlation-probe", { n: 2 });
|
|
507
|
+
await waitFor(() => {
|
|
508
|
+
const entries = jobLog.filter((e) => e.name === "test:job:correlation-probe");
|
|
509
|
+
expect(entries.length).toBe(1);
|
|
510
|
+
});
|
|
511
|
+
const entry = jobLog.find((e) => e.name === "test:job:correlation-probe");
|
|
512
|
+
// Fresh correlationId — new requestId mirrored onto correlationId
|
|
513
|
+
// when no parent-context provided one.
|
|
514
|
+
expect(typeof entry?.payload["observedCorrelationId"]).toBe("string");
|
|
515
|
+
expect(entry?.payload["observedCorrelationId"]).toBe(entry?.payload["observedRequestId"]);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// --- Error handling ---
|
|
521
|
+
|
|
522
|
+
describe("error handling", () => {
|
|
523
|
+
test("failing job is caught, does not crash worker", async () => {
|
|
524
|
+
clearLog();
|
|
525
|
+
await withRunner(async (runner) => {
|
|
526
|
+
const id = await runner.dispatch("test:job:failing-job");
|
|
527
|
+
expect(id).toBeDefined();
|
|
528
|
+
|
|
529
|
+
// No fixed sleep needed — the follow-up dispatch + waitFor below prove
|
|
530
|
+
// the worker is still alive. If the failing job had crashed the worker,
|
|
531
|
+
// the manual-report would never land and waitFor would time out.
|
|
532
|
+
await runner.dispatch("test:job:manual-report", { after: "failure" });
|
|
533
|
+
await waitFor(() => {
|
|
534
|
+
const entries = jobLog.filter((e) => e.name === "test:job:manual-report");
|
|
535
|
+
expect(entries.length).toBeGreaterThanOrEqual(1);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// --- Registry ---
|
|
542
|
+
|
|
543
|
+
describe("job registry", () => {
|
|
544
|
+
test("getAllJobs returns all registered jobs with feature prefix", () => {
|
|
545
|
+
const registry = createRegistry([testFeature]);
|
|
546
|
+
const jobs = registry.getAllJobs();
|
|
547
|
+
expect(jobs.has("test:job:boot-sync")).toBe(true);
|
|
548
|
+
expect(jobs.has("test:job:scheduled")).toBe(true);
|
|
549
|
+
expect(jobs.has("test:job:manual-report")).toBe(true);
|
|
550
|
+
expect(jobs.has("test:job:skip-job")).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("getJob returns job definition", () => {
|
|
554
|
+
const registry = createRegistry([testFeature]);
|
|
555
|
+
const job = registry.getJob("test:job:skip-job");
|
|
556
|
+
expect(job).toBeDefined();
|
|
557
|
+
expect(job?.concurrency).toBe("skip");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("boot job has runOnBoot flag", () => {
|
|
561
|
+
const registry = createRegistry([testFeature]);
|
|
562
|
+
const job = registry.getJob("test:job:boot-sync");
|
|
563
|
+
expect(job).toBeDefined();
|
|
564
|
+
expect(job?.runOnBoot).toBe(true);
|
|
565
|
+
});
|
|
566
|
+
});
|