diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ab5d756..9b745006 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,9 @@ name: Build NuGet Packages on: - push: - branches: - - main - - net8 - - net9 + release: + types: [published] + workflow_dispatch: jobs: check-nuget: @@ -95,7 +93,17 @@ jobs: run: dotnet nuget add source "$(pwd)/nupkgs" --name LocalNupkgs - name: Restore packages with local source - run: dotnet restore + run: | + dotnet restore src/TickerQ/TickerQ.csproj + dotnet restore src/TickerQ.EntityFrameworkCore/TickerQ.EntityFrameworkCore.csproj + dotnet restore src/TickerQ.Dashboard/TickerQ.Dashboard.csproj + dotnet restore src/TickerQ.Instrumentation.OpenTelemetry/TickerQ.Instrumentation.OpenTelemetry.csproj + dotnet restore src/TickerQ.Caching.StackExchangeRedis/TickerQ.Caching.StackExchangeRedis.csproj + dotnet restore hub/sdks/dotnet/TickerQ.SDK/TickerQ.SDK.csproj + dotnet restore hub/remoteExecutor/TickerQ.RemoteExecutor/TickerQ.RemoteExecutor.csproj + dotnet restore tests/TickerQ.Tests/TickerQ.Tests.csproj + dotnet restore tests/TickerQ.EntityFrameworkCore.Tests/TickerQ.EntityFrameworkCore.Tests.csproj + dotnet restore tests/TickerQ.Caching.StackExchangeRedis.Tests/TickerQ.Caching.StackExchangeRedis.Tests.csproj - name: Build TickerQ.SourceGenerator run: dotnet build src/TickerQ.SourceGenerator/TickerQ.SourceGenerator.csproj --configuration Release @@ -109,6 +117,15 @@ jobs: dotnet build src/TickerQ.Caching.StackExchangeRedis/TickerQ.Caching.StackExchangeRedis.csproj --configuration Release dotnet build hub/sdks/dotnet/TickerQ.SDK/TickerQ.SDK.csproj --configuration Release dotnet build hub/remoteExecutor/TickerQ.RemoteExecutor/TickerQ.RemoteExecutor.csproj --configuration Release + dotnet build tests/TickerQ.Tests/TickerQ.Tests.csproj --configuration Release + dotnet build tests/TickerQ.EntityFrameworkCore.Tests/TickerQ.EntityFrameworkCore.Tests.csproj --configuration Release + dotnet build tests/TickerQ.Caching.StackExchangeRedis.Tests/TickerQ.Caching.StackExchangeRedis.Tests.csproj --configuration Release + + - name: Run tests + run: | + dotnet test tests/TickerQ.Tests/ --configuration Release --no-build + dotnet test tests/TickerQ.EntityFrameworkCore.Tests/ --configuration Release --no-build + dotnet test tests/TickerQ.Caching.StackExchangeRedis.Tests/ --configuration Release --no-build - name: Pack other projects run: | @@ -120,12 +137,11 @@ jobs: dotnet pack hub/sdks/dotnet/TickerQ.SDK/TickerQ.SDK.csproj --configuration Release --output ./nupkgs dotnet pack hub/remoteExecutor/TickerQ.RemoteExecutor/TickerQ.RemoteExecutor.csproj --configuration Release --output ./nupkgs - - name: Show .nupkg file sizes + - name: Show package file sizes run: | echo "πŸ“¦ Package sizes:" - for pkg in ./nupkgs/*.nupkg; do - size=$(du -h "$pkg" | cut -f1) - echo " - $(basename "$pkg"): $size" + for pkg in ./nupkgs/*.nupkg ./nupkgs/*.snupkg; do + [ -f "$pkg" ] && size=$(du -h "$pkg" | cut -f1) && echo " - $(basename "$pkg"): $size" done - name: Upload nupkgs artifact @@ -172,7 +188,7 @@ jobs: 'Accept': 'application/vnd.github.v3+json' }, body: JSON.stringify({ - ref: 'main', + ref: '${{ github.event.release.target_commitish || github.ref_name }}', inputs: { version: version, artifact_id: artifactId @@ -190,18 +206,10 @@ jobs: - name: Send Discord Notification if: always() run: | - # Safely handle the commit message REPO_NAME="${GITHUB_REPOSITORY#*/}" - BRANCH="${GITHUB_REF#refs/heads/}" - - if [[ "${{ github.event.head_commit.message }}" =~ ^Merge\ pull\ request ]]; then - PR_BRANCH=$(echo "${{ github.event.head_commit.message }}" | sed -n "s/Merge pull request #[0-9]\+ from [^/]*\/\(.*\)/\1/p") - MESSAGE="πŸ“¦ *${REPO_NAME}* | Branch: \`${BRANCH}\` | Merged from: \`${PR_BRANCH}\`" - else - # Use a shortened commit message to avoid issues - SHORT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -n1) - MESSAGE="πŸ“¦ *${REPO_NAME}* | Branch: \`${BRANCH}\` | Commit: \`${SHORT_MSG}\`" - fi + BRANCH="${{ github.event.release.target_commitish || github.ref_name }}" + TAG="${{ github.event.release.tag_name || 'manual' }}" + MESSAGE="πŸ“¦ *${REPO_NAME}* | Branch: \`${BRANCH}\` | Release: \`${TAG}\`" # Determine status based on check-nuget and build results if [ "${{ needs.check-nuget.result }}" != "success" ]; then diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 72186931..47289f23 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,19 @@ jobs: continue-on-error: true run: | for f in ./nupkgs/*.nupkg; do + echo "πŸ“¦ Pushing $(basename "$f")..." + dotnet nuget push "$f" \ + --source https://api.nuget.org/v3/index.json \ + --api-key ${{ secrets.NUGET_API_KEY }} \ + --skip-duplicate + done + + - name: Publish symbols to NuGet + continue-on-error: true + run: | + for f in ./nupkgs/*.snupkg; do + [ -f "$f" ] || continue + echo "πŸ”— Pushing symbols $(basename "$f")..." dotnet nuget push "$f" \ --source https://api.nuget.org/v3/index.json \ --api-key ${{ secrets.NUGET_API_KEY }} \ diff --git a/.github/workflows/sync-version-branches.yml b/.github/workflows/sync-version-branches.yml index 91443818..8daf5499 100644 --- a/.github/workflows/sync-version-branches.yml +++ b/.github/workflows/sync-version-branches.yml @@ -1,9 +1,8 @@ name: Sync Version Branches on: - push: - branches: - - main + release: + types: [published] workflow_dispatch: inputs: target_branches: @@ -26,7 +25,7 @@ jobs: pull-requests: write issues: write - if: github.ref == 'refs/heads/main' + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' strategy: matrix: diff --git a/.sync-exclude b/.sync-exclude index 14c83d87..4c35546f 100644 --- a/.sync-exclude +++ b/.sync-exclude @@ -9,5 +9,6 @@ src/TickerQ.EntityFrameworkCore/Infrastructure/BasePersistenceProvider.cs src/TickerQ.EntityFrameworkCore/Infrastructure/TickerQueryExtensions.cs src/TickerQ.EntityFrameworkCore/Customizer/CustomizerServiceDescriptor.cs -# Solution file β€” main uses .slnx (net10+), older branches use .sln -TickerQ.sln +# Solution files β€” main uses .slnx (net10+), older branches use .sln +TickerQ.slnx +src/src.sln diff --git a/samples/TickerQ.Sample.WorkerService/TickerQ.Sample.WorkerService.csproj b/samples/TickerQ.Sample.WorkerService/TickerQ.Sample.WorkerService.csproj index 45b725d8..ff2c029f 100644 --- a/samples/TickerQ.Sample.WorkerService/TickerQ.Sample.WorkerService.csproj +++ b/samples/TickerQ.Sample.WorkerService/TickerQ.Sample.WorkerService.csproj @@ -7,7 +7,13 @@ - + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2a93af9b..b5bb5e6b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ net9.0 - 9.2.2 + 9.2.3 [9.0.0,10.0.0) [10.0.0,11.0.0) https://github.com/arcenox-co/TickerQ @@ -14,11 +14,23 @@ icon.jpg true default + + + true + true + true + snupkg + true + true + + + + diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs index f7de31f1..022d994a 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Routing; using TickerQ.Utilities.Entities; namespace TickerQ.Dashboard.DependencyInjection @@ -344,6 +345,15 @@ private static void MapPathBaseAware(this IApplicationBuilder app, string basePa context.Request.PathBase = originalPathBase.Add(matchedPath); context.Request.Path = remainingPath; + // Clear any endpoint matched by host-level routing so the branch's + // own UseRouting() re-evaluates against dashboard endpoints. + // Without this, host Map*() calls (e.g. MapStaticAssets().ShortCircuit()) + // can cause the branch's routing middleware to skip evaluation β€” the + // EndpointRoutingMiddleware short-circuits when GetEndpoint() is non-null. + // This results in 405 responses for SignalR/WebSocket requests (#456). + context.SetEndpoint(null); + context.Request.RouteValues?.Clear(); + try { await branch(context); diff --git a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs index 80e8426b..5399fb8b 100644 --- a/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs +++ b/src/TickerQ.Dashboard/Endpoints/DashboardEndpoints.cs @@ -262,7 +262,7 @@ private static IResult GetOptions( IdleWorkerTimeOut = schedulerOptions.IdleWorkerTimeOut, CurrentMachine = schedulerOptions.NodeIdentifier, LastHostExceptionMessage = executionContext.LastHostExceptionMessage, - SchedulerTimeZone = schedulerOptions.SchedulerTimeZone?.Id + SchedulerTimeZone = ToIanaTimeZoneId(schedulerOptions.SchedulerTimeZone) }, dashboardOptions.DashboardJsonOptions); } @@ -737,5 +737,24 @@ private static async Task GetMachineJobs( return Results.Json(machineJobs.Select(x => new TupleResponse { Item1 = x.Item1, Item2 = x.Item2 }).ToArray(), dashboardOptions.DashboardJsonOptions); } + internal static string? ToIanaTimeZoneId(TimeZoneInfo? timeZone) + { + if (timeZone == null) + return null; + + var id = timeZone.Id; + + // Already an IANA id (contains '/') + if (id.Contains('/') || id == "UTC") + return id; + + // Convert Windows timezone id to IANA + if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out var ianaId)) + return ianaId; + + // Fallback: return the original id + return id; + } + #endregion } diff --git a/src/TickerQ.Dashboard/wwwroot/package-lock.json b/src/TickerQ.Dashboard/wwwroot/package-lock.json index ee09bc79..8ba61822 100644 --- a/src/TickerQ.Dashboard/wwwroot/package-lock.json +++ b/src/TickerQ.Dashboard/wwwroot/package-lock.json @@ -43,6 +43,7 @@ "vite": "^6.0.5", "vite-plugin-dts": "^4.5.0", "vite-plugin-vue-devtools": "^7.6.8", + "vitest": "^4.1.0", "vue-tsc": "^2.2.0" } }, @@ -1251,9 +1252,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2018,6 +2019,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -2252,6 +2260,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2492,6 +2518,92 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.11", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz", @@ -3002,6 +3114,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3206,6 +3328,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3639,6 +3771,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -4040,6 +4179,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4930,12 +5079,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/memorystream": { @@ -5294,6 +5443,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -6050,6 +6210,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6114,6 +6281,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -6412,6 +6593,81 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6855,6 +7111,138 @@ "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", @@ -7114,6 +7502,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/src/TickerQ.Dashboard/wwwroot/package.json b/src/TickerQ.Dashboard/wwwroot/package.json index 68ba44d0..b811af8e 100644 --- a/src/TickerQ.Dashboard/wwwroot/package.json +++ b/src/TickerQ.Dashboard/wwwroot/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", + "test": "vitest run", "lint": "eslint . --fix", "format": "prettier --write src/" }, @@ -48,6 +49,7 @@ "vite": "^6.0.5", "vite-plugin-dts": "^4.5.0", "vite-plugin-vue-devtools": "^7.6.8", + "vitest": "^4.1.0", "vue-tsc": "^2.2.0" } } diff --git a/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts b/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts new file mode 100644 index 00000000..ee3dc071 --- /dev/null +++ b/src/TickerQ.Dashboard/wwwroot/src/__tests__/dateTimeParser.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { getDateFormatRegion, buildDatePart, formatDate, formatTime } from '../utilities/dateTimeParser'; + +describe('getDateFormatRegion', () => { + it('returns "us" for America/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('America/New_York')).toBe('us'); + expect(getDateFormatRegion('America/Chicago')).toBe('us'); + expect(getDateFormatRegion('America/Los_Angeles')).toBe('us'); + }); + + it('returns "us" for US/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('US/Eastern')).toBe('us'); + }); + + it('returns "eu" for Europe/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('Europe/London')).toBe('eu'); + expect(getDateFormatRegion('Europe/Berlin')).toBe('eu'); + }); + + it('returns "eu" for Africa/ prefixed IANA timezone', () => { + expect(getDateFormatRegion('Africa/Cairo')).toBe('eu'); + }); + + it('returns "iso" for undefined or empty timezone', () => { + expect(getDateFormatRegion(undefined)).toBe('iso'); + expect(getDateFormatRegion('')).toBe('iso'); + }); + + it('returns "iso" for unrecognized timezone', () => { + expect(getDateFormatRegion('Asia/Tokyo')).toBe('iso'); + expect(getDateFormatRegion('UTC')).toBe('iso'); + }); + + // Windows timezone ID fallback handling + it('returns "us" for Windows American timezone IDs', () => { + expect(getDateFormatRegion('Eastern Standard Time')).toBe('us'); + expect(getDateFormatRegion('Central Standard Time')).toBe('us'); + expect(getDateFormatRegion('Mountain Standard Time')).toBe('us'); + expect(getDateFormatRegion('Pacific Standard Time')).toBe('us'); + expect(getDateFormatRegion('Alaskan Standard Time')).toBe('us'); + expect(getDateFormatRegion('Hawaiian Standard Time')).toBe('us'); + }); + + it('returns "eu" for Windows European timezone IDs', () => { + expect(getDateFormatRegion('W. Europe Standard Time')).toBe('eu'); + expect(getDateFormatRegion('Central European Standard Time')).toBe('eu'); + expect(getDateFormatRegion('E. Europe Standard Time')).toBe('eu'); + expect(getDateFormatRegion('GMT Standard Time')).toBe('eu'); + expect(getDateFormatRegion('Greenwich Standard Time')).toBe('eu'); + }); +}); + +describe('buildDatePart', () => { + it('builds US format MM/DD/YYYY', () => { + expect(buildDatePart('us', '2026', '03', '18')).toBe('03/18/2026'); + }); + + it('builds EU format DD/MM/YYYY', () => { + expect(buildDatePart('eu', '2026', '03', '18')).toBe('18/03/2026'); + }); + + it('builds ISO format YYYY-MM-DD', () => { + expect(buildDatePart('iso', '2026', '03', '18')).toBe('2026-03-18'); + }); +}); + +describe('formatDate', () => { + it('returns empty string for falsy input', () => { + expect(formatDate('')).toBe(''); + expect(formatDate(null as unknown as string)).toBe(''); + }); + + it('formats a UTC date string with IANA timezone', () => { + const result = formatDate('2026-03-18T04:00:00Z', true, 'America/New_York'); + // Should display as US format (MM/DD/YYYY) in Eastern time + // 04:00 UTC = 00:00 EST (March 18) or 23:00 EST (March 17) depending on DST + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('formats a UTC date string with Europe timezone', () => { + const result = formatDate('2026-03-18T12:00:00Z', true, 'Europe/London'); + // Should display as EU format (DD/MM/YYYY) + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}$/); + }); + + it('formats date without time when includeTime is false', () => { + const result = formatDate('2026-03-18T12:00:00Z', false, 'America/New_York'); + expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + expect(result).not.toContain(':'); + }); + + it('appends Z to strings without offset for UTC interpretation', () => { + // A datetime without Z should be treated as UTC + const withZ = formatDate('2026-03-18T12:00:00Z', false, 'UTC'); + const withoutZ = formatDate('2026-03-18T12:00:00', false, 'UTC'); + expect(withZ).toBe(withoutZ); + }); + + it('gracefully handles invalid timezone by falling back to browser local', () => { + // Windows timezone IDs are not valid for Intl.DateTimeFormat + // Should not throw, should still produce a formatted date + const result = formatDate('2026-03-18T12:00:00Z', true, 'Eastern Standard Time'); + expect(result).toBeTruthy(); + // The fallback uses 'us' region detection for Windows American IDs + // but the Intl formatter falls back to no timezone, producing a valid date + expect(result).toMatch(/\d{4}.*\d{2}.*\d{2}/); + }); + + it('handles date boundary correctly for EST timezone', () => { + // 2026-03-18T03:00:00Z = March 17 at 10pm EST (EST = UTC-5) + // In March 2026, DST is active (EDT = UTC-4), so this is March 17 at 11pm EDT + const result = formatDate('2026-03-18T03:00:00Z', false, 'America/New_York'); + // Should show March 17, not March 18 + expect(result).toBe('03/17/2026'); + }); + + it('handles date with space separator instead of T', () => { + const result = formatDate('2026-03-18 12:00:00', false, 'UTC'); + expect(result).toMatch(/2026/); + }); +}); + +describe('formatTime', () => { + it('formats milliseconds', () => { + expect(formatTime(500, true)).toBe('500ms'); + }); + + it('formats seconds', () => { + expect(formatTime(45)).toBe('45s'); + }); + + it('formats minutes and seconds', () => { + expect(formatTime(90)).toBe('1m 30s'); + }); + + it('formats hours and minutes', () => { + expect(formatTime(3660)).toBe('1h 1m'); + }); + + it('formats days and hours', () => { + expect(formatTime(90000)).toBe('1d 1h'); + }); + + it('formats exact minutes without seconds', () => { + expect(formatTime(120)).toBe('2m'); + }); + + it('formats exact hours without minutes', () => { + expect(formatTime(7200)).toBe('2h'); + }); + + it('formats exact days without hours', () => { + expect(formatTime(86400)).toBe('1d'); + }); +}); diff --git a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts index d903df60..31b39c9e 100644 --- a/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts +++ b/src/TickerQ.Dashboard/wwwroot/src/utilities/dateTimeParser.ts @@ -5,6 +5,10 @@ type DateFormatRegion = 'eu' | 'us' | 'iso'; const europeanZonePrefixes = ['Europe/', 'Africa/']; const americanZonePrefixes = ['America/', 'US/']; +// Fallback patterns for Windows timezone IDs that didn't get converted to IANA on the backend +const windowsEuropeanPatterns = ['W. Europe', 'Central Europe', 'E. Europe', 'GMT ', 'Greenwich']; +const windowsAmericanPatterns = ['Eastern', 'Central Standard', 'Mountain', 'Pacific', 'Alaskan', 'Hawaiian', 'US ']; + export function getDateFormatRegion(timeZone?: string): DateFormatRegion { if (!timeZone) return 'iso'; @@ -16,6 +20,16 @@ export function getDateFormatRegion(timeZone?: string): DateFormatRegion { return 'us'; } + // Handle Windows timezone IDs as fallback (check European first to avoid + // "Central European" matching the American "Central Standard" pattern) + if (windowsEuropeanPatterns.some(p => timeZone.startsWith(p))) { + return 'eu'; + } + + if (windowsAmericanPatterns.some(p => timeZone.startsWith(p))) { + return 'us'; + } + return 'iso'; } @@ -44,25 +58,33 @@ export function formatDate( const dateObj = new Date(iso); - const options: Intl.DateTimeFormatOptions = { + const baseOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit', - ...(timeZone ? { timeZone } : {}), }; if (includeTime) { - options.hour = '2-digit'; - options.minute = '2-digit'; - options.second = '2-digit'; - options.hour12 = false; + baseOptions.hour = '2-digit'; + baseOptions.minute = '2-digit'; + baseOptions.second = '2-digit'; + baseOptions.hour12 = false; + } + + // Try with the provided timezone; fall back to no timezone if it's invalid (e.g. Windows timezone ID) + let formatter: Intl.DateTimeFormat; + let effectiveTimeZone = timeZone; + try { + formatter = new Intl.DateTimeFormat('en-CA', { ...baseOptions, ...(timeZone ? { timeZone } : {}) }); + } catch { + effectiveTimeZone = undefined; + formatter = new Intl.DateTimeFormat('en-CA', baseOptions); } - const formatter = new Intl.DateTimeFormat('en-CA', options); const parts = formatter.formatToParts(dateObj); const get = (type: string) => parts.find(p => p.type === type)?.value ?? ''; - const region = getDateFormatRegion(timeZone); + const region = getDateFormatRegion(effectiveTimeZone); const datePart = buildDatePart(region, get('year'), get('month'), get('day')); if (!includeTime) { diff --git a/src/TickerQ.Dashboard/wwwroot/vitest.config.ts b/src/TickerQ.Dashboard/wwwroot/vitest.config.ts new file mode 100644 index 00000000..d4793df2 --- /dev/null +++ b/src/TickerQ.Dashboard/wwwroot/vitest.config.ts @@ -0,0 +1,11 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, + }, + test: { + include: ['src/**/__tests__/**/*.test.ts'], + }, +}) diff --git a/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs b/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs index ffcf2a5a..c87f5f7e 100644 --- a/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs +++ b/src/TickerQ.EntityFrameworkCore/Configurations/CronTickerConfigurations.cs @@ -28,7 +28,8 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.IsEnabled) .IsRequired() - .HasDefaultValue(true); + .HasDefaultValueSql("1") + .HasSentinel(true); builder.ToTable("CronTickers", _schema); } diff --git a/src/TickerQ.Utilities/TickerFunctionProvider.cs b/src/TickerQ.Utilities/TickerFunctionProvider.cs index 9da5321f..61bc847b 100644 --- a/src/TickerQ.Utilities/TickerFunctionProvider.cs +++ b/src/TickerQ.Utilities/TickerFunctionProvider.cs @@ -20,15 +20,19 @@ namespace TickerQ.Utilities /// public static class TickerFunctionProvider { + private static readonly object _buildLock = new(); + // Callback actions to collect registrations private static Action> _requestTypeRegistrations; private static Action> _requestInfoRegistrations; private static Action> _functionRegistrations; - + // Final frozen dictionaries - public static FrozenDictionary TickerFunctionRequestTypes; - public static FrozenDictionary TickerFunctionRequestInfos; - public static FrozenDictionary TickerFunctions; + public static FrozenDictionary TickerFunctionRequestTypes = FrozenDictionary.Empty; + public static FrozenDictionary TickerFunctionRequestInfos = FrozenDictionary.Empty; + public static FrozenDictionary TickerFunctions = FrozenDictionary.Empty; + + public static bool IsBuilt { get; private set; } /// /// Registers ticker functions during application startup by adding to the callback chain. @@ -158,57 +162,36 @@ internal static void UpdateCronExpressionsFromIConfiguration(IConfiguration conf /// public static void Build() { - // Build functions dictionary - if (_functionRegistrations != null) + lock (_buildLock) { - // Single pass: execute callbacks directly on final dictionary - var functionsDict = new Dictionary(); - _functionRegistrations(functionsDict); - TickerFunctions = functionsDict.ToFrozenDictionary(); - _functionRegistrations = null; // Release callback chain - } - else - { - if (TickerFunctions == null) + // Build functions dictionary + if (_functionRegistrations != null) { - TickerFunctions = new Dictionary() - .ToFrozenDictionary(); + var functionsDict = new Dictionary(); + _functionRegistrations(functionsDict); + TickerFunctions = functionsDict.ToFrozenDictionary(); + _functionRegistrations = null; } - } - // Build request types dictionary - if (_requestTypeRegistrations != null) - { - // Single pass: execute callbacks directly on final dictionary - var requestTypesDict = new Dictionary(); - _requestTypeRegistrations(requestTypesDict); - TickerFunctionRequestTypes = requestTypesDict.ToFrozenDictionary(); - _requestTypeRegistrations = null; // Release callback chain - } - else - { - if (TickerFunctionRequestTypes == null) + // Build request types dictionary + if (_requestTypeRegistrations != null) { - TickerFunctionRequestTypes = new Dictionary() - .ToFrozenDictionary(); + var requestTypesDict = new Dictionary(); + _requestTypeRegistrations(requestTypesDict); + TickerFunctionRequestTypes = requestTypesDict.ToFrozenDictionary(); + _requestTypeRegistrations = null; } - } - // Build request info dictionary (string type + example JSON) - if (_requestInfoRegistrations != null) - { - var requestInfoDict = new Dictionary(); - _requestInfoRegistrations(requestInfoDict); - TickerFunctionRequestInfos = requestInfoDict.ToFrozenDictionary(); - _requestInfoRegistrations = null; - } - else - { - if (TickerFunctionRequestInfos == null) + // Build request info dictionary (string type + example JSON) + if (_requestInfoRegistrations != null) { - TickerFunctionRequestInfos = new Dictionary() - .ToFrozenDictionary(); + var requestInfoDict = new Dictionary(); + _requestInfoRegistrations(requestInfoDict); + TickerFunctionRequestInfos = requestInfoDict.ToFrozenDictionary(); + _requestInfoRegistrations = null; } + + IsBuilt = true; } } } diff --git a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs index ca76586d..3cc261ad 100644 --- a/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs +++ b/src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs @@ -1,7 +1,4 @@ -ο»Ώusing System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; +using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -24,7 +21,7 @@ public static class TickerQServiceExtensions { public static IServiceCollection AddTickerQ(this IServiceCollection services, Action> optionsBuilder = null) => AddTickerQ(services, optionsBuilder); - + public static IServiceCollection AddTickerQ(this IServiceCollection services, Action> optionsBuilder = null) where TTimeTicker : TimeTickerEntity, new() where TCronTicker : CronTickerEntity, new() @@ -34,13 +31,13 @@ public static IServiceCollection AddTickerQ(this IServ var optionInstance = new TickerOptionsBuilder(tickerExecutionContext, schedulerOptionsBuilder); optionsBuilder?.Invoke(optionInstance); CronScheduleCache.TimeZoneInfo = schedulerOptionsBuilder.SchedulerTimeZone; - + // Apply JSON serializer options for ticker requests if configured during service registration if (optionInstance.RequestJsonSerializerOptions != null) { TickerHelper.RequestJsonSerializerOptions = optionInstance.RequestJsonSerializerOptions; } - + // Configure whether ticker request payloads should use GZip compression TickerHelper.UseGZipCompression = optionInstance.RequestGZipCompressionEnabled; services.AddSingleton, TickerManager>(); @@ -49,14 +46,20 @@ public static IServiceCollection AddTickerQ(this IServ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + + // Register the initializer hosted service BEFORE scheduler services + // to guarantee seeding completes before the scheduler starts polling. + // Registered as a singleton so UseTickerQ can resolve it to set the initialization flag. + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + // Only register background services if enabled (default is true) if (optionInstance.RegisterBackgroundServices) { services.AddSingleton(); - services.AddSingleton(provider => + services.AddSingleton(provider => provider.GetRequiredService()); - services.AddHostedService(provider => + services.AddHostedService(provider => provider.GetRequiredService()); services.AddHostedService(provider => provider.GetRequiredService()); services.AddSingleton(); @@ -78,7 +81,7 @@ public static IServiceCollection AddTickerQ(this IServ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + optionInstance.ExternalProviderConfigServiceAction?.Invoke(services); // Register in-memory persistence as fallback β€” only used when no external provider @@ -89,35 +92,45 @@ public static IServiceCollection AddTickerQ(this IServ if (optionInstance.TickerExceptionHandlerType != null) services.AddSingleton(typeof(ITickerExceptionHandler), optionInstance.TickerExceptionHandlerType); - + services.AddSingleton(_ => optionInstance); services.AddSingleton(_ => tickerExecutionContext); services.AddSingleton(_ => schedulerOptionsBuilder); + + // Register AFTER initializer and scheduler to ensure it runs last + services.AddHostedService(); + return services; } - + /// - /// Initializes TickerQ for generic host applications (Console, MAUI, WPF, Worker Services, etc.) + /// Initializes TickerQ for generic host applications (Console, MAUI, WPF, Worker Services, etc.). + /// + /// This method configures middleware and scheduler settings. All I/O-bound + /// initialization (function discovery, database seeding, external provider startup) + /// is deferred to which runs when + /// the host starts. This means design-time tools that build the host without + /// starting it (OpenAPI generators, EF migration tools, etc.) will not trigger + /// database operations. /// public static IHost UseTickerQ(this IHost host, TickerQStartMode qStartMode = TickerQStartMode.Immediate) - { - InitializeTickerQ(host, qStartMode); - return host; - } - - private static void InitializeTickerQ(IHost host, TickerQStartMode qStartMode) { var serviceProvider = host.Services; var tickerExecutionContext = serviceProvider.GetService(); - var configuration = serviceProvider.GetService(); var notificationHubSender = serviceProvider.GetService(); var backgroundScheduler = serviceProvider.GetService(); - + + // Signal the initializer hosted service that UseTickerQ was called, + // so it should perform startup I/O when the host starts. + var initializer = serviceProvider.GetService(); + if (initializer != null) + initializer.InitializationRequested = true; + // If background services are registered, configure them if (backgroundScheduler != null) { backgroundScheduler.SkipFirstRun = qStartMode == TickerQStartMode.Manual; - + tickerExecutionContext.NotifyCoreAction += (value, type) => { if (type == CoreNotifyActionType.NotifyHostExceptionMessage) @@ -135,52 +148,15 @@ private static void InitializeTickerQ(IHost host, TickerQStartMode qStartMode) } // If background services are not registered (due to DisableBackgroundServices()), // silently skip background service configuration. This is expected behavior. - + if (tickerExecutionContext?.DashboardApplicationAction != null) { // Cast object back to IApplicationBuilder for Dashboard middleware tickerExecutionContext.DashboardApplicationAction(host); tickerExecutionContext.DashboardApplicationAction = null; } - - TickerFunctionProvider.UpdateCronExpressionsFromIConfiguration(configuration); - TickerFunctionProvider.Build(); - - // Run core seeding pipeline based on main options (works for both in-memory and EF providers). - var options = tickerExecutionContext.OptionsSeeding; - - if (options == null || options.SeedDefinedCronTickers) - { - SeedDefinedCronTickers(serviceProvider).GetAwaiter().GetResult(); - } - if (options?.TimeSeederAction != null) - { - options.TimeSeederAction(serviceProvider).GetAwaiter().GetResult(); - } - - if (options?.CronSeederAction != null) - { - options.CronSeederAction(serviceProvider).GetAwaiter().GetResult(); - } - - // Let external providers (e.g., EF Core) perform their own startup logic (dead-node cleanup, etc.). - if (tickerExecutionContext.ExternalProviderApplicationAction != null) - { - tickerExecutionContext.ExternalProviderApplicationAction(serviceProvider); - tickerExecutionContext.ExternalProviderApplicationAction = null; - } - } - - private static async Task SeedDefinedCronTickers(IServiceProvider serviceProvider) - { - var internalTickerManager = serviceProvider.GetRequiredService(); - - var functionsToSeed = TickerFunctionProvider.TickerFunctions - .Where(x => !string.IsNullOrEmpty(x.Value.cronExpression)) - .Select(x => (x.Key, x.Value.cronExpression)).ToArray(); - - await internalTickerManager.MigrateDefinedCronTickers(functionsToSeed); + return host; } } } diff --git a/src/TickerQ/Src/BackgroundServices/TickerQInitializerHostedService.cs b/src/TickerQ/Src/BackgroundServices/TickerQInitializerHostedService.cs new file mode 100644 index 00000000..bcc10e29 --- /dev/null +++ b/src/TickerQ/Src/BackgroundServices/TickerQInitializerHostedService.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TickerQ.Provider; +using TickerQ.Utilities; +using TickerQ.Utilities.Interfaces.Managers; + +namespace TickerQ.BackgroundServices; + +/// +/// Performs TickerQ startup initialization (function discovery, cron ticker seeding, +/// external provider setup) as part of the host lifecycle. +/// +/// By running inside instead of inline in +/// UseTickerQ, this service is naturally skipped by design-time tools +/// (OpenAPI generators, EF migrations, etc.) that build the host but never start it. +/// Registered before the scheduler services to guarantee seeding completes first. +/// +internal sealed class TickerQInitializerHostedService : IHostedService +{ + private readonly TickerExecutionContext _executionContext; + private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; + + /// + /// Set to true by UseTickerQ to signal that this hosted service + /// should perform startup I/O when the host starts. When false (default), + /// is a no-op β€” this naturally prevents initialization + /// in design-time tool contexts where UseTickerQ is never called. + /// + internal bool InitializationRequested { get; set; } + + public TickerQInitializerHostedService( + TickerExecutionContext executionContext, + IServiceProvider serviceProvider, + IConfiguration configuration) + { + _executionContext = executionContext; + _serviceProvider = serviceProvider; + _configuration = configuration; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!InitializationRequested) + return; + + TickerFunctionProvider.UpdateCronExpressionsFromIConfiguration(_configuration); + TickerFunctionProvider.Build(); + + var options = _executionContext.OptionsSeeding; + + if (options == null || options.SeedDefinedCronTickers) + { + await SeedDefinedCronTickers(_serviceProvider); + } + + if (options?.TimeSeederAction != null) + { + await options.TimeSeederAction(_serviceProvider); + } + + if (options?.CronSeederAction != null) + { + await options.CronSeederAction(_serviceProvider); + } + + if (_executionContext.ExternalProviderApplicationAction != null) + { + _executionContext.ExternalProviderApplicationAction(_serviceProvider); + _executionContext.ExternalProviderApplicationAction = null; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static async Task SeedDefinedCronTickers(IServiceProvider serviceProvider) + { + var internalTickerManager = serviceProvider.GetRequiredService(); + + var functionsToSeed = TickerFunctionProvider.TickerFunctions + .Where(x => !string.IsNullOrEmpty(x.Value.cronExpression)) + .Select(x => (x.Key, x.Value.cronExpression)).ToArray(); + + await internalTickerManager.MigrateDefinedCronTickers(functionsToSeed); + } +} diff --git a/src/TickerQ/Src/BackgroundServices/TickerQStartupValidator.cs b/src/TickerQ/Src/BackgroundServices/TickerQStartupValidator.cs new file mode 100644 index 00000000..f671749d --- /dev/null +++ b/src/TickerQ/Src/BackgroundServices/TickerQStartupValidator.cs @@ -0,0 +1,45 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TickerQ.Utilities; +using TickerQ.Utilities.Enums; + +namespace TickerQ.BackgroundServices; + +internal class TickerQStartupValidator : IHostedService +{ + private readonly TickerExecutionContext _executionContext; + private readonly TickerQInitializerHostedService _initializer; + private readonly ILogger _logger; + + public TickerQStartupValidator( + TickerExecutionContext executionContext, + TickerQInitializerHostedService initializer, + ILogger logger) + { + _executionContext = executionContext; + _initializer = initializer; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!_initializer.InitializationRequested) + { + const string message = "TickerQ β€” UseTickerQ() was not called. Call app.UseTickerQ() before app.Run() to initialize the scheduler."; + _logger.LogWarning(message); + _executionContext.NotifyCoreAction?.Invoke(message, CoreNotifyActionType.NotifyHostExceptionMessage); + } + else if (TickerFunctionProvider.TickerFunctions.Count == 0) + { + const string message = "TickerQ β€” No ticker functions registered. Ensure you have methods decorated with [TickerFunction]."; + _logger.LogWarning(message); + _executionContext.NotifyCoreAction?.Invoke(message, CoreNotifyActionType.NotifyHostExceptionMessage); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/CronTickerConfigurationTests.cs b/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/CronTickerConfigurationTests.cs new file mode 100644 index 00000000..4a38c686 --- /dev/null +++ b/tests/TickerQ.EntityFrameworkCore.Tests/Infrastructure/CronTickerConfigurationTests.cs @@ -0,0 +1,188 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +using TickerQ.EntityFrameworkCore.Configurations; +using TickerQ.Utilities.Entities; + +namespace TickerQ.EntityFrameworkCore.Tests.Infrastructure; + +public class CronTickerConfigurationTests : IAsyncLifetime +{ + private SqliteConnection _connection; + private DbContextOptions _options; + private TestTickerQDbContext _context; + + public async Task InitializeAsync() + { + _connection = new SqliteConnection("Data Source=:memory:"); + await _connection.OpenAsync(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _context = new TestTickerQDbContext(_options); + await _context.Database.EnsureCreatedAsync(); + } + + public async Task DisposeAsync() + { + await _context.DisposeAsync(); + await _connection.DisposeAsync(); + } + + [Fact] + public void IsEnabled_Uses_DefaultValueSql_Not_DefaultValue() + { + var entityType = _context.Model.FindEntityType(typeof(CronTickerEntity))!; + var property = entityType.FindProperty(nameof(CronTickerEntity.IsEnabled))!; + + // HasDefaultValueSql sets the relational default SQL, not the CLR default value + var relational = property.GetDefaultValueSql(); + Assert.Equal("1", relational); + + // The sentinel is set to true so EF Core sends false explicitly + var sentinel = property.Sentinel; + Assert.Equal(true, sentinel); + } + + [Fact] + public void IsEnabled_Is_Required() + { + var entityType = _context.Model.FindEntityType(typeof(CronTickerEntity))!; + var property = entityType.FindProperty(nameof(CronTickerEntity.IsEnabled))!; + + Assert.False(property.IsNullable); + } + + [Fact] + public async Task Insert_CronTicker_Without_IsEnabled_Gets_Default_True() + { + // The C# property initializer sets IsEnabled = true, + // and the database default is 1 β€” both paths produce true. + var ticker = new CronTickerEntity + { + Id = Guid.NewGuid(), + Function = "TestFunc", + Expression = "* * * * *", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Request = Array.Empty() + }; + + _context.Set().Add(ticker); + await _context.SaveChangesAsync(); + + // Detach and re-read from DB to verify + _context.ChangeTracker.Clear(); + var fromDb = await _context.Set() + .AsNoTracking() + .FirstAsync(e => e.Id == ticker.Id); + + Assert.True(fromDb.IsEnabled); + } + + [Fact] + public async Task Insert_CronTicker_With_IsEnabled_False_Persists() + { + var ticker = new CronTickerEntity + { + Id = Guid.NewGuid(), + Function = "TestFunc", + Expression = "* * * * *", + IsEnabled = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Request = Array.Empty() + }; + + _context.Set().Add(ticker); + await _context.SaveChangesAsync(); + + _context.ChangeTracker.Clear(); + var fromDb = await _context.Set() + .AsNoTracking() + .FirstAsync(e => e.Id == ticker.Id); + + Assert.False(fromDb.IsEnabled); + } + + [Fact] + public async Task Toggle_IsEnabled_RoundTrips_Correctly() + { + var ticker = new CronTickerEntity + { + Id = Guid.NewGuid(), + Function = "TestFunc", + Expression = "* * * * *", + IsEnabled = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Request = Array.Empty() + }; + + _context.Set().Add(ticker); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + // Toggle to false + var entity = await _context.Set().FirstAsync(e => e.Id == ticker.Id); + entity.IsEnabled = false; + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var fromDb = await _context.Set() + .AsNoTracking() + .FirstAsync(e => e.Id == ticker.Id); + Assert.False(fromDb.IsEnabled); + + // Toggle back to true + var entity2 = await _context.Set().FirstAsync(e => e.Id == ticker.Id); + entity2.IsEnabled = true; + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var fromDb2 = await _context.Set() + .AsNoTracking() + .FirstAsync(e => e.Id == ticker.Id); + Assert.True(fromDb2.IsEnabled); + } + + [Fact] + public async Task Where_IsEnabled_Filter_Returns_Only_Enabled() + { + var enabled = new CronTickerEntity + { + Id = Guid.NewGuid(), + Function = "Enabled", + Expression = "* * * * *", + IsEnabled = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Request = Array.Empty() + }; + var disabled = new CronTickerEntity + { + Id = Guid.NewGuid(), + Function = "Disabled", + Expression = "* * * * *", + IsEnabled = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Request = Array.Empty() + }; + + _context.Set().AddRange(enabled, disabled); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + + var results = await _context.Set() + .AsNoTracking() + .Where(e => e.IsEnabled) + .ToListAsync(); + + Assert.Single(results); + Assert.Equal(enabled.Id, results[0].Id); + } +} diff --git a/tests/TickerQ.Tests/DashboardPathBaseTests.cs b/tests/TickerQ.Tests/DashboardPathBaseTests.cs index 46a0fc09..cd4c790a 100644 --- a/tests/TickerQ.Tests/DashboardPathBaseTests.cs +++ b/tests/TickerQ.Tests/DashboardPathBaseTests.cs @@ -3,7 +3,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using TickerQ.Dashboard.DependencyInjection; @@ -272,4 +274,126 @@ private static async Task CreateTestHost( } #endregion + + #region MapPathBaseAware β€” endpoint routing coexistence (issue #456) + + [Fact] + public async Task MapPathBaseAware_ClearsHostEndpoint_SoBranchRoutingCanReevaluate() + { + // Simulates the scenario in issue #456: host-level routing (e.g. MapStaticAssets) + // sets an endpoint before the dashboard branch runs. The branch must clear it so + // its own UseRouting() re-evaluates against dashboard endpoints. + var mapMethod = typeof(ServiceCollectionExtensions).GetMethod( + "MapPathBaseAware", + BindingFlags.NonPublic | BindingFlags.Static)!; + + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + }); + webBuilder.Configure(app => + { + // Simulate host-level routing setting an endpoint (like MapStaticAssets does) + app.Use(async (context, next) => + { + var dummyEndpoint = new Endpoint( + _ => Task.CompletedTask, + new EndpointMetadataCollection(), + "DummyHostEndpoint"); + context.SetEndpoint(dummyEndpoint); + context.Request.RouteValues["dummy"] = "value"; + await next(); + }); + + var configuration = new Action(branch => + { + branch.Run(async context => + { + // Verify endpoint was cleared inside the branch + var endpoint = context.GetEndpoint(); + var hasRouteValues = context.Request.RouteValues.Count > 0; + await context.Response.WriteAsync( + endpoint == null && !hasRouteValues ? "cleared" : "not-cleared"); + }); + }); + + mapMethod.Invoke(null, new object[] { app, "/dashboard", configuration }); + + app.Run(async context => + { + await context.Response.WriteAsync("fallthrough"); + }); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("cleared", body); + } + + [Fact] + public async Task MapPathBaseAware_WithHostEndpoint_NonMatchingPath_PreservesEndpoint() + { + // Non-matching paths should pass through without clearing the host endpoint + var mapMethod = typeof(ServiceCollectionExtensions).GetMethod( + "MapPathBaseAware", + BindingFlags.NonPublic | BindingFlags.Static)!; + + using var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + }); + webBuilder.Configure(app => + { + app.Use(async (context, next) => + { + var dummyEndpoint = new Endpoint( + _ => Task.CompletedTask, + new EndpointMetadataCollection(), + "DummyHostEndpoint"); + context.SetEndpoint(dummyEndpoint); + await next(); + }); + + var configuration = new Action(branch => + { + branch.Run(async context => + { + await context.Response.WriteAsync("branch-hit"); + }); + }); + + mapMethod.Invoke(null, new object[] { app, "/dashboard", configuration }); + + app.Run(async context => + { + var endpoint = context.GetEndpoint(); + await context.Response.WriteAsync( + endpoint?.DisplayName == "DummyHostEndpoint" ? "preserved" : "lost"); + }); + }); + }) + .StartAsync(); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/other/path"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("preserved", body); + } + + #endregion } diff --git a/tests/TickerQ.Tests/DashboardTimeZoneTests.cs b/tests/TickerQ.Tests/DashboardTimeZoneTests.cs new file mode 100644 index 00000000..cb6c8f99 --- /dev/null +++ b/tests/TickerQ.Tests/DashboardTimeZoneTests.cs @@ -0,0 +1,94 @@ +using System; +using TickerQ.Dashboard.Endpoints; + +namespace TickerQ.Tests; + +/// +/// Tests for ToIanaTimeZoneId β€” ensures the dashboard API always returns +/// IANA timezone identifiers regardless of the host OS. +/// +public class DashboardTimeZoneTests +{ + [Fact] + public void ToIanaTimeZoneId_NullTimeZone_ReturnsNull() + { + var result = DashboardEndpoints.ToIanaTimeZoneId(null); + Assert.Null(result); + } + + [Fact] + public void ToIanaTimeZoneId_UtcTimeZone_ReturnsUtc() + { + var result = DashboardEndpoints.ToIanaTimeZoneId(TimeZoneInfo.Utc); + Assert.Equal("UTC", result); + } + + [Fact] + public void ToIanaTimeZoneId_IanaId_ReturnedUnchanged() + { + // On Linux/macOS, FindSystemTimeZoneById with IANA ID returns IANA ID directly. + // On Windows with .NET 6+, it also supports IANA IDs. + var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + // Should contain '/' indicating IANA format + Assert.Contains("/", result); + Assert.Equal("America/New_York", result); + } + + [Fact] + public void ToIanaTimeZoneId_WindowsId_ConvertedToIana() + { + // "Eastern Standard Time" is the Windows ID for US Eastern + var tz = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + // Should be converted to an IANA ID (America/New_York on most systems) + Assert.NotNull(result); + Assert.NotEqual("Eastern Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_PacificStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Pacific Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_CentralEuropeanStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Central European Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_TokyoStandardTime_ConvertedToIana() + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"); + var result = DashboardEndpoints.ToIanaTimeZoneId(tz); + + Assert.NotNull(result); + Assert.NotEqual("Tokyo Standard Time", result); + Assert.Contains("/", result); + } + + [Fact] + public void ToIanaTimeZoneId_LocalTimeZone_ReturnsNonNull() + { + // Regardless of host OS, local timezone should produce a non-null result + var result = DashboardEndpoints.ToIanaTimeZoneId(TimeZoneInfo.Local); + + Assert.NotNull(result); + Assert.NotEmpty(result); + } +} diff --git a/tests/TickerQ.Tests/DesignTimeToolDetectionTests.cs b/tests/TickerQ.Tests/DesignTimeToolDetectionTests.cs new file mode 100644 index 00000000..c49005bc --- /dev/null +++ b/tests/TickerQ.Tests/DesignTimeToolDetectionTests.cs @@ -0,0 +1,207 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; +using TickerQ.BackgroundServices; +using TickerQ.DependencyInjection; +using TickerQ.Utilities; +using TickerQ.Utilities.Enums; +using TickerQ.Utilities.Interfaces.Managers; + +namespace TickerQ.Tests; + +public class DesignTimeToolDetectionTests +{ + [Fact] + public async Task Initializer_Skips_When_UseTickerQ_Not_Called() + { + // Design-time tools (dotnet-getdocument, dotnet-ef) build the host but + // never call UseTickerQ. Verify the initializer does nothing in that case. + var context = new TickerExecutionContext(); + var serviceProvider = Substitute.For(); + var configuration = Substitute.For(); + + var initializer = new TickerQInitializerHostedService(context, serviceProvider, configuration); + await initializer.StartAsync(CancellationToken.None); + + // InitializationRequested is false by default, so no seeding should occur. + Assert.False(initializer.InitializationRequested); + } + + [Fact] + public void UseTickerQ_Sets_InitializationRequested_On_Initializer() + { + var services = new ServiceCollection(); + services.AddTickerQ(); + + var host = BuildMinimalHost(services); + host.UseTickerQ(); + + var initializer = host.Services.GetRequiredService(); + Assert.True(initializer.InitializationRequested); + } + + [Fact] + public void UseTickerQ_Does_Not_Perform_IO() + { + // UseTickerQ should complete without touching the database or building functions. + var services = new ServiceCollection(); + services.AddTickerQ(); + + var host = BuildMinimalHost(services); + + // This should not throw β€” no DB access, no function scanning + var result = host.UseTickerQ(); + Assert.Same(host, result); + } + + [Fact] + public async Task Initializer_Runs_Seeding_When_InitializationRequested() + { + var internalManager = Substitute.For(); + var context = new TickerExecutionContext(); + var configuration = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(context); + services.AddSingleton(configuration); + services.AddSingleton(internalManager); + + var sp = services.BuildServiceProvider(); + var initializer = new TickerQInitializerHostedService(context, sp, configuration); + initializer.InitializationRequested = true; + + await initializer.StartAsync(CancellationToken.None); + + // MigrateDefinedCronTickers should have been called (even if with empty list) + await internalManager.Received(1).MigrateDefinedCronTickers(Arg.Any<(string, string)[]>()); + } + + [Fact] + public async Task Initializer_Respects_IgnoreSeedDefinedCronTickers() + { + var internalManager = Substitute.For(); + + var services = new ServiceCollection(); + services.AddTickerQ(options => options.IgnoreSeedDefinedCronTickers()); + + var host = BuildMinimalHost(services); + host.UseTickerQ(); + + var context = host.Services.GetRequiredService(); + var configuration = host.Services.GetRequiredService(); + + // Create a new initializer with the real context that has seeding disabled + var initializer = new TickerQInitializerHostedService(context, host.Services, configuration); + initializer.InitializationRequested = true; + + await initializer.StartAsync(CancellationToken.None); + + // Seeding should be skipped β€” IInternalTickerManager was not registered via AddTickerQ's + // mock, so if it tried to seed, it would call the real (non-mock) manager. + // The real assertion: IgnoreSeedDefinedCronTickers sets the flag correctly. + var optionsSeeding = context.OptionsSeeding; + Assert.NotNull(optionsSeeding); + Assert.False(optionsSeeding.SeedDefinedCronTickers); + } + + [Fact] + public async Task Initializer_Runs_External_Provider_Action() + { + var actionCalled = false; + var context = new TickerExecutionContext(); + context.ExternalProviderApplicationAction = _ => actionCalled = true; + + var internalManager = Substitute.For(); + var configuration = Substitute.For(); + + var services = new ServiceCollection(); + services.AddSingleton(context); + services.AddSingleton(configuration); + services.AddSingleton(internalManager); + + var sp = services.BuildServiceProvider(); + var initializer = new TickerQInitializerHostedService(context, sp, configuration); + initializer.InitializationRequested = true; + + await initializer.StartAsync(CancellationToken.None); + + Assert.True(actionCalled); + Assert.Null(context.ExternalProviderApplicationAction); + } + + [Fact] + public async Task Initializer_StopAsync_Is_NoOp() + { + var context = new TickerExecutionContext(); + var sp = Substitute.For(); + var config = Substitute.For(); + + var initializer = new TickerQInitializerHostedService(context, sp, config); + + // Should complete immediately without exceptions + await initializer.StopAsync(CancellationToken.None); + } + + [Fact] + public void Initializer_Registered_Before_Scheduler_Services() + { + var services = new ServiceCollection(); + services.AddTickerQ(); + + // Verify registration order: initializer should appear before scheduler + var hostedServiceDescriptors = services + .Where(d => d.ServiceType == typeof(IHostedService)) + .ToList(); + + Assert.True(hostedServiceDescriptors.Count >= 1); + + // First hosted service should be the initializer (resolved via factory from singleton) + var first = hostedServiceDescriptors[0]; + // The factory registration resolves TickerQInitializerHostedService + var sp = BuildMinimalHost(new ServiceCollection()).Services; + // Just verify the initializer singleton is registered before scheduler + var allSingletons = services + .Where(d => d.Lifetime == ServiceLifetime.Singleton) + .Select(d => d.ServiceType ?? d.ImplementationType) + .ToList(); + + var initializerIndex = allSingletons.IndexOf(typeof(TickerQInitializerHostedService)); + var schedulerIndex = allSingletons.IndexOf(typeof(TickerQSchedulerBackgroundService)); + + Assert.True(initializerIndex >= 0, "TickerQInitializerHostedService should be registered"); + // Scheduler may or may not be registered (depends on DisableBackgroundServices) + if (schedulerIndex >= 0) + { + Assert.True(initializerIndex < schedulerIndex, + "Initializer should be registered before scheduler"); + } + } + + private static IHost BuildMinimalHost(IServiceCollection services) + { + services.TryAddSingleton(new ConfigurationBuilder().Build()); + services.TryAddSingleton(Substitute.For()); + services.TryAddSingleton(Substitute.For()); + services.AddLogging(); + return new MinimalHost(services.BuildServiceProvider()); + } + + /// + /// Minimal IHost implementation for testing UseTickerQ without a full host builder. + /// + private sealed class MinimalHost : IHost + { + public IServiceProvider Services { get; } + + public MinimalHost(IServiceProvider services) => Services = services; + + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public void Dispose() { } + } +} diff --git a/tests/TickerQ.Tests/TickerFunctionProviderTests.cs b/tests/TickerQ.Tests/TickerFunctionProviderTests.cs index ba3eaafc..f67fb801 100644 --- a/tests/TickerQ.Tests/TickerFunctionProviderTests.cs +++ b/tests/TickerQ.Tests/TickerFunctionProviderTests.cs @@ -37,12 +37,16 @@ private static void ResetProvider() var type = typeof(TickerFunctionProvider); const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public; - type.GetField("TickerFunctions", flags)!.SetValue(null, null); - type.GetField("TickerFunctionRequestTypes", flags)!.SetValue(null, null); - type.GetField("TickerFunctionRequestInfos", flags)!.SetValue(null, null); + type.GetField("TickerFunctions", flags)!.SetValue(null, + System.Collections.Frozen.FrozenDictionary.Empty); + type.GetField("TickerFunctionRequestTypes", flags)!.SetValue(null, + System.Collections.Frozen.FrozenDictionary.Empty); + type.GetField("TickerFunctionRequestInfos", flags)!.SetValue(null, + System.Collections.Frozen.FrozenDictionary.Empty); type.GetField("_functionRegistrations", flags)!.SetValue(null, null); type.GetField("_requestTypeRegistrations", flags)!.SetValue(null, null); type.GetField("_requestInfoRegistrations", flags)!.SetValue(null, null); + type.GetProperty("IsBuilt", flags)!.SetValue(null, false); } private static Task NoOpDelegate(CancellationToken ct, IServiceProvider sp, TickerQ.Utilities.Base.TickerFunctionContext ctx)