diff --git a/test/HealthChecks/PhotoGalleryHealthCheckTests.cs b/test/HealthChecks/PhotoGalleryHealthCheckTests.cs new file mode 100644 index 00000000..92bb8953 --- /dev/null +++ b/test/HealthChecks/PhotoGalleryHealthCheckTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Viper.Classes.HealthChecks; + +namespace Viper.test.HealthChecks +{ + public sealed class PhotoGalleryHealthCheckTests : IDisposable + { + private readonly string _tempDir; + + public PhotoGalleryHealthCheckTests() + { + _tempDir = Path.Join(Path.GetTempPath(), "photo-gallery-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + private void WriteFile(string name) => File.WriteAllBytes(Path.Join(_tempDir, name), Array.Empty()); + + private static HealthCheckContext CreateContext(PhotoGalleryHealthCheck sut) => new() + { + Registration = new HealthCheckRegistration("photo-gallery", sut, null, null) + }; + + private static async Task RunAsync(PhotoGalleryHealthCheck sut) => + await sut.CheckHealthAsync(CreateContext(sut), TestContext.Current.CancellationToken); + + [Fact] + public async Task CheckHealthAsync_HealthyWhenDirectoryHasPhotos() + { + WriteFile("alice.jpg"); + WriteFile("bob.jpg"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains("Photo gallery OK", result.Description); + Assert.Equal(2, result.Data["photo_count"]); + Assert.Equal(_tempDir, result.Data["path"]); + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyWhenDirectoryEmpty() + { + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("too few photos", result.Description); + Assert.Equal(0, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_UnhealthyWhenOnlyOnePhoto() + { + // A lone photo (e.g. just the nopic.jpg placeholder) is not a healthy gallery. + WriteFile("lonely.jpg"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("too few photos", result.Description); + Assert.Equal(1, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_OnlyCountsExactJpgExtension() + { + WriteFile("alice.jpg"); + WriteFile("bob.jpg"); + // 3-char "*.jpg" pattern would otherwise match these on Windows. + WriteFile("notaphoto.jpginfo"); + WriteFile("readme.txt"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(_tempDir)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Equal(2, result.Data["photo_count"]); + } + + [Fact] + public async Task CheckHealthAsync_DegradedWhenDirectoryUnreachable() + { + // Unreachable share is Degraded, not Unhealthy: the app still works, + // falling back to the default image. + var missing = Path.Join(_tempDir, "does-not-exist"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(missing)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + Assert.Contains("not reachable", result.Description); + } + + [Fact] + public async Task CheckHealthAsync_HealthyWhenMissingAndDirectoryMissing() + { + var missing = Path.Join(_tempDir, "does-not-exist"); + + var result = await RunAsync(new PhotoGalleryHealthCheck(missing, healthyWhenMissing: true)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains("skipped", result.Description); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CheckHealthAsync_UnhealthyWhenPathNotConfigured(string? path) + { + var result = await RunAsync(new PhotoGalleryHealthCheck(path)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not configured", result.Description); + } + } +} diff --git a/web/Classes/HealthChecks/HealthCheckExtensions.cs b/web/Classes/HealthChecks/HealthCheckExtensions.cs index 65cd7ac7..4a41bd02 100644 --- a/web/Classes/HealthChecks/HealthCheckExtensions.cs +++ b/web/Classes/HealthChecks/HealthCheckExtensions.cs @@ -57,20 +57,18 @@ public static IServiceCollection AddViperHealthChecks( .AddDbContextCheck("db-viper", tags: new[] { "ready" }) .AddCheck("disk-space-app", new DiskSpaceHealthCheck(), tags: new[] { "ready" }); - // Photo gallery drive. Always registered so operators can see the check - // exists; in Development the drive is a network share not mounted locally, - // so healthyWhenMissing=true treats "drive absent" as a pass (with a - // "skipped" description) rather than a permanent Unhealthy in dev. var photoPath = configuration["PhotoGallery:IDCardPhotoPath"]; - if (!string.IsNullOrWhiteSpace(photoPath)) - { - builder.AddCheck( - "disk-space-photos", - new DiskSpaceHealthCheck( - explicitDrivePath: photoPath, - healthyWhenMissing: environment.IsDevelopment()), - tags: new[] { "ready" }); - } + + // Content check. A reachable directory with more than one photo is Healthy; + // an unreachable one is Degraded; a reachable but near-empty share is Unhealthy. + // Registered unconditionally so a missing/blank IDCardPhotoPath surfaces as + // Unhealthy rather than silently dropping the check. + builder.AddCheck( + "photo-gallery", + WithAdaptivePolling(new PhotoGalleryHealthCheck( + photoPath, + healthyWhenMissing: environment.IsDevelopment())), + tags: new[] { "ready" }); // CMS files drive. Same pattern as photos - the drive (S:\) is a network // share unmounted on developer machines, so skip in dev. Path mirrors diff --git a/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs new file mode 100644 index 00000000..b640eead --- /dev/null +++ b/web/Classes/HealthChecks/PhotoGalleryHealthCheck.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Viper.Classes.HealthChecks +{ + /// + /// Verifies the ID-card photo directory is reachable and holds more than one + /// photo. + /// Severity reflects user impact: an unreachable directory is Degraded (the app + /// still works, falling back to the default image), while a reachable but + /// near-empty directory is Unhealthy (the photo data itself is gone). + /// Photos are stored flat as "<mailId>.jpg" (see PhotoService), so a + /// top-level *.jpg scan is sufficient. + /// + public class PhotoGalleryHealthCheck : IHealthCheck + { + private readonly string? _photoPath; + private readonly bool _healthyWhenMissing; + + // A healthy gallery holds real student photos, not just the placeholder + // (nopic.jpg). Requiring more than one rejects a share that is empty or + // holds only the default image. + private const int MinimumPhotoCount = 2; + + /// + /// Creates a check for the given ID-card photo directory. + /// + /// + /// Directory holding the ID-card photos (PhotoGallery:IDCardPhotoPath). + /// + /// + /// If true, a missing directory returns Healthy with a "skipped" + /// description rather than Unhealthy. Use in Development, where the photo + /// share is a network path not mounted on developer machines. + /// + public PhotoGalleryHealthCheck(string? photoPath, bool healthyWhenMissing = false) + { + _photoPath = photoPath; + _healthyWhenMissing = healthyWhenMissing; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_photoPath)) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + "Photo path is not configured.")); + } + + if (!Directory.Exists(_photoPath)) + { + return Task.FromResult(_healthyWhenMissing + ? HealthCheckResult.Healthy($"Photo directory '{_photoPath}' not mounted (skipped).") + : HealthCheckResult.Degraded($"Photo directory '{_photoPath}' is not reachable; serving the default image.")); + } + + int photoCount; + try + { + // MatchType.Simple avoids the Win32 quirk where "*.jpg" also matches + // longer extensions (e.g. ".jpginfo"); CaseInsensitive keeps ".JPG" + // counted regardless of the platform default. + var options = new EnumerationOptions + { + MatchType = MatchType.Simple, + MatchCasing = MatchCasing.CaseInsensitive, + }; + photoCount = Directory.EnumerateFiles(_photoPath, "*.jpg", options).Count(); + } + catch (UnauthorizedAccessException) + { + // Can't read the share, so we can't judge the photo count - treat + // as unreachable (Degraded), not empty (Unhealthy). + return Task.FromResult(HealthCheckResult.Degraded( + $"Photo directory '{_photoPath}' not readable: access denied.")); + } + catch (IOException ex) + { + return Task.FromResult(HealthCheckResult.Degraded( + $"Photo directory '{_photoPath}' not readable: {ex.Message}")); + } + + var data = new Dictionary + { + ["path"] = _photoPath, + ["photo_count"] = photoCount, + }; + + if (photoCount < MinimumPhotoCount) + { + return Task.FromResult(HealthCheckResult.Unhealthy( + $"Photo directory '{_photoPath}' has too few photos: {photoCount} (expected at least {MinimumPhotoCount}).", + data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy( + $"Photo gallery OK: {photoCount} photos.", data: data)); + } + } +}