Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
# Default: C:\Tools\mailpit
#MAILPIT_INSTALL_DIR=C:\Tools\mailpit

# Hangfire Scheduler (Enabled by Default)
# Master toggle for the background scheduler. Defaults to true; the app
# registers Hangfire and starts the background server. Set to false to
# disable the scheduler in this environment. Hangfire's tables live in
# the VIPER database under the HangFire schema.
#Hangfire__Enabled=false

# Jenkins Build Trigger (pre-push hook)
# Get your API token from Jenkins: User menu > Configure > API Token
#JENKINS_USER=your-username
Expand Down
42 changes: 35 additions & 7 deletions VueApp/src/layouts/LeftNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
clickable
v-ripple
:href="menuItem.menuItemUrl"
target="_blank"
rel="noopener noreferrer"
:target="menuItem.isExternalSite ? '_blank' : undefined"
:rel="menuItem.isExternalSite ? 'noopener noreferrer' : undefined"
:class="menuItem.displayClass"
>
<q-item-section>
Expand Down Expand Up @@ -162,6 +162,22 @@ function isItemActive(routeTo: string | null): boolean {
return score > 0 && score === bestMatchScore.value
}

// True when the URL resolves to a real SPA route. Vue Router's catch-all
// (path: "/:catchAll(.*)*" etc.) matches anything not otherwise registered,
// so a successful resolve isn't enough — we also reject paths that match
// only via a regex catch-all segment.
function isInSpaRoute(url: string): boolean {
try {
const matched = router.resolve(url).matched
if (matched.length === 0) {
return false
}
return matched.some((r) => !/\(\.\*\)/.test(r.path))
} catch {
return false
}
}
Comment thread
rlorenzo marked this conversation as resolved.

type OverflowTitleElement = HTMLElement & {
_overflowTitleObserver?: ResizeObserver
}
Expand Down Expand Up @@ -252,17 +268,29 @@ async function getLeftNav() {
}
}

let routeToUrl = null
// Resolve to either an in-SPA route (RouterLink, client-side nav)
// or a same-tab anchor (full page load). URLs that don't match
// any registered SPA route fall through to the catch-all 404 if
// RouterLink-handled, so render those as plain anchors instead.
let routeToUrl: string | null = null
let internalAnchorUrl: string | undefined = undefined
if (!isExternalUrl && r.menuItemURL.length > 0) {
if (isRelativeUrl && props.navarea && props.nav) {
routeToUrl = `/${props.nav.toUpperCase()}/${r.menuItemURL}`
const candidate =
isRelativeUrl && props.navarea && props.nav
? `/${props.nav.toUpperCase()}/${r.menuItemURL}`
: r.menuItemURL
Comment thread
rlorenzo marked this conversation as resolved.
if (isInSpaRoute(candidate)) {
routeToUrl = candidate
} else {
routeToUrl = r.menuItemURL
// Plain-anchor hrefs need the SPA's base path prepended
// (e.g. `/2/`) since the browser won't apply Vue Router's
// base for direct hrefs. router.resolve handles this.
internalAnchorUrl = router.resolve(candidate).href
}
Comment thread
rlorenzo marked this conversation as resolved.
}

return {
menuItemUrl: isExternalUrl ? r.menuItemURL : undefined,
menuItemUrl: isExternalUrl ? r.menuItemURL : internalAnchorUrl,
routeTo: routeToUrl,
menuItemText: r.menuItemText,
clickable: r.menuItemURL.length > 0,
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

166 changes: 166 additions & 0 deletions test/HealthChecks/HangfireHealthCheckTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NSubstitute;
using Viper.Classes.HealthChecks;

namespace Viper.test.HealthChecks
{
public class HangfireHealthCheckTests
{
private static HealthCheckContext CreateContext(HangfireHealthCheck sut)
{
return new HealthCheckContext
{
Registration = new HealthCheckRegistration("hangfire", sut, null, null)
};
}

private static (Hangfire.JobStorage storage, Hangfire.Storage.IMonitoringApi monitoring) CreateStorage()
{
var monitoring = Substitute.For<Hangfire.Storage.IMonitoringApi>();
var storage = Substitute.For<Hangfire.JobStorage>();
storage.GetMonitoringApi().Returns(monitoring);
return (storage, monitoring);
}

private static Hangfire.Storage.Monitoring.StatisticsDto SampleStats(long servers = 1) => new()
{
Servers = servers,
Enqueued = 2,
Scheduled = 3,
Processing = 4,
Failed = 5,
Recurring = 6
};

[Fact]
public async Task CheckHealthAsync_HealthyWhenServersHaveRecentHeartbeats()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(SampleStats());
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
{
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
});

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Contains("Hangfire OK", result.Description);
Assert.Equal(1L, result.Data["servers"]);
}

[Fact]
public async Task CheckHealthAsync_DegradedWhenNoServersRegistered()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(SampleStats(0));
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>());

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Degraded, result.Status);
Assert.Contains("no servers registered", result.Description);
}

