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)