[Fact]
public async Task CheckHealthAsync_UnhealthyWhenAllHeartbeatsStale()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(SampleStats());
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
{
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) }
});

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("stale", result.Description);
}

[Fact]
public async Task CheckHealthAsync_UnhealthyWhenServerHasNullHeartbeat()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(SampleStats());
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
{
new() { Name = "srv-never", Heartbeat = null }
});

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("never", result.Description);
}

[Fact]
public async Task CheckHealthAsync_HealthyWhenAtLeastOneHeartbeatIsRecent()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(SampleStats(2));
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
{
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) },
new() { Name = "srv-fresh", Heartbeat = DateTime.UtcNow }
});

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Healthy, result.Status);
Assert.Contains("Hangfire OK", result.Description);
}

[Fact]
public async Task CheckHealthAsync_UnhealthyWhenStorageThrows()
{
var storage = Substitute.For<Hangfire.JobStorage>();
var boom = new InvalidOperationException("boom");
storage.GetMonitoringApi().Returns(_ => throw boom);

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Equal(HealthStatus.Unhealthy, result.Status);
Assert.Contains("unreachable", result.Description);
Assert.Same(boom, result.Exception);
}

[Fact]
public async Task CheckHealthAsync_DataDictionaryContainsAllStatsKeys()
{
var (storage, monitoring) = CreateStorage();
monitoring.GetStatistics().Returns(new Hangfire.Storage.Monitoring.StatisticsDto
{
Servers = 1,
Enqueued = 7,
Scheduled = 8,
Processing = 9,
Failed = 10,
Recurring = 11
});
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
{
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
});

var sut = new HangfireHealthCheck(storage);
var result = await sut.CheckHealthAsync(CreateContext(sut));

Assert.Contains("servers", result.Data.Keys);
Assert.Contains("enqueued", result.Data.Keys);
Assert.Contains("scheduled", result.Data.Keys);
Assert.Contains("processing", result.Data.Keys);
Assert.Contains("failed", result.Data.Keys);
Assert.Contains("recurring", result.Data.Keys);
Assert.Equal(1L, result.Data["servers"]);
Assert.Equal(7L, result.Data["enqueued"]);
Assert.Equal(11L, result.Data["recurring"]);
}
}
}
20 changes: 20 additions & 0 deletions test/RAPS/RapsRoleRefreshScheduledJobTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Reflection;
using Viper.Areas.RAPS.Jobs;
using Viper.Areas.Scheduler.Services;

namespace Viper.test.RAPS
{
public sealed class RapsRoleRefreshScheduledJobTests
{
[Fact]
public void Class_IsDecoratedWithScheduledJob()
{
var attr = typeof(RapsRoleRefreshScheduledJob).GetCustomAttribute<ScheduledJobAttribute>();

Assert.NotNull(attr);
Assert.Equal("raps:role-refresh", attr.Id);
Assert.Equal("0 0 * * *", attr.Cron);
Assert.Equal("Pacific Standard Time", attr.TimeZoneId);
}
}
}
Loading
Loading