From c6f6a55383ddf08b4ed19a2d2915d241e75e84e0 Mon Sep 17 00:00:00 2001 From: lkoerber Date: Thu, 6 Aug 2026 13:29:09 +0200 Subject: [PATCH 1/3] fix: WebP uploads were always rejected by the signature validation ValidateFileSignature sized its read buffer purely by the extension's signature lengths - for .webp that's the 4-byte RIFF header, so the WebP-specific check's `bytesRead >= 12` guard (needed to read the "WEBP" marker at offset 8) was unconditionally false and every WebP upload, including well-formed ones, failed as an "invalid file". The buffer is now at least 12 bytes; forged .webp content is still rejected. Found by the new ImageService test suite and regression- tested there with a real encoded WebP through the full upload path. --- .../Services/Inventory/ImageService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/LagersystemLVHome.Application/Services/Inventory/ImageService.cs b/LagersystemLVHome.Application/Services/Inventory/ImageService.cs index f506103..21d7be2 100644 --- a/LagersystemLVHome.Application/Services/Inventory/ImageService.cs +++ b/LagersystemLVHome.Application/Services/Inventory/ImageService.cs @@ -374,7 +374,11 @@ private void ValidateFileSignature(MemoryStream stream, string extension, string throw new InvalidOperationException($"Keine Signatur-Validierung f\u00fcr {extension} definiert"); } - var headerBytes = new byte[signatures.Max(s => s.Length)]; + // At least 12 bytes: the WebP check below needs the "WEBP" marker at offset 8-11, + // but .webp's only signature entry is the 4-byte RIFF header - sizing the buffer + // purely by signature length capped bytesRead at 4 and made the `bytesRead >= 12` + // guard unconditionally false, rejecting EVERY WebP upload including valid ones. + var headerBytes = new byte[Math.Max(12, signatures.Max(s => s.Length))]; var bytesRead = stream.Read(headerBytes, 0, headerBytes.Length); var isValid = signatures.Any(signature => From 796ecca4eb9508d1989a7f4530e6699364e7ef57 Mon Sep 17 00:00:00 2001 From: lkoerber Date: Thu, 6 Aug 2026 13:29:10 +0200 Subject: [PATCH 2/3] fix: exclude source-generator output from the coverage measurement The LoggerMessage generator's *.g.cs output (700+ coverable lines under obj/) was counted as uncovered hand-written code. Same generated-code rationale as studylife's migrations exclusion. --- .github/workflows/ci-cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4142499..d021bc1 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -51,12 +51,16 @@ jobs: # (LagersystemLVHome, the Blazor Server UI project, is excluded - no unit-test # practice on Razor markup here). Downloaded and committed by semantic-release below. - run: dotnet tool install -g dotnet-reportgenerator-globaltool + # -*.g.cs excludes source-generator output (e.g. the LoggerMessage generator's + # 700+ lines under obj/) - generated code, same rationale as studylife's + # migrations exclusion. - run: > reportgenerator -reports:"LagersystemLVHome.UnitTests/**/TestResults/**/coverage.cobertura.xml" -targetdir:coverage-summary -reporttypes:JsonSummary -assemblyfilters:"-LagersystemLVHome" + -filefilters:"-*.g.cs" - run: python3 scripts/generate_coverage_badge.py coverage-summary/Summary.json coverage-badge.json - uses: actions/upload-artifact@v7 with: From bc511f7dcc1f9e453bf729f1255c02ded95e8803 Mon Sep 17 00:00:00 2001 From: lkoerber Date: Thu, 6 Aug 2026 13:29:10 +0200 Subject: [PATCH 3/3] test: coverage batch 1 - session, dashboard, gamification, inventory, notifications 400 new tests (suite 482 -> 882) from four targeted work packages, lifting business-logic coverage from 25.6% to 42.2%: - SessionManagementService (923 lines, 0% -> 98.6%, 103 tests): full session lifecycle, IP-resolution fallback chain, device/browser/OS detection, VPN risk scoring, hijacking detection (IP/UA change, impossible travel), API sessions. Remaining lines are structurally dead: the only VPN detector never sets Tor/Proxy/Hosting flags, so those risk tiers are unreachable via the public API. - DashboardService (0% -> 92%) + GamificationService (11% -> 100%), 61 tests: dashboard aggregations, trends, ABC analysis, achievements, levels, streaks. - Inventory cluster (104 tests): InventoryService 0% -> 99.8% (CRUD, price history, audit logs, scanner movements, FIFO batch reduction, storage-location math), ExpiryService 30% -> 91% (notification matrix, dedupe), ImageService 0% -> 97% (real SkiaSharp round-trips, signature validation incl. the WebP regression, file-lock error paths). - Notification cluster (131 tests): NotificationService 0% -> 98%, TeamsService 0% -> 100% (webhook payloads, retry/backoff via stub HTTP handlers), EmailService 0% -> 77% - the remainder is the concrete SmtpClient send with no injectable seam, unreachable without opening real sockets. Review notes from writing these (deliberately NOT changed here): NotificationService's InApp path defaults to NotificationChannel.All internally, which can double-send admin emails for low-stock/expiry/ security alerts; two of its catch blocks are unreachable dead code; DashboardService.GetDashboardDataAsync accepts from/to parameters it never uses and GetABCAnalysisAsync ignores its warehouseId filter; SessionManagementService resolves IGeoLocationService but only ever uses it as an availability gate. --- .../Services/Inventory/ExpiryServiceTests.cs | 324 ++++ .../Services/Inventory/ImageServiceTests.cs | 443 +++++ .../Inventory/InventoryServiceTests.cs | 972 +++++++++++ .../Notification/EmailServiceTests.cs | 238 +++ .../Notification/NotificationServiceTests.cs | 1423 +++++++++++++++++ .../Notification/TeamsServiceTests.cs | 479 ++++++ .../Reporting/DashboardServiceTests.cs | 755 +++++++++ ...SessionManagementServiceApiSessionTests.cs | 233 +++ .../SessionManagementServiceCreateTests.cs | 332 ++++ .../SessionManagementServiceLifecycleTests.cs | 492 ++++++ .../SessionManagementServiceQueryTests.cs | 474 ++++++ .../SessionManagementServiceSecurityTests.cs | 250 +++ .../SessionManagementServiceTestSupport.cs | 126 ++ .../Services/UI/GamificationServiceTests.cs | 495 ++++++ 14 files changed, 7036 insertions(+) create mode 100644 LagersystemLVHome.UnitTests/Services/Inventory/ImageServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Inventory/InventoryServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Notification/EmailServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Notification/NotificationServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Notification/TeamsServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Reporting/DashboardServiceTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceApiSessionTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceCreateTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceLifecycleTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceQueryTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceSecurityTests.cs create mode 100644 LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceTestSupport.cs diff --git a/LagersystemLVHome.UnitTests/Services/Inventory/ExpiryServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Inventory/ExpiryServiceTests.cs index 4ba7595..8d1ac13 100644 --- a/LagersystemLVHome.UnitTests/Services/Inventory/ExpiryServiceTests.cs +++ b/LagersystemLVHome.UnitTests/Services/Inventory/ExpiryServiceTests.cs @@ -233,4 +233,328 @@ public async Task GetNextExpiringBatchForProductAsync_ReturnsEarliestNonEmpty() batch!.BatchNumber.Should().Be("earliest"); } + + // ---- Remaining batch queries ---- + + [Fact] + public async Task GetExpiringBatchesAsync_ReturnsWithinThresholdOnly() + { + var (sut, factory) = CreateSut(nameof(GetExpiringBatchesAsync_ReturnsWithinThresholdOnly)); + await SeedCategoryAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.Products.Add(new Product { Id = 1, Name = "P1", WarehouseId = 1, CategoryId = 1 }); + await db.SaveChangesAsync(); + db.ProductBatches.AddRange( + new ProductBatch { BatchNumber = "soon", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(2) }, + new ProductBatch { BatchNumber = "far", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(30) }, + new ProductBatch { BatchNumber = "expired", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(-1) }, + new ProductBatch { BatchNumber = "empty", ProductId = 1, WarehouseId = 1, Quantity = 0, ExpiryDate = DateTime.UtcNow.AddDays(2) }, + new ProductBatch { BatchNumber = "otherWarehouse", ProductId = 1, WarehouseId = 2, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(2) }); + await db.SaveChangesAsync(); + } + + var list = await sut.GetExpiringBatchesAsync(1, daysThreshold: 7); + + list.Should().ContainSingle().Which.BatchNumber.Should().Be("soon"); + } + + [Fact] + public async Task GetExpiringBatchesAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetExpiringBatchesAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetExpiringBatchesAsync(1, cancellationToken: cts.Token)).Should().BeEmpty(); + } + + [Fact] + public async Task GetAllNonEmptyBatchesWithExpiryAsync_FiltersEmptyAndNullExpiry() + { + var (sut, factory) = CreateSut(nameof(GetAllNonEmptyBatchesWithExpiryAsync_FiltersEmptyAndNullExpiry)); + await SeedCategoryAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.Products.Add(new Product { Id = 1, Name = "P1", WarehouseId = 1, CategoryId = 1 }); + await db.SaveChangesAsync(); + db.ProductBatches.AddRange( + new ProductBatch { BatchNumber = "withExpiry", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(2) }, + new ProductBatch { BatchNumber = "noExpiry", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = null }, + new ProductBatch { BatchNumber = "empty", ProductId = 1, WarehouseId = 1, Quantity = 0, ExpiryDate = DateTime.UtcNow.AddDays(2) }); + await db.SaveChangesAsync(); + } + + var list = await sut.GetAllNonEmptyBatchesWithExpiryAsync(1); + + list.Should().ContainSingle().Which.BatchNumber.Should().Be("withExpiry"); + } + + [Fact] + public async Task GetAllNonEmptyBatchesWithExpiryAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetAllNonEmptyBatchesWithExpiryAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetAllNonEmptyBatchesWithExpiryAsync(1, cts.Token)).Should().BeEmpty(); + } + + [Fact] + public async Task GetExpiredBatchesAsync_ReturnsPastExpiryNonEmptyOnly() + { + var (sut, factory) = CreateSut(nameof(GetExpiredBatchesAsync_ReturnsPastExpiryNonEmptyOnly)); + await SeedCategoryAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.Products.Add(new Product { Id = 1, Name = "P1", WarehouseId = 1, CategoryId = 1 }); + await db.SaveChangesAsync(); + db.ProductBatches.AddRange( + new ProductBatch { BatchNumber = "expired", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(-3) }, + new ProductBatch { BatchNumber = "expiredButEmpty", ProductId = 1, WarehouseId = 1, Quantity = 0, ExpiryDate = DateTime.UtcNow.AddDays(-3) }, + new ProductBatch { BatchNumber = "fresh", ProductId = 1, WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(3) }); + await db.SaveChangesAsync(); + } + + var list = await sut.GetExpiredBatchesAsync(1); + + list.Should().ContainSingle().Which.BatchNumber.Should().Be("expired"); + } + + [Fact] + public async Task GetExpiredBatchesAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetExpiredBatchesAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetExpiredBatchesAsync(1, cts.Token)).Should().BeEmpty(); + } + + [Fact] + public async Task GetExpiringBatchesCountAsync_CanceledToken_ReturnsZero() + { + var (sut, _) = CreateSut(nameof(GetExpiringBatchesCountAsync_CanceledToken_ReturnsZero)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetExpiringBatchesCountAsync(1, cancellationToken: cts.Token)).Should().Be(0); + } + + [Fact] + public async Task GetBatchesForProductAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetBatchesForProductAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetBatchesForProductAsync(1, cts.Token)).Should().BeEmpty(); + } + + [Fact] + public async Task GetNextExpiringBatchForProductAsync_CanceledToken_ReturnsNull() + { + var (sut, _) = CreateSut(nameof(GetNextExpiringBatchForProductAsync_CanceledToken_ReturnsNull)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetNextExpiringBatchForProductAsync(1, cts.Token)).Should().BeNull(); + } + + [Fact] + public async Task MarkBatchAsDisposedAsync_CanceledToken_ReturnsFailure() + { + var (sut, _) = CreateSut(nameof(MarkBatchAsDisposedAsync_CanceledToken_ReturnsFailure)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var r = await sut.MarkBatchAsDisposedAsync(1, cancellationToken: cts.Token); + + r.ErrorCode.Should().Be("batch.disposefailed"); + } + + // ---- Exception paths on the simple query methods (pre-canceled token forces the try/catch) ---- + + [Fact] + public async Task GetExpiringProductsAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetExpiringProductsAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetExpiringProductsAsync(1, cancellationToken: cts.Token)).Should().BeEmpty(); + } + + [Fact] + public async Task GetExpiredProductsAsync_CanceledToken_ReturnsEmptyList() + { + var (sut, _) = CreateSut(nameof(GetExpiredProductsAsync_CanceledToken_ReturnsEmptyList)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + (await sut.GetExpiredProductsAsync(1, cts.Token)).Should().BeEmpty(); + } + + // Note: ShouldTrackExpiryForCategoryAsync's catch block cannot be reliably exercised via a + // pre-canceled CancellationToken - its internal `context.Categories.FindAsync(categoryId)` + // call doesn't forward the token, and CreateDbContextAsync itself doesn't observe + // cancellation for the InMemory provider (confirmed empirically: ProcessScannerMovementAsync's + // equivalent test only throws once flow reaches a call that explicitly forwards the token, + // e.g. SaveChangesAsync/ToListAsync/FirstOrDefaultAsync). Left undocumented-by-test; see + // final coverage report for this specific gap. + + // ---- CheckExpiryAndNotifyAsync (drives the four private Notify* helpers) ---- + + private static User MakeManager(int id, int warehouseId = 1, UserRole role = UserRole.Admin, bool isActive = true) + => new() + { + Id = id, + Username = $"u{id}", + Email = $"u{id}@x.local", + PasswordHash = "x", + WarehouseId = warehouseId, + Role = role, + IsActive = isActive + }; + + [Fact] + public async Task CheckExpiryAndNotifyAsync_NotifiesAdminsForExpiredAndExpiringSoonProduct() + { + var (_, factory) = CreateSut(nameof(CheckExpiryAndNotifyAsync_NotifiesAdminsForExpiredAndExpiringSoonProduct)); + var notifier = Substitute.For(); + var svc = new ExpiryService(factory, notifier, NullLogger.Instance); + await SeedCategoryAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.Products.AddRange( + MakeProduct("expired", DateTime.UtcNow.AddDays(-1)), + MakeProduct("expiringSoon", DateTime.UtcNow.AddDays(3))); + db.Users.AddRange( + MakeManager(1, role: UserRole.Admin), + MakeManager(2, role: UserRole.User), // not notified: plain user + MakeManager(3, role: UserRole.Manager, isActive: false)); // not notified: inactive + await db.SaveChangesAsync(); + } + + await svc.CheckExpiryAndNotifyAsync(); + + await notifier.Received(1).CreateNotificationAsync( + 1, NotificationType.CriticalStock, "PRODUKT ABGELAUFEN!", Arg.Any(), Arg.Any(), NotificationChannel.All); + await notifier.Received(1).CreateNotificationAsync( + 1, NotificationType.LowStock, "Produkt läuft bald ab", Arg.Any(), Arg.Any()); + // "any args" matching would ignore the userId we care about; pin it explicitly + // and use Arg.Any for the rest so calls for user 1 don't accidentally match too. + await notifier.DidNotReceive().CreateNotificationAsync( + 2, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await notifier.DidNotReceive().CreateNotificationAsync( + 3, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckExpiryAndNotifyAsync_SkipsProductNotification_WhenRecentDuplicateExists() + { + var (_, factory) = CreateSut(nameof(CheckExpiryAndNotifyAsync_SkipsProductNotification_WhenRecentDuplicateExists)); + var notifier = Substitute.For(); + var svc = new ExpiryService(factory, notifier, NullLogger.Instance); + await SeedCategoryAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.Products.Add(MakeProduct("dupe", DateTime.UtcNow.AddDays(-1))); + db.Users.Add(MakeManager(1)); + db.Notifications.Add(new LagersystemLVHome.Domain.Models.Notification + { + UserId = 1, + Type = NotificationType.CriticalStock, + Message = "Das Produkt 'dupe' ist seit dem ... abgelaufen!", + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await db.SaveChangesAsync(); + } + + await svc.CheckExpiryAndNotifyAsync(); + + await notifier.DidNotReceiveWithAnyArgs().CreateNotificationAsync(default, default, default!, default!); + } + + [Fact] + public async Task CheckExpiryAndNotifyAsync_NotifiesAdminsForExpiredAndExpiringSoonBatch() + { + var (_, factory) = CreateSut(nameof(CheckExpiryAndNotifyAsync_NotifiesAdminsForExpiredAndExpiringSoonBatch)); + var notifier = Substitute.For(); + var svc = new ExpiryService(factory, notifier, NullLogger.Instance); + await SeedCategoryAsync(factory); + Product product; + await using (var db = factory.CreateDbContext()) + { + product = new Product { Name = "BatchedProduct", WarehouseId = 1, CategoryId = 1, Quantity = 5 }; + db.Products.Add(product); + await db.SaveChangesAsync(); + db.ProductBatches.AddRange( + new ProductBatch { ProductId = product.Id, BatchNumber = "B-expired", WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(-2) }, + new ProductBatch { ProductId = product.Id, BatchNumber = "B-soon", WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(3) }); + db.Users.Add(MakeManager(1, role: UserRole.SuperAdmin)); + await db.SaveChangesAsync(); + } + + await svc.CheckExpiryAndNotifyAsync(); + + await notifier.Received(1).CreateNotificationAsync( + 1, NotificationType.CriticalStock, "CHARGE ABGELAUFEN!", Arg.Any(), Arg.Any(), NotificationChannel.All); + await notifier.Received(1).CreateNotificationAsync( + 1, NotificationType.LowStock, "Charge läuft bald ab", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckExpiryAndNotifyAsync_SkipsBatchNotification_WhenRecentDuplicateExists() + { + var (_, factory) = CreateSut(nameof(CheckExpiryAndNotifyAsync_SkipsBatchNotification_WhenRecentDuplicateExists)); + var notifier = Substitute.For(); + var svc = new ExpiryService(factory, notifier, NullLogger.Instance); + await SeedCategoryAsync(factory); + Product product; + await using (var db = factory.CreateDbContext()) + { + product = new Product { Name = "BatchedProduct", WarehouseId = 1, CategoryId = 1, Quantity = 5 }; + db.Products.Add(product); + await db.SaveChangesAsync(); + db.ProductBatches.Add(new ProductBatch { ProductId = product.Id, BatchNumber = "B-dupe", WarehouseId = 1, Quantity = 2, ExpiryDate = DateTime.UtcNow.AddDays(-2) }); + db.Users.Add(MakeManager(1)); + db.Notifications.Add(new LagersystemLVHome.Domain.Models.Notification + { + UserId = 1, + Type = NotificationType.CriticalStock, + Message = "Charge 'B-dupe' von ...", + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await db.SaveChangesAsync(); + } + + await svc.CheckExpiryAndNotifyAsync(); + + await notifier.DidNotReceiveWithAnyArgs().CreateNotificationAsync(default, default, default!, default!); + } + + [Fact] + public async Task CheckExpiryAndNotifyAsync_CanceledToken_DoesNotThrow() + { + var (sut, _) = CreateSut(nameof(CheckExpiryAndNotifyAsync_CanceledToken_DoesNotThrow)); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = async () => await sut.CheckExpiryAndNotifyAsync(cts.Token); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CheckExpiryAndNotifyAsync_NoTrackedProductsOrBatches_CompletesWithoutNotifying() + { + var (_, factory) = CreateSut(nameof(CheckExpiryAndNotifyAsync_NoTrackedProductsOrBatches_CompletesWithoutNotifying)); + var notifier = Substitute.For(); + var svc = new ExpiryService(factory, notifier, NullLogger.Instance); + + await svc.CheckExpiryAndNotifyAsync(); + + await notifier.DidNotReceiveWithAnyArgs().CreateNotificationAsync(default, default, default!, default!); + } } diff --git a/LagersystemLVHome.UnitTests/Services/Inventory/ImageServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Inventory/ImageServiceTests.cs new file mode 100644 index 0000000..d815cea --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Inventory/ImageServiceTests.cs @@ -0,0 +1,443 @@ +using LagersystemLVHome.Application.Services; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using SkiaSharp; + +namespace LagersystemLVHome.UnitTests.Services.Inventory; + +public class ImageServiceTests : IDisposable +{ + // Each test gets its own WebRootPath under the OS temp folder so uploads/deletes + // don't collide across parallel test runs; cleaned up in Dispose. + private readonly string _webRoot = Path.Combine(Path.GetTempPath(), "lagersystem-imgtests-" + Guid.NewGuid()); + + public void Dispose() + { + if (Directory.Exists(_webRoot)) + { + try { Directory.Delete(_webRoot, recursive: true); } catch { /* best effort cleanup */ } + } + GC.SuppressFinalize(this); + } + + /// Minimal fake of Blazor's IBrowserFile backed by an in-memory byte buffer. + private sealed class FakeBrowserFile(string name, byte[] content, long? sizeOverride = null) : IBrowserFile + { + public string Name { get; } = name; + public DateTimeOffset LastModified { get; } = DateTimeOffset.UtcNow; + public long Size { get; } = sizeOverride ?? content.Length; + public string ContentType { get; } = "application/octet-stream"; + + public Stream OpenReadStream(long maxAllowedSize = 512000, CancellationToken cancellationToken = default) + => new MemoryStream(content); + } + + private ImageService CreateSut() + { + Directory.CreateDirectory(_webRoot); + var env = Substitute.For(); + env.WebRootPath.Returns(_webRoot); + return new ImageService(env, NullLogger.Instance); + } + + /// Encodes a small solid-color bitmap using SkiaSharp so tests exercise the + /// real decode/resize/encode pipeline instead of hand-rolled bytes. + private static byte[] MakeImageBytes(SKEncodedImageFormat format, int width = 40, int height = 20, int quality = 90) + { + using var bitmap = new SKBitmap(width, height); + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Clear(new SKColor(120, 60, 200)); + } + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(format, quality); + return data.ToArray(); + } + + private static (int width, int height) ReadImageDimensions(byte[] bytes) + { + using var bitmap = SKBitmap.Decode(bytes); + return (bitmap.Width, bitmap.Height); + } + + // ---- UploadProductImageAsync ---- + + [Fact] + public async Task UploadProductImageAsync_ValidPng_SavesMainAndThumbnail_WithinMaxDimensions() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Png, width: 1600, height: 800); + var file = new FakeBrowserFile("photo.png", bytes); + + var (imageUrl, thumbnailUrl) = await sut.UploadProductImageAsync(file, productId: 7); + + imageUrl.Should().StartWith("/uploads/products/product_7_"); + thumbnailUrl.Should().EndWith("_thumb.png"); + sut.ImageExists(imageUrl).Should().BeTrue(); + sut.ImageExists(thumbnailUrl).Should().BeTrue(); + + var mainBytes = await File.ReadAllBytesAsync(sut.GetImagePath(imageUrl)); + var (mw, mh) = ReadImageDimensions(mainBytes); + mw.Should().BeLessOrEqualTo(800); + mh.Should().BeLessOrEqualTo(800); + + var thumbBytes = await File.ReadAllBytesAsync(sut.GetImagePath(thumbnailUrl)); + var (tw, th) = ReadImageDimensions(thumbBytes); + tw.Should().BeLessOrEqualTo(150); + th.Should().BeLessOrEqualTo(150); + } + + [Fact] + public async Task UploadProductImageAsync_ValidJpeg_Succeeds() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Jpeg, width: 300, height: 300); + var file = new FakeBrowserFile("photo.jpg", bytes); + + var (imageUrl, _) = await sut.UploadProductImageAsync(file, productId: 1); + + sut.ImageExists(imageUrl).Should().BeTrue(); + } + + [Fact] + public async Task UploadProductImageAsync_FileTooLarge_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("photo.png", content: [1, 2, 3], sizeOverride: 6L * 1024 * 1024); + + var act = async () => await sut.UploadProductImageAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*zu gro*"); + } + + [Fact] + public async Task UploadProductImageAsync_UnsupportedExtension_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("photo.gif", content: [1, 2, 3]); + + var act = async () => await sut.UploadProductImageAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*Ung*ltiges Dateiformat*"); + } + + [Fact] + public async Task UploadProductImageAsync_DisguisedFile_FailsSignatureValidation() + { + var sut = CreateSut(); + // .png extension but plain-text content: magic bytes won't match the PNG signature. + var file = new FakeBrowserFile("fake.png", "not really a png"u8.ToArray()); + + var act = async () => await sut.UploadProductImageAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*Ung*ltige Datei*"); + } + + /// + /// Regression test for a real bug found by this suite: ValidateFileSignature() + /// sized its read buffer purely by signature length - for ".webp" that's the + /// 4-byte RIFF header, so the `bytesRead >= 12` guard for reading the "WEBP" + /// marker at offset 8 was unconditionally false and EVERY WebP upload was + /// rejected, even well-formed ones. The buffer is now at least 12 bytes, so a + /// genuine WebP file passes signature validation (a real encoded WebP is used + /// so the whole upload path incl. decode/resize succeeds end-to-end). + /// + [Fact] + public async Task UploadProductImageAsync_Webp_WithValidSignature_PassesValidation() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Webp); + var file = new FakeBrowserFile("photo.webp", bytes); + + var (imageUrl, thumbnailUrl) = await sut.UploadProductImageAsync(file, productId: 1); + + imageUrl.Should().StartWith("/uploads/products/"); + sut.ImageExists(imageUrl).Should().BeTrue(); + sut.ImageExists(thumbnailUrl).Should().BeTrue(); + } + + /// A file with a .webp extension whose content is NOT RIFF/WEBP must + /// still be rejected - the fix widened the buffer, not the validation. + [Fact] + public async Task UploadProductImageAsync_Webp_WithForgedContent_IsRejected() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("photo.webp", MakeImageBytes(SKEncodedImageFormat.Png)); + + var act = async () => await sut.UploadProductImageAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*Ung*ltige Datei*"); + } + + // ---- DeleteProductImageAsync ---- + + [Fact] + public async Task DeleteProductImageAsync_ExistingFiles_DeletesBoth() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Png); + var (imageUrl, thumbnailUrl) = await sut.UploadProductImageAsync(new FakeBrowserFile("p.png", bytes), 1); + + await sut.DeleteProductImageAsync(imageUrl, thumbnailUrl); + + sut.ImageExists(imageUrl).Should().BeFalse(); + sut.ImageExists(thumbnailUrl).Should().BeFalse(); + } + + [Fact] + public async Task DeleteProductImageAsync_NonExistentFiles_DoesNotThrow() + { + var sut = CreateSut(); + + var act = async () => await sut.DeleteProductImageAsync("/uploads/products/missing.png", "/uploads/products/missing_thumb.png"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteProductImageAsync_EmptyUrls_NoOp() + { + var sut = CreateSut(); + + var act = async () => await sut.DeleteProductImageAsync(string.Empty, string.Empty); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteProductImageAsync_LockedFile_SwallowsExceptionAndLeavesFileInPlace() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Png); + var (imageUrl, thumbnailUrl) = await sut.UploadProductImageAsync(new FakeBrowserFile("p.png", bytes), 1); + var physicalPath = sut.GetImagePath(imageUrl); + + // Hold an exclusive lock so File.Delete throws IOException inside the service, + // exercising its catch-and-log branch instead of propagating. + await using (new FileStream(physicalPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var act = async () => await sut.DeleteProductImageAsync(imageUrl, thumbnailUrl); + await act.Should().NotThrowAsync(); + } + + File.Exists(physicalPath).Should().BeTrue("the delete attempt should have failed silently while the file was locked"); + } + + // ---- GetImagePath / ImageExists ---- + + [Fact] + public void GetImagePath_EmptyUrl_ReturnsEmptyString() + { + CreateSut().GetImagePath(string.Empty).Should().BeEmpty(); + } + + [Fact] + public void GetImagePath_NormalizesLeadingSlashAndSeparators() + { + var sut = CreateSut(); + + var path = sut.GetImagePath("/uploads/products/x.png"); + + path.Should().Be(Path.Combine(_webRoot, "uploads", "products", "x.png")); + } + + [Fact] + public void ImageExists_UnknownUrl_ReturnsFalse() + { + CreateSut().ImageExists("/uploads/products/nope.png").Should().BeFalse(); + } + + [Fact] + public void ImageExists_EmptyUrl_ReturnsFalse() + { + CreateSut().ImageExists(string.Empty).Should().BeFalse(); + } + + // ---- UploadProfileImageAsync / DeleteProfileImageAsync ---- + + [Fact] + public async Task UploadProfileImageAsync_ValidImage_ProducesSquareThumbnail() + { + var sut = CreateSut(); + var bytes = MakeImageBytes(SKEncodedImageFormat.Jpeg, width: 400, height: 100); + var file = new FakeBrowserFile("avatar.jpg", bytes); + + var imageUrl = await sut.UploadProfileImageAsync(file); + + imageUrl.Should().StartWith("/uploads/profiles/"); + sut.ImageExists(imageUrl).Should().BeTrue(); + var savedBytes = await File.ReadAllBytesAsync(sut.GetImagePath(imageUrl)); + var (w, h) = ReadImageDimensions(savedBytes); + w.Should().Be(200); + h.Should().Be(200); + } + + [Fact] + public async Task UploadProfileImageAsync_FileTooLarge_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("avatar.jpg", content: [1, 2, 3], sizeOverride: 51L * 1024 * 1024); + + var act = async () => await sut.UploadProfileImageAsync(file); + + (await act.Should().ThrowAsync()).WithMessage("*Profilbild zu gro*"); + } + + [Fact] + public async Task UploadProfileImageAsync_UnsupportedExtension_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("avatar.bmp", content: [1, 2, 3]); + + var act = async () => await sut.UploadProfileImageAsync(file); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UploadProfileImageAsync_DisguisedFile_FailsSignatureValidation() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("avatar.png", "not a real png"u8.ToArray()); + + var act = async () => await sut.UploadProfileImageAsync(file); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteProfileImageAsync_ExistingFile_DeletesAndReturnsTrue() + { + var sut = CreateSut(); + var imageUrl = await sut.UploadProfileImageAsync(new FakeBrowserFile("a.jpg", MakeImageBytes(SKEncodedImageFormat.Jpeg))); + + (await sut.DeleteProfileImageAsync(imageUrl)).Should().BeTrue(); + sut.ImageExists(imageUrl).Should().BeFalse(); + } + + [Fact] + public async Task DeleteProfileImageAsync_NonExistentFile_ReturnsFalse() + { + var sut = CreateSut(); + + (await sut.DeleteProfileImageAsync("/uploads/profiles/missing.jpg")).Should().BeFalse(); + } + + [Fact] + public async Task DeleteProfileImageAsync_EmptyPath_ReturnsFalse() + { + var sut = CreateSut(); + + (await sut.DeleteProfileImageAsync(string.Empty)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteProfileImageAsync_LockedFile_SwallowsExceptionAndReturnsFalse() + { + var sut = CreateSut(); + var imageUrl = await sut.UploadProfileImageAsync(new FakeBrowserFile("a.jpg", MakeImageBytes(SKEncodedImageFormat.Jpeg))); + var physicalPath = sut.GetImagePath(imageUrl); + + await using (new FileStream(physicalPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + (await sut.DeleteProfileImageAsync(imageUrl)).Should().BeFalse(); + } + + File.Exists(physicalPath).Should().BeTrue(); + } + + // ---- UploadSpecificationPdfAsync / DeleteSpecificationPdfAsync ---- + + private static byte[] MakeFakePdfBytes(string body = "fake pdf content") + => [.. "%PDF-1.4\n"u8.ToArray(), .. System.Text.Encoding.ASCII.GetBytes(body)]; + + [Fact] + public async Task UploadSpecificationPdfAsync_ValidPdf_SavesFileWithMatchingContent() + { + var sut = CreateSut(); + var bytes = MakeFakePdfBytes(); + var file = new FakeBrowserFile("spec.pdf", bytes); + + var pdfUrl = await sut.UploadSpecificationPdfAsync(file, productId: 3); + + pdfUrl.Should().StartWith("/uploads/specifications/spec_product_3_").And.EndWith(".pdf"); + sut.ImageExists(pdfUrl).Should().BeTrue(); + var saved = await File.ReadAllBytesAsync(sut.GetImagePath(pdfUrl)); + saved.Should().Equal(bytes); + } + + [Fact] + public async Task UploadSpecificationPdfAsync_FileTooLarge_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("spec.pdf", content: [1, 2, 3], sizeOverride: 11L * 1024 * 1024); + + var act = async () => await sut.UploadSpecificationPdfAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*PDF zu gro*"); + } + + [Fact] + public async Task UploadSpecificationPdfAsync_NonPdfExtension_Throws() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("spec.docx", content: [1, 2, 3]); + + var act = async () => await sut.UploadSpecificationPdfAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*Nur PDF-Dateien*"); + } + + [Fact] + public async Task UploadSpecificationPdfAsync_DisguisedFile_FailsSignatureValidation() + { + var sut = CreateSut(); + var file = new FakeBrowserFile("spec.pdf", "not a real pdf"u8.ToArray()); + + var act = async () => await sut.UploadSpecificationPdfAsync(file, productId: 1); + + (await act.Should().ThrowAsync()).WithMessage("*Ung*ltige Datei*"); + } + + [Fact] + public async Task DeleteSpecificationPdfAsync_ExistingFile_DeletesAndReturnsTrue() + { + var sut = CreateSut(); + var pdfUrl = await sut.UploadSpecificationPdfAsync(new FakeBrowserFile("s.pdf", MakeFakePdfBytes()), productId: 1); + + (await sut.DeleteSpecificationPdfAsync(pdfUrl)).Should().BeTrue(); + sut.ImageExists(pdfUrl).Should().BeFalse(); + } + + [Fact] + public async Task DeleteSpecificationPdfAsync_NonExistentFile_ReturnsFalse() + { + var sut = CreateSut(); + + (await sut.DeleteSpecificationPdfAsync("/uploads/specifications/missing.pdf")).Should().BeFalse(); + } + + [Fact] + public async Task DeleteSpecificationPdfAsync_EmptyPath_ReturnsFalse() + { + var sut = CreateSut(); + + (await sut.DeleteSpecificationPdfAsync(string.Empty)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteSpecificationPdfAsync_LockedFile_SwallowsExceptionAndReturnsFalse() + { + var sut = CreateSut(); + var pdfUrl = await sut.UploadSpecificationPdfAsync(new FakeBrowserFile("s.pdf", MakeFakePdfBytes()), productId: 1); + var physicalPath = sut.GetImagePath(pdfUrl); + + await using (new FileStream(physicalPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + (await sut.DeleteSpecificationPdfAsync(pdfUrl)).Should().BeFalse(); + } + + File.Exists(physicalPath).Should().BeTrue(); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Inventory/InventoryServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Inventory/InventoryServiceTests.cs new file mode 100644 index 0000000..8c05c08 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Inventory/InventoryServiceTests.cs @@ -0,0 +1,972 @@ +using LagersystemLVHome.Data; +using LagersystemLVHome.Data.Repositories; +using LagersystemLVHome.Domain.Models; +using LagersystemLVHome.Application.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute.ExceptionExtensions; +using System.Security.Claims; + +namespace LagersystemLVHome.UnitTests.Services.Inventory; + +public class InventoryServiceTests +{ + private sealed class InMemoryContextFactory(DbContextOptions options) + : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => new(options); + } + + private static IDbContextFactory CreateFactory(string name) + => new InMemoryContextFactory( + new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + private static IHttpContextAccessor AnonymousAccessor() + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns((HttpContext?)null); + return accessor; + } + + /// Authenticated user. Pass a raw string for the WarehouseId claim so + /// unparsable-claim scenarios can be exercised too. + private static IHttpContextAccessor AuthenticatedAccessor(string? warehouseIdClaim = null, string? userName = null) + { + var claims = new List(); + if (warehouseIdClaim != null) claims.Add(new Claim("WarehouseId", warehouseIdClaim)); + if (userName != null) claims.Add(new Claim(ClaimTypes.Name, userName)); + var identity = new ClaimsIdentity(claims, "TestAuth"); + var ctx = new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + var accessor = Substitute.For(); + accessor.HttpContext.Returns(ctx); + return accessor; + } + + private sealed class Fixture + { + public required InventoryService Sut { get; init; } + public required IProductRepository ProductRepo { get; init; } + public required ICategoryRepository CategoryRepo { get; init; } + public required IStockMovementRepository MovementRepo { get; init; } + public required IDbContextFactory ContextFactory { get; init; } + public required IPriceHistoryService PriceHistoryService { get; init; } + public required IAuditService AuditService { get; init; } + } + + private static Fixture CreateSut(string dbName, IHttpContextAccessor? accessor = null) + { + var productRepo = Substitute.For(); + var categoryRepo = Substitute.For(); + var movementRepo = Substitute.For(); + var contextFactory = CreateFactory(dbName); + var priceHistoryService = Substitute.For(); + var auditService = Substitute.For(); + + var sut = new InventoryService( + productRepo, categoryRepo, movementRepo, accessor ?? AnonymousAccessor(), + contextFactory, priceHistoryService, auditService, + NullLogger.Instance); + + return new Fixture + { + Sut = sut, + ProductRepo = productRepo, + CategoryRepo = categoryRepo, + MovementRepo = movementRepo, + ContextFactory = contextFactory, + PriceHistoryService = priceHistoryService, + AuditService = auditService + }; + } + + private static Product MakeProduct(int id, int warehouseId = 1, decimal price = 10m, int quantity = 5, string name = "P") + => new() { Id = id, Name = name, WarehouseId = warehouseId, Price = price, Quantity = quantity }; + + // ---- GetWarehouseId (exercised via the public read methods) ---- + + [Fact] + public async Task GetAllProductsAsync_Unauthenticated_UsesDefaultWarehouse() + { + var f = CreateSut(nameof(GetAllProductsAsync_Unauthenticated_UsesDefaultWarehouse)); + + await f.Sut.GetAllProductsAsync(); + + await f.ProductRepo.Received(1).GetAllAsync(1); + } + + [Fact] + public async Task GetAllProductsAsync_AuthenticatedWithClaim_UsesClaimWarehouse() + { + var f = CreateSut( + nameof(GetAllProductsAsync_AuthenticatedWithClaim_UsesClaimWarehouse), + AuthenticatedAccessor(warehouseIdClaim: "5")); + + await f.Sut.GetAllProductsAsync(); + + await f.ProductRepo.Received(1).GetAllAsync(5); + } + + [Fact] + public async Task GetAllProductsAsync_AuthenticatedWithoutClaim_FallsBackToDefault() + { + var f = CreateSut( + nameof(GetAllProductsAsync_AuthenticatedWithoutClaim_FallsBackToDefault), + AuthenticatedAccessor()); + + await f.Sut.GetAllProductsAsync(); + + await f.ProductRepo.Received(1).GetAllAsync(1); + } + + [Fact] + public async Task GetAllProductsAsync_UnparsableClaim_FallsBackToDefault() + { + var f = CreateSut( + nameof(GetAllProductsAsync_UnparsableClaim_FallsBackToDefault), + AuthenticatedAccessor(warehouseIdClaim: "not-a-number")); + + await f.Sut.GetAllProductsAsync(); + + await f.ProductRepo.Received(1).GetAllAsync(1); + } + + [Fact] + public async Task GetProductByIdAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetProductByIdAsync_DelegatesToRepository)); + var product = MakeProduct(1); + f.ProductRepo.GetByIdAsync(1, 1).Returns(product); + + (await f.Sut.GetProductByIdAsync(1)).Should().BeSameAs(product); + } + + [Fact] + public async Task GetProductByBarcodeAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetProductByBarcodeAsync_DelegatesToRepository)); + var product = MakeProduct(1); + f.ProductRepo.GetByBarcodeAsync("ABC", 1).Returns(product); + + (await f.Sut.GetProductByBarcodeAsync("ABC")).Should().BeSameAs(product); + } + + [Fact] + public async Task GetProductsByCategoryAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetProductsByCategoryAsync_DelegatesToRepository)); + f.ProductRepo.GetByCategoryAsync(3, 1).Returns(new[] { MakeProduct(1) }); + + (await f.Sut.GetProductsByCategoryAsync(3)).Should().ContainSingle(); + await f.ProductRepo.Received(1).GetByCategoryAsync(3, 1); + } + + [Fact] + public async Task GetLowStockProductsAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetLowStockProductsAsync_DelegatesToRepository)); + f.ProductRepo.GetLowStockAsync(1).Returns(new[] { MakeProduct(1) }); + + (await f.Sut.GetLowStockProductsAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task SearchProductsAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(SearchProductsAsync_DelegatesToRepository)); + f.ProductRepo.SearchAsync("term", 1).Returns(new[] { MakeProduct(1) }); + + (await f.Sut.SearchProductsAsync("term")).Should().ContainSingle(); + } + + [Fact] + public async Task GetAllCategoriesAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetAllCategoriesAsync_DelegatesToRepository)); + f.CategoryRepo.GetAllAsync(1).Returns(new[] { new Category { Id = 1, Name = "C" } }); + + (await f.Sut.GetAllCategoriesAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task GetActiveCategoriesAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetActiveCategoriesAsync_DelegatesToRepository)); + f.CategoryRepo.GetActiveAsync(1).Returns(new[] { new Category { Id = 1, Name = "C" } }); + + (await f.Sut.GetActiveCategoriesAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task GetCategoryByIdAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetCategoryByIdAsync_DelegatesToRepository)); + var category = new Category { Id = 1, Name = "C" }; + f.CategoryRepo.GetByIdAsync(1, 1).Returns(category); + + (await f.Sut.GetCategoryByIdAsync(1)).Should().BeSameAs(category); + } + + // ---- Category CRUD ---- + + [Fact] + public async Task CreateCategoryAsync_SetsWarehouseId_AndLogsAudit() + { + var f = CreateSut(nameof(CreateCategoryAsync_SetsWarehouseId_AndLogsAudit), AuthenticatedAccessor("7")); + var category = new Category { Name = "New" }; + f.CategoryRepo.CreateAsync(Arg.Do(c => c.Id = 42)).Returns(ci => ci.Arg()); + + var created = await f.Sut.CreateCategoryAsync(category); + + created.WarehouseId.Should().Be(7); + await f.AuditService.Received(1).LogCategoryCreatedAsync(42, "New"); + } + + [Fact] + public async Task UpdateCategoryAsync_LogsAudit() + { + var f = CreateSut(nameof(UpdateCategoryAsync_LogsAudit)); + var category = new Category { Id = 1, Name = "Updated" }; + f.CategoryRepo.UpdateAsync(category).Returns(category); + + var updated = await f.Sut.UpdateCategoryAsync(category); + + updated.Should().BeSameAs(category); + await f.AuditService.Received(1).LogCategoryUpdatedAsync(1, "Updated"); + } + + [Fact] + public async Task DeleteCategoryAsync_KnownCategory_LogsWithName() + { + var f = CreateSut(nameof(DeleteCategoryAsync_KnownCategory_LogsWithName)); + f.CategoryRepo.GetByIdAsync(1, 1).Returns(new Category { Id = 1, Name = "Gone" }); + + await f.Sut.DeleteCategoryAsync(1); + + await f.CategoryRepo.Received(1).DeleteAsync(1); + await f.AuditService.Received(1).LogCategoryDeletedAsync(1, "Gone"); + } + + [Fact] + public async Task DeleteCategoryAsync_UnknownCategory_LogsWithFallbackName() + { + var f = CreateSut(nameof(DeleteCategoryAsync_UnknownCategory_LogsWithFallbackName)); + f.CategoryRepo.GetByIdAsync(99, 1).Returns((Category?)null); + + await f.Sut.DeleteCategoryAsync(99); + + await f.AuditService.Received(1).LogCategoryDeletedAsync(99, "Category#99"); + } + + // ---- Product CRUD ---- + + [Fact] + public async Task CreateProductAsync_HappyPath_CreatesInitialPriceAndAudits() + { + var f = CreateSut(nameof(CreateProductAsync_HappyPath_CreatesInitialPriceAndAudits), AuthenticatedAccessor("3", "alice")); + var product = new Product { Name = "Widget", Price = 9.99m }; + f.ProductRepo.CreateAsync(Arg.Do(p => p.Id = 10)).Returns(ci => ci.Arg()); + + var created = await f.Sut.CreateProductAsync(product); + + created.WarehouseId.Should().Be(3); + await f.PriceHistoryService.Received(1).CreateInitialPriceAsync(10, 3, 9.99m, "EUR", "alice"); + await f.AuditService.Received(1).LogProductCreatedAsync(10, "Widget"); + } + + [Fact] + public async Task CreateProductAsync_Unauthenticated_UsesSystemAsCreatedBy() + { + var f = CreateSut(nameof(CreateProductAsync_Unauthenticated_UsesSystemAsCreatedBy)); + var product = new Product { Name = "Widget", Price = 5m }; + f.ProductRepo.CreateAsync(Arg.Any()).Returns(ci => ci.Arg()); + + await f.Sut.CreateProductAsync(product); + + await f.PriceHistoryService.Received(1) + .CreateInitialPriceAsync(Arg.Any(), Arg.Any(), Arg.Any(), "EUR", "System"); + } + + [Fact] + public async Task CreateProductAsync_PriceHistoryThrows_IsSwallowed_ProductStillCreatedAndAudited() + { + var f = CreateSut(nameof(CreateProductAsync_PriceHistoryThrows_IsSwallowed_ProductStillCreatedAndAudited)); + var product = new Product { Name = "Widget", Price = 5m }; + f.ProductRepo.CreateAsync(Arg.Any()).Returns(ci => ci.Arg()); + f.PriceHistoryService.CreateInitialPriceAsync(default, default, default, default!, default) + .ThrowsAsyncForAnyArgs(new InvalidOperationException("db down")); + + var act = async () => await f.Sut.CreateProductAsync(product); + + await act.Should().NotThrowAsync(); + await f.AuditService.Received(1).LogProductCreatedAsync(Arg.Any(), "Widget"); + } + + [Fact] + public async Task UpdateProductAsync_UnknownProduct_Throws() + { + var f = CreateSut(nameof(UpdateProductAsync_UnknownProduct_Throws)); + f.ProductRepo.GetByIdAsync(1, 1).Returns((Product?)null); + + var act = async () => await f.Sut.UpdateProductAsync(new Product { Id = 1 }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task UpdateProductAsync_PriceChanged_UpdatesPriceHistoryAndAudits() + { + var f = CreateSut(nameof(UpdateProductAsync_PriceChanged_UpdatesPriceHistoryAndAudits), AuthenticatedAccessor("1", "bob")); + var existing = MakeProduct(1, price: 10m); + var updatedInput = MakeProduct(1, price: 15m, name: "Widget"); + f.ProductRepo.GetByIdAsync(1, 1).Returns(existing); + f.ProductRepo.UpdateAsync(updatedInput).Returns(updatedInput); + + var result = await f.Sut.UpdateProductAsync(updatedInput); + + result.Should().BeSameAs(updatedInput); + await f.PriceHistoryService.Received(1).UpdatePriceAutomaticAsync(1, updatedInput.WarehouseId, 10m, 15m, "EUR", "bob"); + await f.AuditService.Received(1).LogProductUpdatedAsync(1, "Widget", Arg.Any()); + } + + [Fact] + public async Task UpdateProductAsync_PriceUnchanged_SkipsPriceHistory() + { + var f = CreateSut(nameof(UpdateProductAsync_PriceUnchanged_SkipsPriceHistory)); + var existing = MakeProduct(1, price: 10m); + var updatedInput = MakeProduct(1, price: 10m); + f.ProductRepo.GetByIdAsync(1, 1).Returns(existing); + f.ProductRepo.UpdateAsync(updatedInput).Returns(updatedInput); + + await f.Sut.UpdateProductAsync(updatedInput); + + await f.PriceHistoryService.DidNotReceiveWithAnyArgs() + .UpdatePriceAutomaticAsync(default, default, default, default, default!, default); + } + + [Fact] + public async Task UpdateProductAsync_PriceHistoryThrows_IsSwallowed() + { + var f = CreateSut(nameof(UpdateProductAsync_PriceHistoryThrows_IsSwallowed)); + var existing = MakeProduct(1, price: 10m); + var updatedInput = MakeProduct(1, price: 20m); + f.ProductRepo.GetByIdAsync(1, 1).Returns(existing); + f.ProductRepo.UpdateAsync(updatedInput).Returns(updatedInput); + f.PriceHistoryService.UpdatePriceAutomaticAsync(default, default, default, default, default!, default) + .ThrowsAsyncForAnyArgs(new InvalidOperationException("db down")); + + var act = async () => await f.Sut.UpdateProductAsync(updatedInput); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteProductAsync_KnownProduct_LogsWithName() + { + var f = CreateSut(nameof(DeleteProductAsync_KnownProduct_LogsWithName)); + f.ProductRepo.GetByIdAsync(1, 1).Returns(MakeProduct(1, name: "Gone")); + + await f.Sut.DeleteProductAsync(1); + + await f.ProductRepo.Received(1).DeleteAsync(1); + await f.AuditService.Received(1).LogProductDeletedAsync(1, "Gone"); + } + + [Fact] + public async Task DeleteProductAsync_UnknownProduct_LogsWithFallbackName() + { + var f = CreateSut(nameof(DeleteProductAsync_UnknownProduct_LogsWithFallbackName)); + f.ProductRepo.GetByIdAsync(99, 1).Returns((Product?)null); + + await f.Sut.DeleteProductAsync(99); + + await f.AuditService.Received(1).LogProductDeletedAsync(99, "Product#99"); + } + + // ---- Stock scan operations (exercise the private storage-location/batch helpers) ---- + + [Fact] + public async Task AddStockByScanAsync_UnknownBarcode_ReturnsNull() + { + var f = CreateSut(nameof(AddStockByScanAsync_UnknownBarcode_ReturnsNull)); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns((Product?)null); + + (await f.Sut.AddStockByScanAsync("X")).Should().BeNull(); + } + + [Fact] + public async Task AddStockByScanAsync_NoStorageLocations_StillUpdatesQuantityAndCreatesMovement() + { + var f = CreateSut(nameof(AddStockByScanAsync_NoStorageLocations_StillUpdatesQuantityAndCreatesMovement)); + var product = MakeProduct(1, quantity: 5); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns(product); + + var result = await f.Sut.AddStockByScanAsync("X", 3, "note"); + + result!.Quantity.Should().Be(8); + await f.ProductRepo.Received(1).UpdateAsync(Arg.Is(p => p.Quantity == 8)); + await f.MovementRepo.Received(1).CreateAsync(Arg.Is( + m => m.QuantityChange == 3 && m.Type == MovementType.ScanAdd && m.ScannedBarcode == "X" && m.Notes == "note")); + } + + [Fact] + public async Task AddStockByScanAsync_DistributesAcrossExistingStorageLocations() + { + var f = CreateSut(nameof(AddStockByScanAsync_DistributesAcrossExistingStorageLocations)); + var product = MakeProduct(1, quantity: 5); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns(product); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.AddRange( + new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 10 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 2, Quantity = 5 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 3, Quantity = 1 }); + await db.SaveChangesAsync(); + } + + await f.Sut.AddStockByScanAsync("X", 7); + + await using var verify = f.ContextFactory.CreateDbContext(); + var locations = await verify.ProductStorageLocations.OrderBy(l => l.StorageLocationId).ToListAsync(); + // Ordered ascending by quantity for distribution: loc3(1) gets +3, loc2(5) gets +2, loc1(10) gets +2 + locations.Single(l => l.StorageLocationId == 3).Quantity.Should().Be(4); + locations.Single(l => l.StorageLocationId == 2).Quantity.Should().Be(7); + locations.Single(l => l.StorageLocationId == 1).Quantity.Should().Be(12); + } + + [Fact] + public async Task RemoveStockByScanAsync_UnknownBarcode_ReturnsNull() + { + var f = CreateSut(nameof(RemoveStockByScanAsync_UnknownBarcode_ReturnsNull)); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns((Product?)null); + + (await f.Sut.RemoveStockByScanAsync("X")).Should().BeNull(); + } + + [Fact] + public async Task RemoveStockByScanAsync_ClampsQuantityAtZero() + { + var f = CreateSut(nameof(RemoveStockByScanAsync_ClampsQuantityAtZero)); + var product = MakeProduct(1, quantity: 2); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns(product); + + var result = await f.Sut.RemoveStockByScanAsync("X", 10); + + result!.Quantity.Should().Be(0); + await f.MovementRepo.Received(1).CreateAsync(Arg.Is(m => m.QuantityChange == -10 && m.Type == MovementType.ScanRemove)); + } + + [Fact] + public async Task RemoveStockByScanAsync_ReducesLocationsDescendingAndBatchesFifo() + { + var f = CreateSut(nameof(RemoveStockByScanAsync_ReducesLocationsDescendingAndBatchesFifo)); + var product = MakeProduct(1, quantity: 20); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns(product); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.AddRange( + new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 3 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 2, Quantity = 10 }); + db.ProductBatches.AddRange( + new ProductBatch { ProductId = 1, BatchNumber = "early", Quantity = 4, ExpiryDate = DateTime.UtcNow.AddDays(1), CreatedAt = DateTime.UtcNow }, + new ProductBatch { ProductId = 1, BatchNumber = "late", Quantity = 4, ExpiryDate = DateTime.UtcNow.AddDays(10), CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + // Remove 8: locations ordered desc by quantity -> loc2(10) first takes 8, loc1(3) untouched. + // Batches ordered by expiry -> "early" (4) fully consumed, "late" takes remaining 4. + await f.Sut.RemoveStockByScanAsync("X", 8); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.SingleAsync(l => l.StorageLocationId == 2)).Quantity.Should().Be(2); + (await verify.ProductStorageLocations.SingleAsync(l => l.StorageLocationId == 1)).Quantity.Should().Be(3); + (await verify.ProductBatches.SingleAsync(b => b.BatchNumber == "early")).Quantity.Should().Be(0); + (await verify.ProductBatches.SingleAsync(b => b.BatchNumber == "late")).Quantity.Should().Be(0); + } + + [Fact] + public async Task RemoveStockByScanAsync_NoLocationsOrBatches_StillSucceeds() + { + var f = CreateSut(nameof(RemoveStockByScanAsync_NoLocationsOrBatches_StillSucceeds)); + var product = MakeProduct(1, quantity: 5); + f.ProductRepo.GetByBarcodeAsync("X", 1).Returns(product); + + var result = await f.Sut.RemoveStockByScanAsync("X", 2); + + result!.Quantity.Should().Be(3); + } + + [Fact] + public async Task GetRecentMovementsAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetRecentMovementsAsync_DelegatesToRepository)); + f.MovementRepo.GetRecentAsync(10, 1).Returns(new[] { new StockMovement { Id = 1 } }); + + (await f.Sut.GetRecentMovementsAsync(10)).Should().ContainSingle(); + } + + [Fact] + public async Task GetMovementsByProductAsync_DelegatesToRepository() + { + var f = CreateSut(nameof(GetMovementsByProductAsync_DelegatesToRepository)); + f.MovementRepo.GetByProductAsync(1, 1).Returns(new[] { new StockMovement { Id = 1 } }); + + (await f.Sut.GetMovementsByProductAsync(1)).Should().ContainSingle(); + } + + [Fact] + public async Task GetDashboardStatsAsync_AggregatesAndSortsCategoryStats() + { + var f = CreateSut(nameof(GetDashboardStatsAsync_AggregatesAndSortsCategoryStats)); + var catA = new Category { Id = 1, Name = "A", Icon = "a" }; + var catB = new Category { Id = 2, Name = "B", Icon = "b" }; + var p1 = MakeProduct(1, price: 2m, quantity: 3); + p1.CategoryId = 1; + var p2 = MakeProduct(2, price: 2m, quantity: 1); + p2.CategoryId = 1; + var p3 = MakeProduct(3, price: 5m, quantity: 2); + p3.CategoryId = 2; + f.ProductRepo.GetAllAsync(1).Returns(new[] { p1, p2, p3 }); + f.CategoryRepo.GetActiveAsync(1).Returns(new[] { catA, catB }); + f.ProductRepo.GetLowStockAsync(1).Returns(new[] { MakeProduct(4) }); + f.MovementRepo.GetTodayMovementsAsync(1).Returns(new[] { new StockMovement { Id = 1 } }); + + var stats = await f.Sut.GetDashboardStatsAsync(); + + stats.TotalProducts.Should().Be(3); + stats.TotalCategories.Should().Be(2); + stats.LowStockCount.Should().Be(1); + stats.TodayMovements.Should().Be(1); + stats.TotalStockValue.Should().Be((int)(2m * 3 + 2m * 1 + 5m * 2)); + // Category A has 2 products (higher ProductCount) -> sorted first + stats.CategoryStats.Select(c => c.Name).Should().ContainInOrder("A", "B"); + stats.CategoryStats[0].TotalQuantity.Should().Be(4); + } + + [Fact] + public async Task AdjustStockAsync_UnknownProduct_ReturnsNull() + { + var f = CreateSut(nameof(AdjustStockAsync_UnknownProduct_ReturnsNull)); + f.ProductRepo.GetByIdAsync(1, 1).Returns((Product?)null); + + (await f.Sut.AdjustStockAsync(1, 10)).Should().BeNull(); + } + + [Fact] + public async Task AdjustStockAsync_Increase_DistributesToStorageLocations() + { + var f = CreateSut(nameof(AdjustStockAsync_Increase_DistributesToStorageLocations)); + var product = MakeProduct(1, quantity: 5); + f.ProductRepo.GetByIdAsync(1, 1).Returns(product); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 0 }); + await db.SaveChangesAsync(); + } + + var result = await f.Sut.AdjustStockAsync(1, 15, "adjust up"); + + result!.Quantity.Should().Be(15); + await f.MovementRepo.Received(1).CreateAsync(Arg.Is(m => m.QuantityChange == 10 && m.Type == MovementType.Adjustment && m.Notes == "adjust up")); + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(10); + } + + [Fact] + public async Task AdjustStockAsync_Decrease_ReducesLocationsAndBatches() + { + var f = CreateSut(nameof(AdjustStockAsync_Decrease_ReducesLocationsAndBatches)); + var product = MakeProduct(1, quantity: 15); + f.ProductRepo.GetByIdAsync(1, 1).Returns(product); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 15 }); + db.ProductBatches.Add(new ProductBatch { ProductId = 1, BatchNumber = "b1", Quantity = 15, ExpiryDate = DateTime.UtcNow.AddDays(3) }); + await db.SaveChangesAsync(); + } + + var result = await f.Sut.AdjustStockAsync(1, 5); + + result!.Quantity.Should().Be(5); + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(5); + (await verify.ProductBatches.SingleAsync()).Quantity.Should().Be(5); + } + + [Fact] + public async Task AdjustStockAsync_NoChange_CreatesZeroMovement_DoesNotTouchLocations() + { + var f = CreateSut(nameof(AdjustStockAsync_NoChange_CreatesZeroMovement_DoesNotTouchLocations)); + var product = MakeProduct(1, quantity: 5); + f.ProductRepo.GetByIdAsync(1, 1).Returns(product); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 5 }); + await db.SaveChangesAsync(); + } + + await f.Sut.AdjustStockAsync(1, 5); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(5); + await f.MovementRepo.Received(1).CreateAsync(Arg.Is(m => m.QuantityChange == 0)); + } + + // ---- Storage location queries ---- + + [Fact] + public async Task GetProductStorageLocationsAsync_IncludesStorageLocation() + { + var f = CreateSut(nameof(GetProductStorageLocationsAsync_IncludesStorageLocation)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.StorageLocations.Add(new StorageLocation { Id = 1, Code = "A1", Name = "Shelf" }); + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 3 }); + await db.SaveChangesAsync(); + } + + var list = await f.Sut.GetProductStorageLocationsAsync(1); + + list.Should().ContainSingle().Which.StorageLocation!.Code.Should().Be("A1"); + } + + [Fact] + public async Task GetActiveStorageLocationsForProductAsync_FiltersZeroAndOrdersByCode() + { + var f = CreateSut(nameof(GetActiveStorageLocationsForProductAsync_FiltersZeroAndOrdersByCode)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.StorageLocations.AddRange( + new StorageLocation { Id = 1, Code = "B1", Name = "Shelf B" }, + new StorageLocation { Id = 2, Code = "A1", Name = "Shelf A" }); + db.ProductStorageLocations.AddRange( + new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 3 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 2, Quantity = 1 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 3, Quantity = 0 }); + await db.SaveChangesAsync(); + } + + var list = await f.Sut.GetActiveStorageLocationsForProductAsync(1); + + list.Select(l => l.StorageLocation!.Code).Should().ContainInOrder("A1", "B1"); + } + + [Fact] + public async Task GetStorageLocationCountsForProductsAsync_EmptyIds_ReturnsEmptyDictionary() + { + var f = CreateSut(nameof(GetStorageLocationCountsForProductsAsync_EmptyIds_ReturnsEmptyDictionary)); + + (await f.Sut.GetStorageLocationCountsForProductsAsync(Array.Empty())).Should().BeEmpty(); + } + + [Fact] + public async Task GetStorageLocationCountsForProductsAsync_CountsPerProduct() + { + var f = CreateSut(nameof(GetStorageLocationCountsForProductsAsync_CountsPerProduct)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.AddRange( + new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 1 }, + new ProductStorageLocation { ProductId = 1, StorageLocationId = 2, Quantity = 1 }, + new ProductStorageLocation { ProductId = 2, StorageLocationId = 3, Quantity = 1 }); + await db.SaveChangesAsync(); + } + + var counts = await f.Sut.GetStorageLocationCountsForProductsAsync(new[] { 1, 2 }); + + counts[1].Should().Be(2); + counts[2].Should().Be(1); + } + + [Fact] + public async Task ReplaceProductStorageLocationsAsync_ReplacesExistingAndFiltersZeroQuantity() + { + var f = CreateSut(nameof(ReplaceProductStorageLocationsAsync_ReplacesExistingAndFiltersZeroQuantity)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 99 }); + await db.SaveChangesAsync(); + } + + await f.Sut.ReplaceProductStorageLocationsAsync(1, new[] + { + new ProductStorageLocationAssignment(2, 5), + new ProductStorageLocationAssignment(3, 0) // filtered out + }); + + await using var verify = f.ContextFactory.CreateDbContext(); + var remaining = await verify.ProductStorageLocations.ToListAsync(); + remaining.Should().ContainSingle().Which.StorageLocationId.Should().Be(2); + } + + [Fact] + public async Task GetProductBatchesAsync_OrdersByExpiryWithNullsLast() + { + var f = CreateSut(nameof(GetProductBatchesAsync_OrdersByExpiryWithNullsLast)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductBatches.AddRange( + new ProductBatch { ProductId = 1, BatchNumber = "none", Quantity = 1, ExpiryDate = null }, + new ProductBatch { ProductId = 1, BatchNumber = "soon", Quantity = 1, ExpiryDate = DateTime.UtcNow.AddDays(1) }); + await db.SaveChangesAsync(); + } + + var list = await f.Sut.GetProductBatchesAsync(1); + + list.Select(b => b.BatchNumber).Should().ContainInOrder("soon", "none"); + } + + [Fact] + public async Task GetActiveBatchesForProductAsync_FiltersZeroAndOrdersByCreatedDescending() + { + var f = CreateSut(nameof(GetActiveBatchesForProductAsync_FiltersZeroAndOrdersByCreatedDescending)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductBatches.AddRange( + new ProductBatch { ProductId = 1, BatchNumber = "old", Quantity = 1, CreatedAt = DateTime.UtcNow.AddDays(-2) }, + new ProductBatch { ProductId = 1, BatchNumber = "new", Quantity = 1, CreatedAt = DateTime.UtcNow }, + new ProductBatch { ProductId = 1, BatchNumber = "empty", Quantity = 0, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + + var list = await f.Sut.GetActiveBatchesForProductAsync(1); + + list.Select(b => b.BatchNumber).Should().ContainInOrder("new", "old"); + } + + [Fact] + public async Task ReplaceProductBatchesAsync_ReplacesExistingAndFiltersZeroQuantity() + { + var f = CreateSut(nameof(ReplaceProductBatchesAsync_ReplacesExistingAndFiltersZeroQuantity)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.ProductBatches.Add(new ProductBatch { ProductId = 1, BatchNumber = "old", Quantity = 5 }); + await db.SaveChangesAsync(); + } + + await f.Sut.ReplaceProductBatchesAsync(1, 2, new[] + { + new ProductBatch { BatchNumber = "kept", Quantity = 3, Notes = "n" }, + new ProductBatch { BatchNumber = "dropped", Quantity = 0 } + }); + + await using var verify = f.ContextFactory.CreateDbContext(); + var remaining = await verify.ProductBatches.ToListAsync(); + remaining.Should().ContainSingle(); + remaining[0].BatchNumber.Should().Be("kept"); + remaining[0].WarehouseId.Should().Be(2); + } + + // ---- ProcessScannerMovementAsync ---- + + private static ScannerMovementCommand MakeCommand( + int productId = 1, + bool isAdd = true, + int quantity = 5, + IReadOnlyList? distribution = null, + bool processBatch = false, + int? selectedBatchId = null, + string? batchNumber = null, + DateTime? batchExpiryDate = null, + DateTime? batchManufactureDate = null, + string? batchNotes = null) + => new( + productId, isAdd, quantity, "BC1", 1, + distribution ?? Array.Empty(), + processBatch, selectedBatchId, batchNumber, batchExpiryDate, batchManufactureDate, batchNotes, + "movement notes"); + + [Fact] + public async Task ProcessScannerMovementAsync_UnknownProduct_ReturnsFailure() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_UnknownProduct_ReturnsFailure)); + + var result = await f.Sut.ProcessScannerMovementAsync(MakeCommand(productId: 999)); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("scanner.productnotfound"); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Add_CreatesNewLocation_AndNewBatchWithDefaultNotes() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Add_CreatesNewLocation_AndNewBatchWithDefaultNotes)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 5)); + await db.SaveChangesAsync(); + } + + var command = MakeCommand( + isAdd: true, quantity: 4, + distribution: new[] { new ProductStorageLocationAssignment(1, 4) }, + processBatch: true, batchNumber: "NB1"); + + var result = await f.Sut.ProcessScannerMovementAsync(command); + + result.IsSuccess.Should().BeTrue(); + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.Products.FindAsync(1))!.Quantity.Should().Be(9); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(4); + var batch = await verify.ProductBatches.SingleAsync(); + batch.BatchNumber.Should().Be("NB1"); + batch.Quantity.Should().Be(4); + batch.Notes.Should().StartWith("Scanner-Eingang am"); + var movement = await verify.StockMovements.SingleAsync(); + movement.Type.Should().Be(MovementType.ScanAdd); + movement.QuantityChange.Should().Be(4); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Add_UpdatesExistingLocationAndExistingBatch() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Add_UpdatesExistingLocationAndExistingBatch)); + ProductBatch batch; + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 5)); + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 2 }); + batch = new ProductBatch { ProductId = 1, BatchNumber = "EX1", Quantity = 3, WarehouseId = 1 }; + db.ProductBatches.Add(batch); + await db.SaveChangesAsync(); + } + + var command = MakeCommand( + isAdd: true, quantity: 4, + distribution: new[] { new ProductStorageLocationAssignment(1, 4) }, + processBatch: true, selectedBatchId: batch.Id, batchNotes: "custom note"); + + await f.Sut.ProcessScannerMovementAsync(command); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(6); + (await verify.ProductBatches.SingleAsync()).Quantity.Should().Be(7); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Add_ProcessBatchWithoutSelectionOrNumber_SkipsBatchHandling() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Add_ProcessBatchWithoutSelectionOrNumber_SkipsBatchHandling)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 5)); + await db.SaveChangesAsync(); + } + + var command = MakeCommand(isAdd: true, quantity: 2, processBatch: true); + + var result = await f.Sut.ProcessScannerMovementAsync(command); + + result.IsSuccess.Should().BeTrue(); + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductBatches.AnyAsync()).Should().BeFalse(); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Remove_ClampsProductQuantityAtZero_UpdatesLocation() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Remove_ClampsProductQuantityAtZero_UpdatesLocation)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 3)); + db.ProductStorageLocations.Add(new ProductStorageLocation { ProductId = 1, StorageLocationId = 1, Quantity = 5 }); + await db.SaveChangesAsync(); + } + + var command = MakeCommand(isAdd: false, quantity: 10, distribution: new[] { new ProductStorageLocationAssignment(1, 5) }); + + await f.Sut.ProcessScannerMovementAsync(command); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.Products.FindAsync(1))!.Quantity.Should().Be(0); + (await verify.ProductStorageLocations.SingleAsync()).Quantity.Should().Be(0); + var movement = await verify.StockMovements.SingleAsync(); + movement.Type.Should().Be(MovementType.ScanRemove); + movement.QuantityChange.Should().Be(-10); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Remove_FifoAcrossBatches_RemovesEmptiedBatches() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Remove_FifoAcrossBatches_RemovesEmptiedBatches)); + ProductBatch selected, early, late; + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 20)); + selected = new ProductBatch { ProductId = 1, BatchNumber = "selected", Quantity = 2, WarehouseId = 1, ExpiryDate = DateTime.UtcNow.AddDays(5) }; + early = new ProductBatch { ProductId = 1, BatchNumber = "early", Quantity = 3, WarehouseId = 1, ExpiryDate = DateTime.UtcNow.AddDays(1) }; + late = new ProductBatch { ProductId = 1, BatchNumber = "late", Quantity = 10, WarehouseId = 1, ExpiryDate = DateTime.UtcNow.AddDays(10) }; + db.ProductBatches.AddRange(selected, early, late); + await db.SaveChangesAsync(); + } + + // Remove 8: selectedBatch (2) drained first & removed, then FIFO by expiry across the rest: + // "early" (3) drained & removed, "late" takes remaining 3 of 6 -> 7 left. + var command = MakeCommand(isAdd: false, quantity: 8, processBatch: true, selectedBatchId: selected.Id); + + await f.Sut.ProcessScannerMovementAsync(command); + + await using var verify = f.ContextFactory.CreateDbContext(); + var remainingBatches = await verify.ProductBatches.ToListAsync(); + remainingBatches.Select(b => b.BatchNumber).Should().BeEquivalentTo(new[] { "late" }); + remainingBatches.Single().Quantity.Should().Be(7); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Remove_ProcessBatchFalse_LeavesBatchesUntouched() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Remove_ProcessBatchFalse_LeavesBatchesUntouched)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 20)); + db.ProductBatches.Add(new ProductBatch { ProductId = 1, BatchNumber = "b1", Quantity = 10, WarehouseId = 1 }); + await db.SaveChangesAsync(); + } + + var command = MakeCommand(isAdd: false, quantity: 8, processBatch: false); + + await f.Sut.ProcessScannerMovementAsync(command); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductBatches.SingleAsync()).Quantity.Should().Be(10); + } + + [Fact] + public async Task ProcessScannerMovementAsync_Remove_UnknownStorageLocation_DoesNotCreateOne() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_Remove_UnknownStorageLocation_DoesNotCreateOne)); + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1, quantity: 20)); + await db.SaveChangesAsync(); + } + + var command = MakeCommand(isAdd: false, quantity: 5, distribution: new[] { new ProductStorageLocationAssignment(99, 5) }); + + await f.Sut.ProcessScannerMovementAsync(command); + + await using var verify = f.ContextFactory.CreateDbContext(); + (await verify.ProductStorageLocations.AnyAsync()).Should().BeFalse(); + } + + [Fact] + public async Task ProcessScannerMovementAsync_ExceptionDuringProcessing_ReturnsFailure() + { + var f = CreateSut(nameof(ProcessScannerMovementAsync_ExceptionDuringProcessing_ReturnsFailure)); + // Product must exist so the flow reaches SaveChangesAsync(cancellationToken), which is + // the point where EF Core reliably observes an already-canceled token and throws + // (CreateDbContextAsync itself does not check cancellation for the InMemory provider). + await using (var db = f.ContextFactory.CreateDbContext()) + { + db.Products.Add(MakeProduct(1)); + await db.SaveChangesAsync(); + } + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = await f.Sut.ProcessScannerMovementAsync(MakeCommand(), cts.Token); + + result.IsFailure.Should().BeTrue(); + result.ErrorCode.Should().Be("scanner.movementfailed"); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Notification/EmailServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Notification/EmailServiceTests.cs new file mode 100644 index 0000000..205e85c --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Notification/EmailServiceTests.cs @@ -0,0 +1,238 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace LagersystemLVHome.UnitTests.Services.Notification; + +// NOTE ON COVERAGE: EmailService news up a concrete System.Net.Mail.SmtpClient with no +// injectable seam (constructor takes a plain EmailSettings, not an abstraction over SMTP). +// With EnableEmail=true, SendEmailAsync/SendEmailWithAttachmentAsync would attempt a real +// SmtpClient.SendMailAsync() call against _settings.SmtpHost - a genuine network operation +// we must not perform in a unit test. Those specific lines (SmtpClient/MailMessage +// construction, SendMailAsync, the success log, and the surrounding try/catch's "success" +// path) are therefore intentionally left uncovered and documented here rather than exercised. +// Everything reachable without opening a socket (the EnableEmail=false early-return gate, +// and the full GetUserDisplayNameAsync resolution logic used by every template method) is +// covered below. +public class EmailServiceTests +{ + private static EmailSettings DisabledSettings() => new() + { + EnableEmail = false, + ApplicationUrl = "https://app.example.com" + }; + + private static IServiceProvider BuildProviderWithDb(string dbName, Action? seed = null) + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(dbName)); + var provider = services.BuildServiceProvider(); + + if (seed != null) + { + using var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + seed(db); + db.SaveChanges(); + } + + return provider; + } + + // A service provider whose CreateScope() throws, used to exercise GetUserDisplayNameAsync's catch branch. + private static IServiceProvider BuildThrowingProvider() + { + var provider = Substitute.For(); + provider.GetService(typeof(IServiceScopeFactory)).Returns(_ => throw new InvalidOperationException("no scope factory")); + return provider; + } + + private static EmailService Build(IServiceProvider serviceProvider, EmailSettings? settings = null) + => new(settings ?? DisabledSettings(), NullLogger.Instance, serviceProvider); + + // --- SendEmailAsync / SendEmailWithAttachmentAsync: disabled gate --- + + [Fact] + public async Task SendEmailAsync_EmailDisabled_ReturnsWithoutThrowing() + { + var provider = BuildProviderWithDb(nameof(SendEmailAsync_EmailDisabled_ReturnsWithoutThrowing)); + var sut = Build(provider); + + var act = () => sut.SendEmailAsync("to@test.local", "Subject", "Body"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendEmailWithAttachmentAsync_EmailDisabled_ReturnsWithoutThrowing() + { + var provider = BuildProviderWithDb(nameof(SendEmailWithAttachmentAsync_EmailDisabled_ReturnsWithoutThrowing)); + var sut = Build(provider); + + var act = () => sut.SendEmailWithAttachmentAsync( + "to@test.local", "Subject", "Body", new byte[] { 1, 2, 3 }, "file.pdf"); + + await act.Should().NotThrowAsync(); + } + + // --- GetUserDisplayNameAsync resolution, exercised through the public template methods --- + + [Fact] + public async Task SendWelcomeEmailAsync_UserFoundWithDisplayName_DoesNotThrow() + { + var provider = BuildProviderWithDb( + nameof(SendWelcomeEmailAsync_UserFoundWithDisplayName_DoesNotThrow), + db => db.Users.Add(new User + { + Username = "jdoe", + Email = "jdoe@test.local", + DisplayName = "Jane Doe", + PasswordHash = "x" + })); + var sut = Build(provider); + + var act = () => sut.SendWelcomeEmailAsync("jdoe@test.local", "jdoe"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendWelcomeEmailAsync_UserFoundWithoutDisplayName_FallsBackToEmailPrefix() + { + var provider = BuildProviderWithDb( + nameof(SendWelcomeEmailAsync_UserFoundWithoutDisplayName_FallsBackToEmailPrefix), + db => db.Users.Add(new User + { + Username = "jdoe", + Email = "jdoe@test.local", + DisplayName = "", + PasswordHash = "x" + })); + var sut = Build(provider); + + var act = () => sut.SendWelcomeEmailAsync("jdoe@test.local", "jdoe"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendWelcomeEmailAsync_UserNotFound_FallsBackToEmailPrefix() + { + var provider = BuildProviderWithDb(nameof(SendWelcomeEmailAsync_UserNotFound_FallsBackToEmailPrefix)); + var sut = Build(provider); + + var act = () => sut.SendWelcomeEmailAsync("unknown@test.local", "unknown"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendWelcomeEmailAsync_ScopeCreationThrows_FallsBackToEmailPrefixWithoutThrowing() + { + var sut = Build(BuildThrowingProvider()); + + var act = () => sut.SendWelcomeEmailAsync("someone@test.local", "someone"); + + await act.Should().NotThrowAsync(); + } + + // --- Remaining template methods: cover their body-building branches --- + + [Fact] + public async Task SendPasswordResetEmailAsync_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendPasswordResetEmailAsync_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendPasswordResetEmailAsync("to@test.local", "user", "reset-token-123"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendAccountApprovedEmailAsync_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendAccountApprovedEmailAsync_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendAccountApprovedEmailAsync("to@test.local", "user"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendAccountRejectedEmailAsync_WithReason_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendAccountRejectedEmailAsync_WithReason_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendAccountRejectedEmailAsync("to@test.local", "user", "Incomplete profile"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendAccountRejectedEmailAsync_WithoutReason_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendAccountRejectedEmailAsync_WithoutReason_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendAccountRejectedEmailAsync("to@test.local", "user", null); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendTwoFactorCodeEmailAsync_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendTwoFactorCodeEmailAsync_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendTwoFactorCodeEmailAsync("to@test.local", "123456"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendAccountDeletionConfirmationAsync_UsernameProvided_SkipsDisplayNameLookup() + { + // A throwing provider proves the DB lookup path is not taken when username is provided. + var sut = Build(BuildThrowingProvider()); + + var act = () => sut.SendAccountDeletionConfirmationAsync("to@test.local", "ExplicitUsername"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendAccountDeletionConfirmationAsync_NoUsername_FallsBackToDisplayNameLookup() + { + var provider = BuildProviderWithDb( + nameof(SendAccountDeletionConfirmationAsync_NoUsername_FallsBackToDisplayNameLookup), + db => db.Users.Add(new User + { + Username = "jdoe", + Email = "jdoe@test.local", + DisplayName = "Jane Doe", + PasswordHash = "x" + })); + var sut = Build(provider); + + var act = () => sut.SendAccountDeletionConfirmationAsync("jdoe@test.local", ""); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task SendLowStockAlertAsync_DoesNotThrow() + { + var provider = BuildProviderWithDb(nameof(SendLowStockAlertAsync_DoesNotThrow)); + var sut = Build(provider); + + var act = () => sut.SendLowStockAlertAsync("admin@test.local", "Widget", 2, 10); + + await act.Should().NotThrowAsync(); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Notification/NotificationServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Notification/NotificationServiceTests.cs new file mode 100644 index 0000000..625cb26 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Notification/NotificationServiceTests.cs @@ -0,0 +1,1423 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute.ExceptionExtensions; + +namespace LagersystemLVHome.UnitTests.Services.Notification; + +// The enclosing namespace segment "Notification" shadows the domain entity of the same name +// (the parent namespace's nested-namespace member wins over a using-alias declared outside +// this namespace body), so it must be aliased here, inside the namespace body, for +// unqualified use below. +using Notification = LagersystemLVHome.Domain.Models.Notification; + +public class NotificationServiceTests +{ + private sealed class InMemoryContextFactory(DbContextOptions options) + : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => new(options); + } + + // Used to exercise the outer try/catch blocks: CreateDbContextAsync always throws. + private sealed class ThrowingContextFactory : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => throw new InvalidOperationException("db unavailable"); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("db unavailable"); + } + + private static IDbContextFactory CreateFactory(string name) + => new InMemoryContextFactory( + new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + private static NotificationService Build( + IDbContextFactory factory, + IEmailService? email = null, + ITeamsService? teams = null, + NotificationChannels? channels = null, + INotificationEventService? events = null) + => new( + factory, + email ?? Substitute.For(), + teams ?? Substitute.For(), + Options.Create(channels ?? new NotificationChannels()), + NullLogger.Instance, + events ?? Substitute.For()); + + private static Warehouse MakeWarehouse(int id = 1) => new() + { + Id = id, + Name = $"WH{id}", + Address = "addr", + Code = $"W{id}", + IsActive = true + }; + + private static User MakeUser( + int id, + int warehouseId = 1, + UserRole role = UserRole.User, + bool isActive = true, + string? email = null) => new() + { + Id = id, + Username = $"u{id}", + Email = email ?? $"u{id}@test.local", + DisplayName = $"User {id}", + PasswordHash = "x", + WarehouseId = warehouseId, + Role = role, + IsActive = isActive + }; + + private static Product MakeProduct(int id = 1, int warehouseId = 1, int quantity = 1, string name = "Widget") => new() + { + Id = id, + Name = name, + WarehouseId = warehouseId, + Quantity = quantity, + Price = 1 + }; + + // ==================== CreateNotificationAsync (public, 6-arg) ==================== + + [Fact] + public async Task CreateNotificationAsync_UserNotFound_DoesNothing() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_UserNotFound_DoesNothing)); + var sut = Build(factory); + + await sut.CreateNotificationAsync(999, NotificationType.Info, "T", "M"); + + await using var db = factory.CreateDbContext(); + (await db.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CreateNotificationAsync_InAppChannel_SavesNotificationAndCreatesDefaultSettings() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_InAppChannel_SavesNotificationAndCreatesDefaultSettings)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CreateNotificationAsync(1, NotificationType.Info, "Title", "Message", "/x", NotificationChannel.InApp); + + await using var verifyDb = factory.CreateDbContext(); + var notification = await verifyDb.Notifications.SingleAsync(); + notification.Title.Should().Be("Title"); + notification.UserId.Should().Be(1); + notification.IsRead.Should().BeFalse(); + (await verifyDb.UserNotificationSettings.CountAsync()).Should().Be(1, "GetUserSettingsAsync should create default settings"); + } + + [Fact] + public async Task CreateNotificationAsync_InAppDisabledForType_DoesNotSaveNotification() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_InAppDisabledForType_DoesNotSaveNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, InAppLowStock = false }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.InApp); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CreateNotificationAsync_EmailChannel_SendsEmailWhenEnabled() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_EmailChannel_SendsEmailWhenEnabled)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, email: "target@test.local")); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, EmailLowStock = true }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.Email); + + await email.Received(1).SendEmailAsync("target@test.local", "Title", "Message"); + } + + [Fact] + public async Task CreateNotificationAsync_EmailDisabledForType_DoesNotSendEmail() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_EmailDisabledForType_DoesNotSendEmail)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, EmailLowStock = false }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.Email); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!); + } + + [Fact] + public async Task CreateNotificationAsync_EmailThrows_IsSwallowedAndDoesNotPropagate() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_EmailThrows_IsSwallowedAndDoesNotPropagate)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, EmailLowStock = true, InAppLowStock = true }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + email.SendEmailAsync(default!, default!, default!).ThrowsForAnyArgs(new InvalidOperationException("smtp down")); + var sut = Build(factory, email: email); + + var act = () => sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.All); + + await act.Should().NotThrowAsync(); + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(1, "in-app notification should still be saved despite email failure"); + } + + [Fact] + public async Task CreateNotificationAsync_PushChannel_LogsSkippedAndDoesNotThrow() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_PushChannel_LogsSkippedAndDoesNotThrow)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, PushLowStock = true }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var act = () => sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.Push); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateNotificationAsync_PushDisabledForType_DoesNotThrow() + { + var factory = CreateFactory(nameof(CreateNotificationAsync_PushDisabledForType_DoesNotThrow)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, PushLowStock = false }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var act = () => sut.CreateNotificationAsync(1, NotificationType.LowStock, "Title", "Message", channel: NotificationChannel.Push); + + await act.Should().NotThrowAsync(); + } + + [Theory] + [InlineData(NotificationType.CriticalStock)] + [InlineData(NotificationType.NewUser)] + [InlineData(NotificationType.SecurityAlert)] + [InlineData(NotificationType.SystemUpdate)] + [InlineData(NotificationType.Info)] + public async Task CreateNotificationAsync_AllChannel_RespectsShouldSendSwitchesForEachType(NotificationType type) + { + var factory = CreateFactory($"{nameof(CreateNotificationAsync_AllChannel_RespectsShouldSendSwitchesForEachType)}_{type}"); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + var act = () => sut.CreateNotificationAsync(1, type, "Title", "Message"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateNotificationAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.CreateNotificationAsync(1, NotificationType.Info, "T", "M"); + + await act.Should().NotThrowAsync(); + } + + // ==================== CreateLowStockNotificationAsync ==================== + + [Fact] + public async Task CreateLowStockNotificationAsync_QuantityInLowRange_CreatesNotificationForEligibleUsers() + { + var factory = CreateFactory(nameof(CreateLowStockNotificationAsync_QuantityInLowRange_CreatesNotificationForEligibleUsers)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.Users.Add(MakeUser(2, role: UserRole.User)); // wrong role: excluded + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 2, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + var product = MakeProduct(quantity: 7); + + await sut.CreateLowStockNotificationAsync(product); + + await using var verifyDb = factory.CreateDbContext(); + var notifications = await verifyDb.Notifications.ToListAsync(); + notifications.Should().ContainSingle().Which.UserId.Should().Be(1); + notifications[0].Type.Should().Be(NotificationType.LowStock); + } + + [Fact] + public async Task CreateLowStockNotificationAsync_QuantityBelowCriticalThreshold_DoesNotCreateLowStockNotification() + { + var factory = CreateFactory(nameof(CreateLowStockNotificationAsync_QuantityBelowCriticalThreshold_DoesNotCreateLowStockNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + var product = MakeProduct(quantity: 2); // below critical threshold -> not "low stock" range + + await sut.CreateLowStockNotificationAsync(product); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CreateLowStockNotificationAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + var product = MakeProduct(); + + var act = () => sut.CreateLowStockNotificationAsync(product); + + await act.Should().NotThrowAsync(); + } + + // ==================== CreateCriticalStockNotificationAsync ==================== + + [Fact] + public async Task CreateCriticalStockNotificationAsync_QuantityAtOrBelowThreshold_CreatesNotification() + { + var factory = CreateFactory(nameof(CreateCriticalStockNotificationAsync_QuantityAtOrBelowThreshold_CreatesNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, CriticalStockThreshold = 5 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + var product = MakeProduct(quantity: 3); + + await sut.CreateCriticalStockNotificationAsync(product); + + await using var verifyDb = factory.CreateDbContext(); + var notification = await verifyDb.Notifications.SingleAsync(); + notification.Type.Should().Be(NotificationType.CriticalStock); + } + + [Fact] + public async Task CreateCriticalStockNotificationAsync_QuantityAboveThreshold_DoesNotCreateNotification() + { + var factory = CreateFactory(nameof(CreateCriticalStockNotificationAsync_QuantityAboveThreshold_DoesNotCreateNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, CriticalStockThreshold = 5 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + var product = MakeProduct(quantity: 20); + + await sut.CreateCriticalStockNotificationAsync(product); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CreateCriticalStockNotificationAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + var product = MakeProduct(); + + var act = () => sut.CreateCriticalStockNotificationAsync(product); + + await act.Should().NotThrowAsync(); + } + + // ==================== CreateNewUserNotificationAsync ==================== + + [Fact] + public async Task CreateNewUserNotificationAsync_NotifiesActiveAdminsInSameWarehouse_ExcludingNewUser() + { + var factory = CreateFactory(nameof(CreateNewUserNotificationAsync_NotifiesActiveAdminsInSameWarehouse_ExcludingNewUser)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.Users.Add(MakeUser(2, role: UserRole.SuperAdmin)); + db.Users.Add(MakeUser(3, role: UserRole.Admin, isActive: false)); // inactive: excluded + db.Users.Add(MakeUser(4, role: UserRole.User)); // wrong role: excluded + var newUser = MakeUser(5, role: UserRole.User); + db.Users.Add(newUser); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await using var lookupDb = factory.CreateDbContext(); + var createdNewUser = await lookupDb.Users.FirstAsync(u => u.Id == 5); + + await sut.CreateNewUserNotificationAsync(createdNewUser); + + await using var verifyDb = factory.CreateDbContext(); + var notifications = await verifyDb.Notifications.ToListAsync(); + notifications.Select(n => n.UserId).Should().BeEquivalentTo(new[] { 1, 2 }); + notifications.Should().OnlyContain(n => n.Type == NotificationType.NewUser); + } + + [Fact] + public async Task CreateNewUserNotificationAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.CreateNewUserNotificationAsync(MakeUser(1)); + + await act.Should().NotThrowAsync(); + } + + // ==================== CreateSecurityAlertAsync ==================== + + [Fact] + public async Task CreateSecurityAlertAsync_CreatesNotificationForUser() + { + var factory = CreateFactory(nameof(CreateSecurityAlertAsync_CreatesNotificationForUser)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CreateSecurityAlertAsync(1, "Suspicious login detected"); + + await using var verifyDb = factory.CreateDbContext(); + var notification = await verifyDb.Notifications.SingleAsync(); + notification.Type.Should().Be(NotificationType.SecurityAlert); + notification.Message.Should().Be("Suspicious login detected"); + } + + // NOTE: CreateSecurityAlertAsync's try/catch wraps only a call to the private + // CreateNotificationAsync(int, ...) overload, which itself catches and swallows every + // exception internally and never rethrows (see NotificationService.cs lines 42-122). + // As a result CreateSecurityAlertAsync's own catch block (LogSecurityAlertCreateError) + // is unreachable dead code under normal operation - there is no way to make the inner + // call throw from outside the class to exercise it. Flagged as a suspected bug/dead + // code below in the final report; intentionally left uncovered here. + + // ==================== GetUserNotificationsAsync ==================== + + [Fact] + public async Task GetUserNotificationsAsync_UserNotFound_ReturnsEmptyList() + { + var factory = CreateFactory(nameof(GetUserNotificationsAsync_UserNotFound_ReturnsEmptyList)); + var sut = Build(factory); + + var result = await sut.GetUserNotificationsAsync(999); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetUserNotificationsAsync_RegularUser_ExcludesSecurityAlertsAndOtherWarehouses() + { + var factory = CreateFactory(nameof(GetUserNotificationsAsync_RegularUser_ExcludesSecurityAlertsAndOtherWarehouses)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Warehouses.Add(MakeWarehouse(2)); + db.Users.Add(MakeUser(1, warehouseId: 1, role: UserRole.User)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "A", WarehouseId = 1, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.SecurityAlert, Title = "B", WarehouseId = 1, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "C", WarehouseId = 2, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "D", WarehouseId = null, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUserNotificationsAsync(1); + + result.Select(n => n.Title).Should().BeEquivalentTo(new[] { "A", "D" }); + } + + [Fact] + public async Task GetUserNotificationsAsync_SuperAdmin_SeesEverything() + { + var factory = CreateFactory(nameof(GetUserNotificationsAsync_SuperAdmin_SeesEverything)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Warehouses.Add(MakeWarehouse(2)); + db.Users.Add(MakeUser(1, warehouseId: 1, role: UserRole.SuperAdmin)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.SecurityAlert, Title = "A", WarehouseId = 2, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUserNotificationsAsync(1); + + result.Should().ContainSingle().Which.Title.Should().Be("A"); + } + + [Fact] + public async Task GetUserNotificationsAsync_UnreadOnly_FiltersReadNotifications() + { + var factory = CreateFactory(nameof(GetUserNotificationsAsync_UnreadOnly_FiltersReadNotifications)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "Read", IsRead = true, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "Unread", IsRead = false, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUserNotificationsAsync(1, unreadOnly: true); + + result.Should().ContainSingle().Which.Title.Should().Be("Unread"); + } + + [Fact] + public async Task GetUserNotificationsAsync_RespectsLimit() + { + var factory = CreateFactory(nameof(GetUserNotificationsAsync_RespectsLimit)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + for (var i = 0; i < 5; i++) + { + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = $"N{i}", CreatedAt = DateTime.UtcNow.AddMinutes(i) }); + } + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUserNotificationsAsync(1, limit: 2); + + result.Should().HaveCount(2); + } + + [Fact] + public async Task GetUserNotificationsAsync_OuterException_ReturnsEmptyList() + { + var sut = Build(new ThrowingContextFactory()); + + var result = await sut.GetUserNotificationsAsync(1); + + result.Should().BeEmpty(); + } + + // ==================== GetUnreadCountAsync ==================== + + [Fact] + public async Task GetUnreadCountAsync_UserNotFound_ReturnsZero() + { + var factory = CreateFactory(nameof(GetUnreadCountAsync_UserNotFound_ReturnsZero)); + var sut = Build(factory); + + var result = await sut.GetUnreadCountAsync(999); + + result.Should().Be(0); + } + + [Fact] + public async Task GetUnreadCountAsync_RegularUser_ExcludesSecurityAlertsAndCountsUnreadOnly() + { + var factory = CreateFactory(nameof(GetUnreadCountAsync_RegularUser_ExcludesSecurityAlertsAndCountsUnreadOnly)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.User)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, IsRead = false, WarehouseId = 1, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, IsRead = true, WarehouseId = 1, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.SecurityAlert, IsRead = false, WarehouseId = 1, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUnreadCountAsync(1); + + result.Should().Be(1); + } + + [Fact] + public async Task GetUnreadCountAsync_SuperAdmin_CountsEverythingUnread() + { + var factory = CreateFactory(nameof(GetUnreadCountAsync_SuperAdmin_CountsEverythingUnread)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.SecurityAlert, IsRead = false, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var result = await sut.GetUnreadCountAsync(1); + + result.Should().Be(1); + } + + [Fact] + public async Task GetUnreadCountAsync_OuterException_ReturnsZero() + { + var sut = Build(new ThrowingContextFactory()); + + var result = await sut.GetUnreadCountAsync(1); + + result.Should().Be(0); + } + + // ==================== MarkAsReadAsync ==================== + + [Fact] + public async Task MarkAsReadAsync_UnreadNotification_MarksReadAndRaisesEvent() + { + var factory = CreateFactory(nameof(MarkAsReadAsync_UnreadNotification_MarksReadAndRaisesEvent)); + int notificationId; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + var notification = new Notification { UserId = 1, Type = NotificationType.Info, Title = "T", IsRead = false, CreatedAt = DateTime.UtcNow }; + db.Notifications.Add(notification); + await db.SaveChangesAsync(); + notificationId = notification.Id; + } + var events = Substitute.For(); + var sut = Build(factory, events: events); + + await sut.MarkAsReadAsync(notificationId); + + await using var verifyDb = factory.CreateDbContext(); + var updated = await verifyDb.Notifications.FindAsync(notificationId); + updated!.IsRead.Should().BeTrue(); + updated.ReadAt.Should().NotBeNull(); + events.Received(1).NotifyChanged(); + } + + [Fact] + public async Task MarkAsReadAsync_AlreadyRead_DoesNotRaiseEvent() + { + var factory = CreateFactory(nameof(MarkAsReadAsync_AlreadyRead_DoesNotRaiseEvent)); + int notificationId; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + var notification = new Notification { UserId = 1, Type = NotificationType.Info, Title = "T", IsRead = true, CreatedAt = DateTime.UtcNow }; + db.Notifications.Add(notification); + await db.SaveChangesAsync(); + notificationId = notification.Id; + } + var events = Substitute.For(); + var sut = Build(factory, events: events); + + await sut.MarkAsReadAsync(notificationId); + + events.DidNotReceive().NotifyChanged(); + } + + [Fact] + public async Task MarkAsReadAsync_NotificationNotFound_DoesNothing() + { + var factory = CreateFactory(nameof(MarkAsReadAsync_NotificationNotFound_DoesNothing)); + var events = Substitute.For(); + var sut = Build(factory, events: events); + + var act = () => sut.MarkAsReadAsync(999); + + await act.Should().NotThrowAsync(); + events.DidNotReceive().NotifyChanged(); + } + + [Fact] + public async Task MarkAsReadAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.MarkAsReadAsync(1); + + await act.Should().NotThrowAsync(); + } + + // ==================== MarkAllAsReadAsync ==================== + + [Fact] + public async Task MarkAllAsReadAsync_MarksAllUnreadAndRaisesEvent() + { + var factory = CreateFactory(nameof(MarkAllAsReadAsync_MarksAllUnreadAndRaisesEvent)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, IsRead = false, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, IsRead = false, CreatedAt = DateTime.UtcNow }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, IsRead = true, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var events = Substitute.For(); + var sut = Build(factory, events: events); + + await sut.MarkAllAsReadAsync(1); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync(n => !n.IsRead)).Should().Be(0); + events.Received(1).NotifyChanged(); + } + + [Fact] + public async Task MarkAllAsReadAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.MarkAllAsReadAsync(1); + + await act.Should().NotThrowAsync(); + } + + // ==================== DeleteNotificationAsync ==================== + + [Fact] + public async Task DeleteNotificationAsync_Found_RemovesIt() + { + var factory = CreateFactory(nameof(DeleteNotificationAsync_Found_RemovesIt)); + int notificationId; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + var notification = new Notification { UserId = 1, Type = NotificationType.Info, Title = "T", CreatedAt = DateTime.UtcNow }; + db.Notifications.Add(notification); + await db.SaveChangesAsync(); + notificationId = notification.Id; + } + var sut = Build(factory); + + await sut.DeleteNotificationAsync(notificationId); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task DeleteNotificationAsync_NotFound_DoesNothing() + { + var factory = CreateFactory(nameof(DeleteNotificationAsync_NotFound_DoesNothing)); + var sut = Build(factory); + + var act = () => sut.DeleteNotificationAsync(999); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteNotificationAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.DeleteNotificationAsync(1); + + await act.Should().NotThrowAsync(); + } + + // ==================== DeleteOldNotificationsAsync ==================== + + [Fact] + public async Task DeleteOldNotificationsAsync_RemovesOldReadNotifications_KeepsRecentOrUnread() + { + var factory = CreateFactory(nameof(DeleteOldNotificationsAsync_RemovesOldReadNotifications_KeepsRecentOrUnread)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1)); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "Old-Read", IsRead = true, CreatedAt = DateTime.UtcNow.AddDays(-40) }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "Old-Unread", IsRead = false, CreatedAt = DateTime.UtcNow.AddDays(-40) }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "Recent-Read", IsRead = true, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.DeleteOldNotificationsAsync(30); + + await using var verifyDb = factory.CreateDbContext(); + var remaining = await verifyDb.Notifications.Select(n => n.Title).ToListAsync(); + remaining.Should().BeEquivalentTo(new[] { "Old-Unread", "Recent-Read" }); + } + + [Fact] + public async Task DeleteOldNotificationsAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.DeleteOldNotificationsAsync(); + + await act.Should().NotThrowAsync(); + } + + // ==================== GetUserSettingsAsync / UpdateUserSettingsAsync ==================== + + [Fact] + public async Task GetUserSettingsAsync_NoExistingSettings_CreatesDefaults() + { + var factory = CreateFactory(nameof(GetUserSettingsAsync_NoExistingSettings_CreatesDefaults)); + var sut = Build(factory); + + var settings = await sut.GetUserSettingsAsync(1); + + settings.LowStockThreshold.Should().Be(10); + settings.CriticalStockThreshold.Should().Be(5); + settings.EmailLowStock.Should().BeTrue(); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.UserNotificationSettings.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task GetUserSettingsAsync_ExistingSettings_ReturnsThem() + { + var factory = CreateFactory(nameof(GetUserSettingsAsync_ExistingSettings_ReturnsThem)); + await using (var db = factory.CreateDbContext()) + { + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 99 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + var settings = await sut.GetUserSettingsAsync(1); + + settings.LowStockThreshold.Should().Be(99); + } + + [Fact] + public async Task GetUserSettingsAsync_OuterException_ReturnsFallbackSettings() + { + var sut = Build(new ThrowingContextFactory()); + + var settings = await sut.GetUserSettingsAsync(42); + + settings.UserId.Should().Be(42); + } + + [Fact] + public async Task UpdateUserSettingsAsync_UpdatesTimestampAndPersists() + { + var factory = CreateFactory(nameof(UpdateUserSettingsAsync_UpdatesTimestampAndPersists)); + await using (var db = factory.CreateDbContext()) + { + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10 }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + UserNotificationSettings toUpdate; + await using (var db = factory.CreateDbContext()) + { + toUpdate = await db.UserNotificationSettings.SingleAsync(s => s.UserId == 1); + } + toUpdate.LowStockThreshold = 25; + + await sut.UpdateUserSettingsAsync(toUpdate); + + await using var verifyDb = factory.CreateDbContext(); + var updated = await verifyDb.UserNotificationSettings.SingleAsync(s => s.UserId == 1); + updated.LowStockThreshold.Should().Be(25); + } + + [Fact] + public async Task UpdateUserSettingsAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.UpdateUserSettingsAsync(new UserNotificationSettings { UserId = 1 }); + + await act.Should().NotThrowAsync(); + } + + // ==================== CheckLowStockAndNotifyAsync ==================== + + [Fact] + public async Task CheckLowStockAndNotifyAsync_CriticalQuantity_CreatesCriticalStockNotification() + { + var factory = CreateFactory(nameof(CheckLowStockAndNotifyAsync_CriticalQuantity_CreatesCriticalStockNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + db.Products.Add(MakeProduct(quantity: 3, name: "CriticalWidget")); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CheckLowStockAndNotifyAsync(); + + await using var verifyDb = factory.CreateDbContext(); + var notification = await verifyDb.Notifications.SingleAsync(); + notification.Type.Should().Be(NotificationType.CriticalStock); + } + + [Fact] + public async Task CheckLowStockAndNotifyAsync_LowQuantity_CreatesLowStockNotification() + { + var factory = CreateFactory(nameof(CheckLowStockAndNotifyAsync_LowQuantity_CreatesLowStockNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + db.Products.Add(MakeProduct(quantity: 8, name: "LowWidget")); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CheckLowStockAndNotifyAsync(); + + await using var verifyDb = factory.CreateDbContext(); + var notification = await verifyDb.Notifications.SingleAsync(); + notification.Type.Should().Be(NotificationType.LowStock); + } + + [Fact] + public async Task CheckLowStockAndNotifyAsync_SufficientQuantity_CreatesNoNotification() + { + var factory = CreateFactory(nameof(CheckLowStockAndNotifyAsync_SufficientQuantity_CreatesNoNotification)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Products.Add(MakeProduct(quantity: 500, name: "PlentyWidget")); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CheckLowStockAndNotifyAsync(); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CheckLowStockAndNotifyAsync_RecentNotificationExists_SkipsDuplicate() + { + var factory = CreateFactory(nameof(CheckLowStockAndNotifyAsync_RecentNotificationExists_SkipsDuplicate)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin)); + db.UserNotificationSettings.Add(new UserNotificationSettings { UserId = 1, LowStockThreshold = 10, CriticalStockThreshold = 5 }); + db.Products.Add(MakeProduct(quantity: 3, name: "DupWidget")); + db.Notifications.Add(new Notification + { + UserId = 1, + Type = NotificationType.CriticalStock, + Title = "Existing", + ActionUrl = "/products?search=DupWidget", + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await db.SaveChangesAsync(); + } + var sut = Build(factory); + + await sut.CheckLowStockAndNotifyAsync(); + + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(1, "no duplicate notification should be created within 24h"); + } + + [Fact] + public async Task CheckLowStockAndNotifyAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.CheckLowStockAndNotifyAsync(); + + await act.Should().NotThrowAsync(); + } + + // ==================== SendDailyDigestAsync ==================== + + [Fact] + public async Task SendDailyDigestAsync_UserWithUnreadNotifications_SendsDigestEmail() + { + var factory = CreateFactory(nameof(SendDailyDigestAsync_UserWithUnreadNotifications_SendsDigestEmail)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, email: "digest@test.local")); + // DigestTime must match the current hour for the user to be picked up. + db.UserNotificationSettings.Add(new UserNotificationSettings + { + UserId = 1, + DailyDigest = true, + DigestTime = new TimeSpan(DateTime.Now.Hour, 0, 0) + }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "N1", Message = "M1", IsRead = false, CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.SendDailyDigestAsync(); + + await email.Received(1).SendEmailAsync( + "digest@test.local", + Arg.Any(), + Arg.Is(m => m.Contains("N1"))); + } + + [Fact] + public async Task SendDailyDigestAsync_UserWithNoUnreadNotifications_SkipsEmail() + { + var factory = CreateFactory(nameof(SendDailyDigestAsync_UserWithNoUnreadNotifications_SkipsEmail)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, email: "digest@test.local")); + db.UserNotificationSettings.Add(new UserNotificationSettings + { + UserId = 1, + DailyDigest = true, + DigestTime = new TimeSpan(DateTime.Now.Hour, 0, 0) + }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.SendDailyDigestAsync(); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!); + } + + [Fact] + public async Task SendDailyDigestAsync_DigestTimeDoesNotMatchCurrentHour_UserIsSkipped() + { + var factory = CreateFactory(nameof(SendDailyDigestAsync_DigestTimeDoesNotMatchCurrentHour_UserIsSkipped)); + var otherHour = (DateTime.Now.Hour + 1) % 24; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, email: "digest@test.local")); + db.UserNotificationSettings.Add(new UserNotificationSettings + { + UserId = 1, + DailyDigest = true, + DigestTime = new TimeSpan(otherHour, 0, 0) + }); + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = "N1", CreatedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.SendDailyDigestAsync(); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!); + } + + [Fact] + public async Task SendDailyDigestAsync_MoreThanTenUnread_TruncatesDigestToTen() + { + var factory = CreateFactory(nameof(SendDailyDigestAsync_MoreThanTenUnread_TruncatesDigestToTen)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, email: "digest@test.local")); + db.UserNotificationSettings.Add(new UserNotificationSettings + { + UserId = 1, + DailyDigest = true, + DigestTime = new TimeSpan(DateTime.Now.Hour, 0, 0) + }); + for (var i = 0; i < 15; i++) + { + db.Notifications.Add(new Notification { UserId = 1, Type = NotificationType.Info, Title = $"N{i}", IsRead = false, CreatedAt = DateTime.UtcNow }); + } + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var sut = Build(factory, email: email); + + await sut.SendDailyDigestAsync(); + + await email.Received(1).SendEmailAsync( + "digest@test.local", + Arg.Any(), + Arg.Is(m => m.Contains("15 ungelesene"))); + } + + [Fact] + public async Task SendDailyDigestAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.SendDailyDigestAsync(); + + await act.Should().NotThrowAsync(); + } + + // ==================== Push notification stubs ==================== + + [Fact] + public async Task SendPushNotificationAsync_NotConfigured_ReturnsFalse() + { + var factory = CreateFactory(nameof(SendPushNotificationAsync_NotConfigured_ReturnsFalse)); + var sut = Build(factory); + + var result = await sut.SendPushNotificationAsync(1, "Title", "Body"); + + result.Should().BeFalse(); + } + + [Fact] + public async Task RequestPushPermissionAsync_NotConfigured_ReturnsTrue() + { + var factory = CreateFactory(nameof(RequestPushPermissionAsync_NotConfigured_ReturnsTrue)); + var sut = Build(factory); + + var result = await sut.RequestPushPermissionAsync(1, "subscription-data"); + + result.Should().BeTrue(); + } + + // ==================== SendLowStockAlertAsync (multi-channel) ==================== + + [Fact] + public async Task SendLowStockAlertAsync_AllChannelsEnabled_NotifiesEmailAndTeams() + { + var factory = CreateFactory(nameof(SendLowStockAlertAsync_AllChannelsEnabled_NotifiesEmailAndTeams)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin, email: "admin@test.local")); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.LowStockAlerts.InApp = true; + channels.LowStockAlerts.Email = true; + channels.LowStockAlerts.Teams = true; + var sut = Build(factory, email: email, teams: teams, channels: channels); + + await sut.SendLowStockAlertAsync("Widget", 2, 10, "Main Warehouse"); + + await email.Received(1).SendEmailAsync( + "admin@test.local", + Arg.Is(s => s.Contains("Niedriger Bestand")), + Arg.Is(b => b.Contains("Widget") && b.Contains("Main Warehouse")), + isHtml: true); + await teams.Received(1).SendLowStockAlertAsync("Widget", 2, 10, "Main Warehouse"); + } + + [Fact] + public async Task SendLowStockAlertAsync_AllChannelsDisabled_NotifiesNothing() + { + var factory = CreateFactory(nameof(SendLowStockAlertAsync_AllChannelsDisabled_NotifiesNothing)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin, email: "admin@test.local")); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.LowStockAlerts.InApp = false; + channels.LowStockAlerts.Email = false; + channels.LowStockAlerts.Teams = false; + var sut = Build(factory, email: email, teams: teams, channels: channels); + + await sut.SendLowStockAlertAsync("Widget", 2, 10); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!, isHtml: default); + await teams.DidNotReceiveWithAnyArgs().SendLowStockAlertAsync(default!, default, default); + } + + [Fact] + public async Task SendLowStockAlertAsync_EmailWithoutWarehouseName_OmitsWarehouseLine() + { + var factory = CreateFactory(nameof(SendLowStockAlertAsync_EmailWithoutWarehouseName_OmitsWarehouseLine)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin, email: "admin@test.local")); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var channels = new NotificationChannels(); + channels.LowStockAlerts.InApp = false; + channels.LowStockAlerts.Email = true; + channels.LowStockAlerts.Teams = false; + var sut = Build(factory, email: email, channels: channels); + + await sut.SendLowStockAlertAsync("Widget", 2, 10); + + await email.Received(1).SendEmailAsync( + "admin@test.local", + Arg.Any(), + Arg.Is(b => !b.Contains("Lager:")), + isHtml: true); + } + + [Fact] + public async Task SendLowStockAlertAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.SendLowStockAlertAsync("Widget", 2, 10); + + await act.Should().NotThrowAsync(); + } + + // ==================== SendExpiryAlertAsync (multi-channel) ==================== + + [Fact] + public async Task SendExpiryAlertAsync_AllChannelsEnabled_NotifiesEmailAndTeams() + { + var factory = CreateFactory(nameof(SendExpiryAlertAsync_AllChannelsEnabled_NotifiesEmailAndTeams)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin, email: "admin@test.local")); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.InApp = true; + channels.ExpiryAlerts.Email = true; + channels.ExpiryAlerts.Teams = true; + var sut = Build(factory, email: email, teams: teams, channels: channels); + var expiry = DateTime.UtcNow.AddDays(3); + + await sut.SendExpiryAlertAsync("Milk", expiry, 5, "Fridge A"); + + await email.Received(1).SendEmailAsync( + "admin@test.local", + Arg.Is(s => s.Contains("MHD-Warnung")), + Arg.Is(b => b.Contains("Milk") && b.Contains("Fridge A")), + isHtml: true); + await teams.Received(1).SendExpiryAlertAsync("Milk", expiry, 5, "Fridge A"); + } + + [Fact] + public async Task SendExpiryAlertAsync_EmailWithoutLocation_OmitsLocationLine() + { + var factory = CreateFactory(nameof(SendExpiryAlertAsync_EmailWithoutLocation_OmitsLocationLine)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.Admin, email: "admin@test.local")); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.InApp = false; + channels.ExpiryAlerts.Email = true; + channels.ExpiryAlerts.Teams = false; + var sut = Build(factory, email: email, channels: channels); + + await sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(1), 5); + + await email.Received(1).SendEmailAsync( + "admin@test.local", + Arg.Any(), + Arg.Is(b => !b.Contains("Lagerort:")), + isHtml: true); + } + + [Fact] + public async Task SendExpiryAlertAsync_AllChannelsDisabled_NotifiesNothing() + { + var factory = CreateFactory(nameof(SendExpiryAlertAsync_AllChannelsDisabled_NotifiesNothing)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.InApp = false; + channels.ExpiryAlerts.Email = false; + channels.ExpiryAlerts.Teams = false; + var sut = Build(factory, email: email, teams: teams, channels: channels); + + await sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(1), 5); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!, isHtml: default); + await teams.DidNotReceiveWithAnyArgs().SendExpiryAlertAsync(default!, default, default, default); + } + + [Fact] + public async Task SendExpiryAlertAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(1), 5); + + await act.Should().NotThrowAsync(); + } + + // ==================== SendSecurityAlertAsync (multi-channel) ==================== + + [Fact] + public async Task SendSecurityAlertAsync_AllChannelsEnabled_NotifiesSuperAdminsAndTeams() + { + var factory = CreateFactory(nameof(SendSecurityAlertAsync_AllChannelsEnabled_NotifiesSuperAdminsAndTeams)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin, email: "super@test.local")); + db.Users.Add(MakeUser(2, role: UserRole.Admin, email: "admin@test.local")); // not a super admin: excluded from email + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.SecurityAlerts.InApp = true; + channels.SecurityAlerts.Email = true; + channels.SecurityAlerts.Teams = true; + var sut = Build(factory, email: email, teams: teams, channels: channels); + + await sut.SendSecurityAlertAsync("Breach Detected", "Unauthorized access attempt", "high"); + + await email.Received(1).SendEmailAsync( + "super@test.local", + Arg.Is(s => s.Contains("Breach Detected")), + Arg.Is(b => b.Contains("Unauthorized access attempt") && b.Contains("high")), + isHtml: true); + await teams.Received(1).SendSystemAlertAsync("Breach Detected", "Unauthorized access attempt", "high"); + } + + [Fact] + public async Task SendSecurityAlertAsync_AllChannelsDisabled_NotifiesNothing() + { + var factory = CreateFactory(nameof(SendSecurityAlertAsync_AllChannelsDisabled_NotifiesNothing)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + await db.SaveChangesAsync(); + } + var email = Substitute.For(); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.SecurityAlerts.InApp = false; + channels.SecurityAlerts.Email = false; + channels.SecurityAlerts.Teams = false; + var sut = Build(factory, email: email, teams: teams, channels: channels); + + await sut.SendSecurityAlertAsync("Title", "Message", "low"); + + await email.DidNotReceiveWithAnyArgs().SendEmailAsync(default!, default!, default!, isHtml: default); + await teams.DidNotReceiveWithAnyArgs().SendSystemAlertAsync(default!, default!, default!); + } + + [Fact] + public async Task SendSecurityAlertAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.SendSecurityAlertAsync("Title", "Message", "high"); + + await act.Should().NotThrowAsync(); + } + + // ==================== SendSystemAlertAsync (multi-channel) ==================== + + [Fact] + public async Task SendSystemAlertAsync_AllChannelsEnabled_NotifiesInAppAndTeams() + { + var factory = CreateFactory(nameof(SendSystemAlertAsync_AllChannelsEnabled_NotifiesInAppAndTeams)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse()); + db.Users.Add(MakeUser(1, role: UserRole.SuperAdmin)); + await db.SaveChangesAsync(); + } + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.SystemAlerts.InApp = true; + channels.SystemAlerts.Teams = true; + var sut = Build(factory, teams: teams, channels: channels); + + await sut.SendSystemAlertAsync("Maintenance", "System will restart", "warning"); + + await teams.Received(1).SendSystemAlertAsync("Maintenance", "System will restart", "warning"); + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task SendSystemAlertAsync_AllChannelsDisabled_NotifiesNothing() + { + var factory = CreateFactory(nameof(SendSystemAlertAsync_AllChannelsDisabled_NotifiesNothing)); + var teams = Substitute.For(); + var channels = new NotificationChannels(); + channels.SystemAlerts.InApp = false; + channels.SystemAlerts.Teams = false; + var sut = Build(factory, teams: teams, channels: channels); + + await sut.SendSystemAlertAsync("Title", "Message", "info"); + + await teams.DidNotReceiveWithAnyArgs().SendSystemAlertAsync(default!, default!, default!); + await using var verifyDb = factory.CreateDbContext(); + (await verifyDb.Notifications.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task SendSystemAlertAsync_OuterException_IsSwallowed() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.SendSystemAlertAsync("Title", "Message", "info"); + + await act.Should().NotThrowAsync(); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Notification/TeamsServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Notification/TeamsServiceTests.cs new file mode 100644 index 0000000..fbcb98a --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Notification/TeamsServiceTests.cs @@ -0,0 +1,479 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using LagersystemLVHome.Application.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace LagersystemLVHome.UnitTests.Services.Notification; + +public class TeamsServiceTests +{ + // Records every posted payload so tests can assert on the JSON body sent to the webhook. + private sealed class FakeHandler : HttpMessageHandler + { + private readonly Func _responder; + public List RequestBodies { get; } = new(); + public List RequestUris { get; } = new(); + public int CallCount { get; private set; } + + public FakeHandler(Func responder) + { + _responder = responder; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + RequestUris.Add(request.RequestUri?.ToString()); + if (request.Content != null) + { + RequestBodies.Add(await request.Content.ReadAsStringAsync(cancellationToken)); + } + return _responder(request); + } + } + + private static TeamsService Build( + FakeHandler handler, + TeamsSettings? teamsSettings = null, + NotificationChannels? channels = null) + { + var client = new HttpClient(handler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(client); + httpClientFactory.CreateClient().Returns(client); + + var settings = teamsSettings ?? new TeamsSettings + { + EnableTeams = true, + WebhookUrl = "https://teams.example.com/webhook", + MaxRetries = 1 + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["EmailSettings:ApplicationUrl"] = "https://app.example.com" + }) + .Build(); + + return new TeamsService( + Options.Create(settings), + Options.Create(channels ?? new NotificationChannels()), + NullLogger.Instance, + httpClientFactory, + configuration); + } + + private static HttpResponseMessage Ok() => new(HttpStatusCode.OK); + private static HttpResponseMessage ServerError() => new(HttpStatusCode.InternalServerError) + { + Content = new StringContent("boom") + }; + + // --- IsEnabled / IsEnabledForType --- + + [Fact] + public void IsEnabled_TeamsDisabled_ReturnsFalse() + { + var sut = Build(new FakeHandler(_ => Ok()), new TeamsSettings { EnableTeams = false, WebhookUrl = "https://x" }); + sut.IsEnabled().Should().BeFalse(); + } + + [Fact] + public void IsEnabled_NoWebhookUrl_ReturnsFalse() + { + var sut = Build(new FakeHandler(_ => Ok()), new TeamsSettings { EnableTeams = true, WebhookUrl = "" }); + sut.IsEnabled().Should().BeFalse(); + } + + [Fact] + public void IsEnabled_EnabledWithWebhook_ReturnsTrue() + { + var sut = Build(new FakeHandler(_ => Ok()), new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x" }); + sut.IsEnabled().Should().BeTrue(); + } + + [Theory] + [InlineData("lowstock", true, true, true)] + [InlineData("lowstock", true, false, false)] + [InlineData("expiry", true, true, true)] + [InlineData("anomaly", true, true, true)] + [InlineData("securityrisk", true, true, true)] + [InlineData("system", true, true, true)] + [InlineData("unknown-type", true, true, false)] + public void IsEnabledForType_RespectsSettingsAndChannelConfig( + string type, bool enableTeams, bool enableForType, bool expected) + { + var teamsSettings = new TeamsSettings + { + EnableTeams = enableTeams, + WebhookUrl = "https://x", + EnableForLowStock = enableForType, + EnableForExpiry = enableForType, + EnableForAnomalies = enableForType, + EnableForSecurityRisks = enableForType, + EnableForSystemAlerts = enableForType + }; + var channels = new NotificationChannels(); + channels.LowStockAlerts.Teams = enableForType; + channels.ExpiryAlerts.Teams = enableForType; + channels.SecurityAlerts.Teams = enableForType; + channels.SystemAlerts.Teams = enableForType; + + var sut = Build(new FakeHandler(_ => Ok()), teamsSettings, channels); + + sut.IsEnabledForType(type).Should().Be(expected); + } + + [Fact] + public void IsEnabledForType_GloballyDisabled_ReturnsFalseRegardlessOfType() + { + var sut = Build(new FakeHandler(_ => Ok()), new TeamsSettings { EnableTeams = false, WebhookUrl = "" }); + sut.IsEnabledForType("lowstock").Should().BeFalse(); + } + + [Fact] + public void IsEnabledForType_IsCaseInsensitive() + { + var teamsSettings = new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", EnableForLowStock = true }; + var channels = new NotificationChannels(); + channels.LowStockAlerts.Teams = true; + var sut = Build(new FakeHandler(_ => Ok()), teamsSettings, channels); + + sut.IsEnabledForType("LowStock").Should().BeTrue(); + } + + // --- SendMessageAsync --- + + [Fact] + public async Task SendMessageAsync_Disabled_ReturnsFalseWithoutHttpCall() + { + var handler = new FakeHandler(_ => Ok()); + var sut = Build(handler, new TeamsSettings { EnableTeams = false, WebhookUrl = "" }); + + var result = await sut.SendMessageAsync("Title", "Message"); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(0); + } + + [Fact] + public async Task SendMessageAsync_Enabled_PostsAdaptiveCardAndReturnsTrue() + { + var handler = new FakeHandler(_ => Ok()); + var sut = Build(handler); + + var result = await sut.SendMessageAsync("Hello", "World"); + + result.Should().BeTrue(); + handler.CallCount.Should().Be(1); + handler.RequestBodies[0].Should().Contain("Hello").And.Contain("World").And.Contain("AdaptiveCard"); + } + + // --- SendLowStockAlertAsync --- + + [Fact] + public async Task SendLowStockAlertAsync_DisabledForType_ReturnsFalseWithoutHttpCall() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.LowStockAlerts.Teams = false; + var sut = Build(handler, channels: channels); + + var result = await sut.SendLowStockAlertAsync("Widget", 2, 10); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(0); + } + + [Fact] + public async Task SendLowStockAlertAsync_WithWarehouseName_IncludesWarehouseFact() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.LowStockAlerts.Teams = true; + var sut = Build(handler, channels: channels); + + var result = await sut.SendLowStockAlertAsync("Widget", 2, 10, "Main Warehouse"); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain("Widget").And.Contain("Main Warehouse"); + } + + [Fact] + public async Task SendLowStockAlertAsync_WithoutWarehouseName_OmitsWarehouseFact() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.LowStockAlerts.Teams = true; + var sut = Build(handler, channels: channels); + + var result = await sut.SendLowStockAlertAsync("Widget", 2, 10); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain("Widget").And.NotContain("Lager"); + } + + // --- SendExpiryAlertAsync --- + + [Theory] + [InlineData(-1, "ABGELAUFEN")] + [InlineData(3, "Kritisch")] + [InlineData(30, "Warnung")] + public async Task SendExpiryAlertAsync_UrgencyDependsOnDaysUntilExpiry(int daysFromNow, string expectedUrgency) + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.Teams = true; + var sut = Build(handler, channels: channels); + + var result = await sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(daysFromNow), 5, "Fridge"); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain(expectedUrgency).And.Contain("Fridge"); + } + + [Fact] + public async Task SendExpiryAlertAsync_DisabledForType_ReturnsFalse() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.Teams = false; + var sut = Build(handler, channels: channels); + + var result = await sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(1), 5); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(0); + } + + [Fact] + public async Task SendExpiryAlertAsync_WithoutLocation_OmitsLocationFact() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.ExpiryAlerts.Teams = true; + var sut = Build(handler, channels: channels); + + var result = await sut.SendExpiryAlertAsync("Milk", DateTime.UtcNow.AddDays(1), 5); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain("Milk").And.NotContain("Lagerort"); + } + + // --- SendAnomalyAlertAsync --- + + [Theory] + [InlineData(0.9, "Kritisch")] + [InlineData(0.7, "Hoch")] + [InlineData(0.3, "Mittel")] + public async Task SendAnomalyAlertAsync_SeverityDependsOnScore(double score, string expectedSeverity) + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SecurityAlerts.Teams = true; + var teamsSettings = new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", EnableForAnomalies = true, MaxRetries = 1 }; + var sut = Build(handler, teamsSettings, channels); + + var result = await sut.SendAnomalyAlertAsync("Unusual", score, "Weird pattern detected", "Product #5"); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain(expectedSeverity).And.Contain("Product #5"); + } + + [Fact] + public async Task SendAnomalyAlertAsync_WithoutAffectedEntity_OmitsFact() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SecurityAlerts.Teams = true; + var teamsSettings = new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", EnableForAnomalies = true, MaxRetries = 1 }; + var sut = Build(handler, teamsSettings, channels); + + var result = await sut.SendAnomalyAlertAsync("Unusual", 0.5, "Weird pattern detected"); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().NotContain("Betroffenes Objekt"); + } + + [Fact] + public async Task SendAnomalyAlertAsync_DisabledForType_ReturnsFalse() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SecurityAlerts.Teams = false; + var sut = Build(handler, channels: channels); + + var result = await sut.SendAnomalyAlertAsync("Unusual", 0.9, "desc"); + + result.Should().BeFalse(); + } + + // --- SendSecurityRiskAlertAsync --- + + [Theory] + [InlineData("critical", "KRITISCH")] + [InlineData("high", "Hoch")] + [InlineData("low", "Mittel")] + public async Task SendSecurityRiskAlertAsync_SeverityDependsOnRiskLevel(string riskLevel, string expectedSeverity) + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SecurityAlerts.Teams = true; + var teamsSettings = new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", EnableForSecurityRisks = true, MaxRetries = 1 }; + var sut = Build(handler, teamsSettings, channels); + + var result = await sut.SendSecurityRiskAlertAsync( + "jdoe", riskLevel, 7.5, new List { "f1", "f2", "f3", "f4" }); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain(expectedSeverity).And.Contain("jdoe"); + // Only the first 3 risk factors should be included (Take(3)). + handler.RequestBodies[0].Should().Contain("f1").And.Contain("f2").And.Contain("f3").And.NotContain("f4"); + } + + [Fact] + public async Task SendSecurityRiskAlertAsync_DisabledForType_ReturnsFalse() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SecurityAlerts.Teams = false; + var sut = Build(handler, channels: channels); + + var result = await sut.SendSecurityRiskAlertAsync("jdoe", "high", 5, new List()); + + result.Should().BeFalse(); + } + + // --- SendSystemAlertAsync --- + + [Theory] + [InlineData("error")] + [InlineData("warning")] + [InlineData("info")] + [InlineData("other")] + public async Task SendSystemAlertAsync_MapsSeverityToColor(string severity) + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SystemAlerts.Teams = true; + var teamsSettings = new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", EnableForSystemAlerts = true, MaxRetries = 1 }; + var sut = Build(handler, teamsSettings, channels); + + var result = await sut.SendSystemAlertAsync("System Down", "The system is down", severity); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain("System Down").And.Contain(severity); + } + + [Fact] + public async Task SendSystemAlertAsync_DisabledForType_ReturnsFalse() + { + var handler = new FakeHandler(_ => Ok()); + var channels = new NotificationChannels(); + channels.SystemAlerts.Teams = false; + var sut = Build(handler, channels: channels); + + var result = await sut.SendSystemAlertAsync("Title", "Msg", "info"); + + result.Should().BeFalse(); + } + + // --- SendAdaptiveCardAsync --- + + [Fact] + public async Task SendAdaptiveCardAsync_Disabled_ReturnsFalseWithoutHttpCall() + { + var handler = new FakeHandler(_ => Ok()); + var sut = Build(handler, new TeamsSettings { EnableTeams = false, WebhookUrl = "" }); + + var result = await sut.SendAdaptiveCardAsync(new { type = "AdaptiveCard" }); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(0); + } + + [Fact] + public async Task SendAdaptiveCardAsync_Enabled_PostsCardAndReturnsTrue() + { + var handler = new FakeHandler(_ => Ok()); + var sut = Build(handler); + + var customCard = new { type = "AdaptiveCard", body = new object[] { new { type = "TextBlock", text = "custom" } } }; + var result = await sut.SendAdaptiveCardAsync(customCard); + + result.Should().BeTrue(); + handler.RequestBodies[0].Should().Contain("custom"); + } + + // --- SendToTeamsAsync retry / failure behaviour --- + + [Fact] + public async Task SendMessageAsync_SingleAttemptWebhookFailure_ReturnsFalseWithoutDelay() + { + // MaxRetries = 1 means the loop exits immediately after the first failure + // (retries(1) < MaxRetries(1) is false), so no Task.Delay is invoked - keeps the test fast. + var handler = new FakeHandler(_ => ServerError()); + var sut = Build(handler, new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", MaxRetries = 1 }); + + var result = await sut.SendMessageAsync("Title", "Message"); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(1); + } + + [Fact] + public async Task SendMessageAsync_FailsThenSucceeds_RetriesAndReturnsTrue() + { + // First call returns a server error (exercises the retry-with-delay branch), second call succeeds. + var callNumber = 0; + var handler = new FakeHandler(_ => + { + callNumber++; + return callNumber == 1 ? ServerError() : Ok(); + }); + var sut = Build(handler, new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", MaxRetries = 2 }); + + var result = await sut.SendMessageAsync("Title", "Message"); + + result.Should().BeTrue(); + handler.CallCount.Should().Be(2); + } + + [Fact] + public async Task SendMessageAsync_ThrowsThenSucceeds_RetriesAndReturnsTrue() + { + // First call throws (exercises the catch-block retry-with-delay branch), second call succeeds. + var callNumber = 0; + var handler = new FakeHandler(_ => + { + callNumber++; + if (callNumber == 1) + { + throw new HttpRequestException("network error"); + } + return Ok(); + }); + var sut = Build(handler, new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", MaxRetries = 2 }); + + var result = await sut.SendMessageAsync("Title", "Message"); + + result.Should().BeTrue(); + handler.CallCount.Should().Be(2); + } + + [Fact] + public async Task SendMessageAsync_AllAttemptsThrow_ReturnsFalseAfterExhaustingRetries() + { + var handler = new FakeHandler(_ => throw new HttpRequestException("network error")); + var sut = Build(handler, new TeamsSettings { EnableTeams = true, WebhookUrl = "https://x", MaxRetries = 1 }); + + var result = await sut.SendMessageAsync("Title", "Message"); + + result.Should().BeFalse(); + handler.CallCount.Should().Be(1); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Reporting/DashboardServiceTests.cs b/LagersystemLVHome.UnitTests/Services/Reporting/DashboardServiceTests.cs new file mode 100644 index 0000000..66975dc --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Reporting/DashboardServiceTests.cs @@ -0,0 +1,755 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace LagersystemLVHome.UnitTests.Services.Reporting; + +public class DashboardServiceTests +{ + private sealed class InMemoryContextFactory(DbContextOptions options) + : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => new(options); + } + + /// + /// A context factory that fails from the Nth call onwards, used to exercise the + /// try/catch fallback paths that are otherwise unreachable with a healthy InMemory provider. + /// + private sealed class ThrowingContextFactory : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => throw new InvalidOperationException("Simulated DB failure"); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated DB failure"); + } + + private static IDbContextFactory CreateFactory(string name) + => new InMemoryContextFactory( + new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + /// + /// Cache stub that always executes the factory delegate directly (no real caching), + /// so tests observe fresh data on every call. + /// + private static ICacheService CreatePassthroughCache() + { + var cache = Substitute.For(); + cache.GetOrCreateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any>>(), + Arg.Any()) + .Returns(ci => ci.Arg>>()()); + return cache; + } + + private static DashboardService Build( + IDbContextFactory factory, + ICacheService? cache = null, + DashboardSettings? settings = null) + => new(factory, cache ?? CreatePassthroughCache(), settings ?? new DashboardSettings(), NullLogger.Instance); + + // ---- Entity builders ------------------------------------------------- + + private static Warehouse MakeWarehouse(int id, string name = "WH") => new() + { + Id = id, + Name = name, + Code = $"W{id:000}", + Address = "addr", + IsActive = true + }; + + private static Category MakeCategory(int id, int warehouseId, string? name = null) => new() + { + Id = id, + Name = name ?? $"Cat{id}", + WarehouseId = warehouseId + }; + + private static Product MakeProduct(int id, int warehouseId, int categoryId, int quantity, int minQuantity, decimal price) => new() + { + Id = id, + Name = $"Product{id}", + WarehouseId = warehouseId, + CategoryId = categoryId, + Quantity = quantity, + MinQuantity = minQuantity, + Price = price + }; + + private static StorageLocation MakeLocation(int id, int warehouseId, int? maxCapacity = null) => new() + { + Id = id, + Code = $"L{id}", + Name = $"Location{id}", + WarehouseId = warehouseId, + MaxCapacity = maxCapacity + }; + + private static ProductStorageLocation MakeLink(int productId, int storageLocationId, int quantity) => new() + { + ProductId = productId, + StorageLocationId = storageLocationId, + Quantity = quantity + }; + + private static StockMovement MakeMovement(int productId, int warehouseId, int quantityChange, MovementType type, DateTime timestamp) => new() + { + ProductId = productId, + WarehouseId = warehouseId, + QuantityChange = quantityChange, + Type = type, + Timestamp = timestamp + }; + + private static ProductBatch MakeBatch(int productId, int warehouseId, int quantity, DateTime? expiryDate) => new() + { + ProductId = productId, + WarehouseId = warehouseId, + BatchNumber = $"B-{productId}-{Guid.NewGuid():N}", + Quantity = quantity, + ExpiryDate = expiryDate + }; + + private static ProductPrice MakePrice(int productId, int warehouseId, decimal price, DateTime validFrom) => new() + { + ProductId = productId, + WarehouseId = warehouseId, + Price = price, + ValidFrom = validFrom + }; + + // ---- GetDashboardDataAsync(warehouseId, from, to, ct) ----------------- + + [Fact] + public async Task GetDashboardDataAsync_EmptyDatabase_ReturnsZeroedDefaults() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_EmptyDatabase_ReturnsZeroedDefaults)); + var sut = Build(factory); + + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.TotalProducts.Should().Be(0); + data.TotalStockValue.Should().Be(0); + data.AverageProductValue.Should().Be(0, "AverageProductValue is only set when TotalProducts > 0"); + data.InventoryHealthScore.Should().Be(0, "no products means the health score short-circuits to 0"); + data.AbcAnalysis.TotalValue.Should().Be(0); + data.ExpiryAnalytics.TotalAtRisk.Should().Be(0); + data.StorageUtilization.TotalLocations.Should().Be(0); + data.RecentMovements.Should().BeEmpty(); + } + + [Fact] + public async Task GetDashboardDataAsync_NoWarehouseFilter_AggregatesKpisAcrossWarehouses() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_NoWarehouseFilter_AggregatesKpisAcrossWarehouses)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.AddRange(MakeWarehouse(1, "WH1"), MakeWarehouse(2, "WH2")); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.AddRange( + MakeProduct(1, 1, 1, quantity: 10, minQuantity: 5, price: 20), // healthy stock + MakeProduct(2, 1, 1, quantity: 2, minQuantity: 5, price: 15), // low stock + MakeProduct(3, 2, 1, quantity: 0, minQuantity: 5, price: 30)); // out of stock + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.TotalProducts.Should().Be(3); + data.TotalCategories.Should().Be(1); + data.TotalWarehouses.Should().Be(2); + data.TotalStockQuantity.Should().Be(12); + data.LowStockCount.Should().Be(2, "products 2 and 3 have Quantity <= MinQuantity"); + // No ScanAdd movements exist -> FIFO falls back to Quantity * Price for every product. + data.TotalStockValue.Should().Be(10 * 20 + 2 * 15 + 0 * 30); + data.AverageProductValue.Should().Be(data.TotalStockValue / 3); + } + + [Fact] + public async Task GetDashboardDataAsync_WithWarehouseFilter_OnlyIncludesProductsInThatWarehouse() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_WithWarehouseFilter_OnlyIncludesProductsInThatWarehouse)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.AddRange(MakeWarehouse(1), MakeWarehouse(2)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.AddRange( + MakeProduct(1, 1, 1, 5, 1, 10), + MakeProduct(2, 2, 1, 5, 1, 10)); + db.StorageLocations.AddRange(MakeLocation(10, 1), MakeLocation(20, 2)); + db.ProductStorageLocations.AddRange( + MakeLink(1, 10, 5), + MakeLink(2, 20, 5)); + db.StockMovements.AddRange( + MakeMovement(1, 1, 1, MovementType.ScanAdd, DateTime.UtcNow.AddDays(-1)), + MakeMovement(2, 2, 1, MovementType.ScanAdd, DateTime.UtcNow.AddDays(-1))); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: 1); + + data.TotalProducts.Should().Be(1); + data.RecentMovements.Should().ContainSingle().Which.ProductId.Should().Be(1); + } + + [Fact] + public async Task GetDashboardDataAsync_RecentMovements_LimitedToTwentyMostRecent() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_RecentMovements_LimitedToTwentyMostRecent)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, 100, 1, 1)); + for (var i = 0; i < 25; i++) + { + db.StockMovements.Add(MakeMovement(1, 1, 1, MovementType.ManualAdd, DateTime.UtcNow.AddMinutes(-i))); + } + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.RecentMovements.Should().HaveCount(20); + data.RecentMovements.Should().BeInDescendingOrder(m => m.Timestamp); + } + + [Fact] + public async Task GetDashboardDataAsync_ContextFactoryThrows_ReturnsDefaultDashboardData() + { + var sut = Build(new ThrowingContextFactory()); + + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.Should().NotBeNull(); + data.TotalProducts.Should().Be(0); + } + + // ---- GetDashboardDataAsync(ct) -- current-user overload --------------- + + [Fact] + public async Task GetDashboardDataAsync_CtOverload_NoUsers_ReturnsDefaultDashboardData() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_CtOverload_NoUsers_ReturnsDefaultDashboardData)); + var sut = Build(factory); + + var data = await sut.GetDashboardDataAsync(CancellationToken.None); + + data.Should().NotBeNull(); + data.TotalProducts.Should().Be(0); + } + + [Fact] + public async Task GetDashboardDataAsync_CtOverload_UserWithWarehouse_FiltersByUsersWarehouse() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_CtOverload_UserWithWarehouse_FiltersByUsersWarehouse)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.AddRange(MakeWarehouse(1), MakeWarehouse(2)); + db.Categories.Add(MakeCategory(1, 1)); + db.Users.Add(new User { Id = 1, Username = "u1", Email = "u1@x.local", PasswordHash = "x", WarehouseId = 1 }); + db.Products.AddRange( + MakeProduct(1, 1, 1, 5, 1, 10), + MakeProduct(2, 2, 1, 5, 1, 10)); + db.StorageLocations.AddRange(MakeLocation(10, 1), MakeLocation(20, 2)); + db.ProductStorageLocations.AddRange(MakeLink(1, 10, 5), MakeLink(2, 20, 5)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(CancellationToken.None); + + data.TotalProducts.Should().Be(1, "only the product reachable via the user's warehouse should be counted"); + } + + [Fact] + public async Task GetDashboardDataAsync_CtOverload_UserWithZeroWarehouseId_DoesNotFilter() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_CtOverload_UserWithZeroWarehouseId_DoesNotFilter)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.AddRange(MakeWarehouse(1), MakeWarehouse(2)); + db.Categories.Add(MakeCategory(1, 1)); + db.Users.Add(new User { Id = 1, Username = "u1", Email = "u1@x.local", PasswordHash = "x", WarehouseId = 0 }); + db.Products.AddRange( + MakeProduct(1, 1, 1, 5, 1, 10), + MakeProduct(2, 2, 1, 5, 1, 10)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(CancellationToken.None); + + data.TotalProducts.Should().Be(2, "WarehouseId 0 is falsy so no warehouse filter is applied"); + } + + // ---- Inventory health score (via public entry point) ------------------ + + [Fact] + public async Task GetDashboardDataAsync_HealthyBalancedStock_ScoreClampedAtHundred() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_HealthyBalancedStock_ScoreClampedAtHundred)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 100, minQuantity: 5, price: 1)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + // lowStockRatio=0, outOfStockRatio=0, expiredRatio=0, balancedStock bonus=+10 -> clamped to 100. + data.InventoryHealthScore.Should().Be(100); + } + + [Fact] + public async Task GetDashboardDataAsync_OutOfStockProduct_ReducesHealthScore() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_OutOfStockProduct_ReducesHealthScore)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 0, minQuantity: 5, price: 1)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + // lowStockRatio=1 (-30), outOfStockRatio=1 (-40), no expired, no balanced bonus -> 100-30-40=30. + data.InventoryHealthScore.Should().Be(30); + } + + [Fact] + public async Task GetDashboardDataAsync_ExpiredBatch_ReducesHealthScore() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_ExpiredBatch_ReducesHealthScore)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 100, minQuantity: 5, price: 1)); + db.ProductBatches.Add(MakeBatch(1, 1, quantity: 1, expiryDate: DateTime.UtcNow.AddDays(-1))); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + // Balanced-stock bonus (+10) offset by expired-batch penalty (expiredRatio=1 -> -20) -> 100-20+10=90. + data.InventoryHealthScore.Should().Be(90); + } + + // ---- Stock turnover rate ----------------------------------------------- + + [Fact] + public async Task GetDashboardDataAsync_StockTurnoverRate_ComputedFromLast30DaysOutbound() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_StockTurnoverRate_ComputedFromLast30DaysOutbound)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 10, minQuantity: 1, price: 1)); + db.StockMovements.AddRange( + MakeMovement(1, 1, -4, MovementType.ScanRemove, DateTime.UtcNow.AddDays(-2)), // in window + MakeMovement(1, 1, -100, MovementType.ScanRemove, DateTime.UtcNow.AddDays(-40))); // outside window + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + // soldUnits=4, avgInventory=10 -> rate=0.4 + data.StockTurnoverRate.Should().Be(0.4); + } + + [Fact] + public async Task GetDashboardDataAsync_NoProducts_TurnoverRateIsZero() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_NoProducts_TurnoverRateIsZero)); + var sut = Build(factory); + + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.StockTurnoverRate.Should().Be(0); + } + + // ---- ABC analysis -------------------------------------------------------- + + [Fact] + public async Task GetDashboardDataAsync_AbcAnalysis_ClassifiesByCumulativeValuePercentage() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_AbcAnalysis_ClassifiesByCumulativeValuePercentage)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + // Values chosen so cumulative percentages land exactly on the 80% / 95% boundaries. + db.Products.AddRange( + MakeProduct(1, 1, 1, quantity: 1, minQuantity: 1, price: 800), // 80% cumulative -> class A + MakeProduct(2, 1, 1, quantity: 1, minQuantity: 1, price: 150), // 95% cumulative -> class B + MakeProduct(3, 1, 1, quantity: 1, minQuantity: 1, price: 50)); // 100% cumulative -> class C + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.AbcAnalysis.TotalValue.Should().Be(1000); + data.AbcAnalysis.ClassACount.Should().Be(1); + data.AbcAnalysis.ClassAValue.Should().Be(800); + data.AbcAnalysis.ClassBCount.Should().Be(1); + data.AbcAnalysis.ClassBValue.Should().Be(150); + data.AbcAnalysis.ClassCCount.Should().Be(1); + data.AbcAnalysis.ClassCValue.Should().Be(50); + } + + [Fact] + public async Task GetDashboardDataAsync_AbcAnalysis_TotalValueZero_AllProductsClassC() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_AbcAnalysis_TotalValueZero_AllProductsClassC)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.AddRange( + MakeProduct(1, 1, 1, quantity: 0, minQuantity: 1, price: 10), + MakeProduct(2, 1, 1, quantity: 0, minQuantity: 1, price: 20)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.AbcAnalysis.TotalValue.Should().Be(0); + data.AbcAnalysis.ClassCCount.Should().Be(2); + data.AbcAnalysis.ClassACount.Should().Be(0); + data.AbcAnalysis.ClassBCount.Should().Be(0); + } + + // ---- Expiry analytics ------------------------------------------------------ + + [Fact] + public async Task GetDashboardDataAsync_ExpiryAnalytics_BucketsBatchesByExpiryWindow() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_ExpiryAnalytics_BucketsBatchesByExpiryWindow)); + var today = DateTime.UtcNow.Date; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 100, minQuantity: 1, price: 10)); + db.ProductBatches.AddRange( + MakeBatch(1, 1, quantity: 1, expiryDate: today.AddDays(-1)), // expired + MakeBatch(1, 1, quantity: 2, expiryDate: today.AddDays(3)), // expiring soon (<=7) + MakeBatch(1, 1, quantity: 3, expiryDate: today.AddDays(20)), // expiring this month (8-30) + MakeBatch(1, 1, quantity: 4, expiryDate: today.AddDays(60))); // beyond 30 days -> ignored + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.ExpiryAnalytics.ExpiredCount.Should().Be(1); + data.ExpiryAnalytics.ExpiredValue.Should().Be(1 * 10); + data.ExpiryAnalytics.ExpiringSoonCount.Should().Be(1); + data.ExpiryAnalytics.ExpiringSoonValue.Should().Be(2 * 10); + data.ExpiryAnalytics.ExpiringThisMonthCount.Should().Be(1); + data.ExpiryAnalytics.ExpiringThisMonthValue.Should().Be(3 * 10); + data.ExpiryAnalytics.TotalAtRisk.Should().Be(3, "the 60-day-out batch is beyond all three buckets"); + } + + // ---- Storage utilization ----------------------------------------------- + + [Fact] + public async Task GetDashboardDataAsync_StorageUtilization_NoLocationsWithCapacity_ReturnsBasicCounts() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_StorageUtilization_NoLocationsWithCapacity_ReturnsBasicCounts)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.StorageLocations.Add(MakeLocation(1, 1, maxCapacity: null)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.StorageUtilization.TotalLocations.Should().Be(1); + data.StorageUtilization.OccupiedLocations.Should().Be(0); + data.StorageUtilization.LocationsWithCapacity.Should().Be(0); + data.StorageUtilization.AverageUtilization.Should().Be(0); + } + + [Fact] + public async Task GetDashboardDataAsync_StorageUtilization_ComputesOccupancyAndFullLocations() + { + var factory = CreateFactory(nameof(GetDashboardDataAsync_StorageUtilization_ComputesOccupancyAndFullLocations)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 100, minQuantity: 1, price: 1)); + db.StorageLocations.AddRange( + MakeLocation(1, 1, maxCapacity: 100), // 12% utilized + MakeLocation(2, 1, maxCapacity: 10), // 90% utilized -> "full" + MakeLocation(3, 1, maxCapacity: 0), // zero-capacity guard -> utilization 0, not a div/0 + MakeLocation(4, 1, maxCapacity: null)); // excluded from utilization stats entirely + db.ProductStorageLocations.AddRange( + MakeLink(1, 1, 12), + MakeLink(1, 2, 9)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var data = await sut.GetDashboardDataAsync(warehouseId: null); + + data.StorageUtilization.TotalLocations.Should().Be(4); + data.StorageUtilization.OccupiedLocations.Should().Be(2); + data.StorageUtilization.EmptyLocations.Should().Be(2); + data.StorageUtilization.LocationsWithCapacity.Should().Be(3); + data.StorageUtilization.FullLocations.Should().Be(1); + data.StorageUtilization.AverageUtilization.Should().BeApproximately((12 + 90 + 0) / 3.0, 0.001); + } + + // ---- GetStockTrendsAsync ------------------------------------------------ + + [Fact] + public async Task GetStockTrendsAsync_ReturnsOneEntryPerDayWithInOutTotals() + { + var factory = CreateFactory(nameof(GetStockTrendsAsync_ReturnsOneEntryPerDayWithInOutTotals)); + var today = DateTime.UtcNow.Date; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.Add(MakeProduct(1, 1, 1, quantity: 10, minQuantity: 1, price: 5)); + db.StockMovements.AddRange( + MakeMovement(1, 1, 10, MovementType.ScanAdd, today.AddDays(-1)), + MakeMovement(1, 1, -4, MovementType.ScanRemove, today.AddDays(-1)), + MakeMovement(1, 1, 3, MovementType.ScanAdd, today.AddDays(-10))); // outside 3-day window + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var trends = await sut.GetStockTrendsAsync(days: 3); + + trends.Should().HaveCount(3); + var dayWithMovements = trends.Single(t => t.Date == today.AddDays(-1)); + dayWithMovements.StockIn.Should().Be(10); + dayWithMovements.StockOut.Should().Be(4); + dayWithMovements.TotalStock.Should().Be(6); + dayWithMovements.Value.Should().Be((10 + 4) * 5); + trends.Where(t => t.Date != today.AddDays(-1)).Should().OnlyContain(t => t.StockIn == 0 && t.StockOut == 0); + } + + [Fact] + public async Task GetStockTrendsAsync_ContextFactoryThrows_ReturnsEmptyList() + { + var sut = Build(new ThrowingContextFactory()); + + var trends = await sut.GetStockTrendsAsync(days: 5); + + trends.Should().BeEmpty(); + } + + // ---- GetTopMoversAsync ---------------------------------------------------- + + [Fact] + public async Task GetTopMoversAsync_OrdersByMovementCountDescendingAndRespectsWindow() + { + // Note: Product.CategoryId is a required (non-nullable) FK with OnDelete(Restrict), so a + // Product can never legitimately reference a missing Category in production. The + // EF Core InMemory provider also silently drops rows with an unmatched required FK when + // using .Include(), so the "Ohne Kategorie" fallback branch cannot be exercised here and + // is effectively unreachable/defensive-only code; both products use a real category instead. + var factory = CreateFactory(nameof(GetTopMoversAsync_OrdersByMovementCountDescendingAndRespectsWindow)); + var recent = DateTime.UtcNow.AddDays(-1); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1, "CatA")); + db.Products.AddRange( + MakeProduct(1, 1, 1, 10, 1, 2), + MakeProduct(2, 1, 1, 10, 1, 3)); + db.StockMovements.AddRange( + MakeMovement(1, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(1, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(1, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(2, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(2, 1, -1, MovementType.ManualRemove, DateTime.UtcNow.AddDays(-40))); // outside window + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var topMovers = await sut.GetTopMoversAsync(count: 10); + + topMovers.Should().HaveCount(2); + topMovers[0].ProductId.Should().Be(1); + topMovers[0].MovementCount.Should().Be(3); + topMovers[0].CategoryName.Should().Be("CatA"); + topMovers[1].ProductId.Should().Be(2); + topMovers[1].MovementCount.Should().Be(1, "the movement 40 days ago is outside the 30-day window"); + } + + [Fact] + public async Task GetTopMoversAsync_RespectsCountLimit() + { + var factory = CreateFactory(nameof(GetTopMoversAsync_RespectsCountLimit)); + var recent = DateTime.UtcNow.AddDays(-1); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.AddRange( + MakeProduct(1, 1, 1, 10, 1, 1), + MakeProduct(2, 1, 1, 10, 1, 1), + MakeProduct(3, 1, 1, 10, 1, 1)); + db.StockMovements.AddRange( + MakeMovement(1, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(2, 1, 1, MovementType.ManualAdd, recent), + MakeMovement(3, 1, 1, MovementType.ManualAdd, recent)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var topMovers = await sut.GetTopMoversAsync(count: 2); + + topMovers.Should().HaveCount(2); + } + + // ---- GetCategoryValuesAsync ------------------------------------------------- + + [Fact] + public async Task GetCategoryValuesAsync_GroupsByCategoryAndSortsDescendingByValue() + { + // Note: see the comment on GetTopMoversAsync_OrdersByMovementCountDescendingAndRespectsWindow + // regarding why the "Ohne Kategorie" fallback (missing category) branch cannot be exercised + // through the InMemory provider: Product.CategoryId is a required FK and Include() silently + // drops rows whose FK doesn't resolve, which does not reflect real (FK-enforced) production data. + var factory = CreateFactory(nameof(GetCategoryValuesAsync_GroupsByCategoryAndSortsDescendingByValue)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.AddRange(MakeCategory(1, 1, "CatA"), MakeCategory(2, 1, "CatB")); + db.Products.AddRange( + MakeProduct(1, 1, 1, quantity: 2, minQuantity: 1, price: 10), + MakeProduct(2, 1, 1, quantity: 3, minQuantity: 1, price: 10), + MakeProduct(3, 1, 2, quantity: 1, minQuantity: 1, price: 100)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var categories = await sut.GetCategoryValuesAsync(); + + categories.Should().HaveCount(2); + var catA = categories.Single(c => c.CategoryName == "CatA"); + catA.ProductCount.Should().Be(2); + catA.TotalQuantity.Should().Be(5); + catA.TotalValue.Should().Be(2 * 10 + 3 * 10); + + var catB = categories.Single(c => c.CategoryName == "CatB"); + catB.TotalValue.Should().Be(100); + + // Sorted descending by value: CatB (100) before CatA (50). + categories[0].CategoryName.Should().Be("CatB"); + } + + [Fact] + public async Task GetCategoryValuesAsync_ContextFactoryThrows_ReturnsEmptyList() + { + var sut = Build(new ThrowingContextFactory()); + + var categories = await sut.GetCategoryValuesAsync(); + + categories.Should().BeEmpty(); + } + + // ---- GetWarehouseDistributionAsync ------------------------------------------- + + [Fact] + public async Task GetWarehouseDistributionAsync_ComputesPerWarehouseCountsAndFifoValue() + { + var factory = CreateFactory(nameof(GetWarehouseDistributionAsync_ComputesPerWarehouseCountsAndFifoValue)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1, "Main")); + db.Categories.Add(MakeCategory(1, 1)); + db.Products.AddRange( + MakeProduct(1, 1, 1, quantity: 2, minQuantity: 1, price: 10), + MakeProduct(2, 1, 1, quantity: 1, minQuantity: 1, price: 5)); + db.StorageLocations.AddRange(MakeLocation(1, 1), MakeLocation(2, 1)); + db.ProductStorageLocations.AddRange( + MakeLink(1, 1, 2), + MakeLink(2, 2, 1)); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var distribution = await sut.GetWarehouseDistributionAsync(); + + distribution.Should().ContainSingle(); + var wh = distribution[0]; + wh.WarehouseName.Should().Be("Main"); + wh.StorageLocationCount.Should().Be(2); + wh.ProductCount.Should().Be(2); + wh.TotalValue.Should().Be(2 * 10 + 1 * 5); + } + + [Fact] + public async Task GetWarehouseDistributionAsync_ContextFactoryThrows_ReturnsEmptyList() + { + var sut = Build(new ThrowingContextFactory()); + + var distribution = await sut.GetWarehouseDistributionAsync(); + + distribution.Should().BeEmpty(); + } + + // ---- FIFO chronological ordering (CalculateProductFIFOValueAsync internals) --- + + [Fact] + public async Task GetCategoryValuesAsync_FifoValue_UsesChronologicalPriceHistory() + { + var factory = CreateFactory(nameof(GetCategoryValuesAsync_FifoValue_UsesChronologicalPriceHistory)); + var now = DateTime.UtcNow; + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(MakeWarehouse(1)); + db.Categories.Add(MakeCategory(1, 1)); + // Quantity=15: fully consumed by two ScanAdd batches (2 + 10) plus 3 units with no matching + // stock-in, which must fall back to the oldest ProductPrice entry. + db.Products.Add(MakeProduct(1, 1, 1, quantity: 15, minQuantity: 1, price: 999)); + db.ProductPrices.AddRange( + MakePrice(1, 1, price: 10, validFrom: now.AddDays(-30)), + MakePrice(1, 1, price: 12, validFrom: now.AddDays(-5))); + db.StockMovements.AddRange( + // Before any ProductPrice.ValidFrom -> GetPriceAtTimestampAsync falls back to the initial price (10). + MakeMovement(1, 1, 2, MovementType.ScanAdd, now.AddDays(-40)), + // Only the -30d price entry qualifies here (10). + MakeMovement(1, 1, 10, MovementType.ScanAdd, now.AddDays(-20)), + // Both entries qualify; the most recent one (-5d, price 12) wins. + MakeMovement(1, 1, 3, MovementType.ScanAdd, now.AddDays(-3)), + // Not a ScanAdd -> ignored by the FIFO calculation entirely. + MakeMovement(1, 1, 1000, MovementType.ManualAdd, now.AddDays(-1))); + await db.SaveChangesAsync(); + } + + var sut = Build(factory); + var categories = await sut.GetCategoryValuesAsync(); + + // 2*10 (fallback) + 10*10 (exact match) + 3*12 (most recent match) = 156 + categories.Single().TotalValue.Should().Be(156); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceApiSessionTests.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceApiSessionTests.cs new file mode 100644 index 0000000..6703fd4 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceApiSessionTests.cs @@ -0,0 +1,233 @@ +using LagersystemLVHome.Data; +using Microsoft.EntityFrameworkCore; +using static LagersystemLVHome.UnitTests.Services.Session.SessionManagementServiceTestSupport; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Covers API-key session management: +/// and . +/// +public class SessionManagementServiceApiSessionTests +{ + /// + /// Factory whose context creation always fails - used to exercise the + /// try/catch around the DB access in IncrementApiRequestCountAsync. + /// + private sealed class ThrowingContextFactory : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => throw new InvalidOperationException("Simulated DB failure"); + } + + [Fact] + public async Task IncrementApiRequestCountAsync_DbFailure_IsCaughtAndDoesNotThrow() + { + var sut = BuildService(new ThrowingContextFactory()); + + var act = async () => await sut.IncrementApiRequestCountAsync("api-123"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_UnknownUser_ReturnsNull() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_UnknownUser_ReturnsNull)); + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(999, warehouseId: 1, "1.2.3.4", "my-key"); + + session.Should().BeNull(); + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_NoExistingSession_CreatesNewApiSession() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_NoExistingSession_CreatesNewApiSession)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(1, warehouseId: 1, "5.5.5.5", "integration-key", "/api/products"); + + session.Should().NotBeNull(); + session!.SessionId.Should().StartWith("api-"); + session.DeviceType.Should().Be("API"); + session.Browser.Should().Be("integration-key"); + session.ApiRequestsCount.Should().Be(1); + session.PageViewsCount.Should().Be(0); + session.RiskLevel.Should().Be(SessionRiskLevel.Low); + session.LastPageUrl.Should().Be("/api/products"); + session.IsActive.Should().BeTrue(); + + (await factory.CreateDbContext().UserSessions.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_NoRequestPath_DefaultsLastPageUrlToApiRoot() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_NoRequestPath_DefaultsLastPageUrlToApiRoot)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(1, warehouseId: 1, "5.5.5.5", "integration-key"); + + session!.LastPageUrl.Should().Be("/api"); + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_ExistingActiveApiSession_UpdatesActivityAndIncrementsCount() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_ExistingActiveApiSession_UpdatesActivityAndIncrementsCount)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + db.UserSessions.Add(new Domain.Models.UserSession + { + SessionId = "api-existing", + UserId = 1, + Username = "u1", + WarehouseId = 1, + IsActive = true, + DeviceType = "API", + Browser = "integration-key", + IpAddress = "1.1.1.1", + ApiRequestsCount = 5 + }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(1, warehouseId: 1, "9.9.9.9", "integration-key", "/api/orders"); + + session.Should().NotBeNull(); + session!.SessionId.Should().Be("api-existing"); + session.ApiRequestsCount.Should().Be(6); + session.IpAddress.Should().Be("9.9.9.9"); + session.LastPageUrl.Should().Be("/api/orders"); + + (await factory.CreateDbContext().UserSessions.CountAsync()).Should().Be(1); // no duplicate created + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_ExistingSession_NoRequestPath_KeepsLastPageUrl() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_ExistingSession_NoRequestPath_KeepsLastPageUrl)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + db.UserSessions.Add(new Domain.Models.UserSession + { + SessionId = "api-existing", + UserId = 1, + Username = "u1", + WarehouseId = 1, + IsActive = true, + DeviceType = "API", + Browser = "integration-key", + LastPageUrl = "/api/previous" + }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(1, warehouseId: 1, "9.9.9.9", "integration-key"); + + session!.LastPageUrl.Should().Be("/api/previous"); + } + + [Fact] + public async Task GetOrCreateApiSessionAsync_DifferentApiKeyName_CreatesSeparateSession() + { + var factory = CreateFactory(nameof(GetOrCreateApiSessionAsync_DifferentApiKeyName_CreatesSeparateSession)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + db.UserSessions.Add(new Domain.Models.UserSession + { + SessionId = "api-existing", + UserId = 1, + Username = "u1", + WarehouseId = 1, + IsActive = true, + DeviceType = "API", + Browser = "key-a" + }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetOrCreateApiSessionAsync(1, warehouseId: 1, "9.9.9.9", "key-b"); + + session!.SessionId.Should().NotBe("api-existing"); + (await factory.CreateDbContext().UserSessions.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task IncrementApiRequestCountAsync_EmptySessionId_IsNoOp() + { + var factory = CreateFactory(nameof(IncrementApiRequestCountAsync_EmptySessionId_IsNoOp)); + var sut = BuildService(factory); + + await sut.IncrementApiRequestCountAsync(""); // should not throw + } + + [Fact] + public async Task IncrementApiRequestCountAsync_NonApiSessionId_IsNoOp() + { + var factory = CreateFactory(nameof(IncrementApiRequestCountAsync_NonApiSessionId_IsNoOp)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("browser-session")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.IncrementApiRequestCountAsync("browser-session"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.ApiRequestsCount.Should().Be(0); // untouched - id doesn't start with "api-" + } + + [Fact] + public async Task IncrementApiRequestCountAsync_UnknownApiSessionId_IsNoOp() + { + var factory = CreateFactory(nameof(IncrementApiRequestCountAsync_UnknownApiSessionId_IsNoOp)); + var sut = BuildService(factory); + + await sut.IncrementApiRequestCountAsync("api-missing"); // should not throw + } + + [Fact] + public async Task IncrementApiRequestCountAsync_KnownActiveApiSession_IncrementsCount() + { + var factory = CreateFactory(nameof(IncrementApiRequestCountAsync_KnownActiveApiSession_IncrementsCount)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(new Domain.Models.UserSession + { + SessionId = "api-123", + UserId = 1, + Username = "u1", + WarehouseId = 1, + IsActive = true, + DeviceType = "API", + ApiRequestsCount = 2 + }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.IncrementApiRequestCountAsync("api-123"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.ApiRequestsCount.Should().Be(3); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceCreateTests.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceCreateTests.cs new file mode 100644 index 0000000..331c242 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceCreateTests.cs @@ -0,0 +1,332 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using static LagersystemLVHome.UnitTests.Services.Session.SessionManagementServiceTestSupport; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Covers : the client-IP +/// fallback chain, user-agent parsing (device/browser/OS), VPN/risk assignment and +/// concurrent-session bookkeeping. +/// +public class SessionManagementServiceCreateTests +{ + [Fact] + public async Task CreateSessionAsync_UnknownUser_ThrowsArgumentException() + { + var factory = CreateFactory(nameof(CreateSessionAsync_UnknownUser_ThrowsArgumentException)); + var sut = BuildService(factory); + + var act = () => sut.CreateSessionAsync(userId: 999, warehouseId: 1, "8.8.8.8", "Mozilla/5.0"); + + await act.Should().ThrowAsync().WithMessage("User not found"); + } + + [Fact] + public async Task CreateSessionAsync_WithExplicitPublicIp_UsesItDirectly() + { + var factory = CreateFactory(nameof(CreateSessionAsync_WithExplicitPublicIp_UsesItDirectly)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, warehouseId: 1, "8.8.8.8", "Mozilla/5.0 (Windows NT 10.0)"); + + session.IpAddress.Should().Be("8.8.8.8"); + session.UserId.Should().Be(1); + session.Username.Should().Be("u1"); + session.SessionId.Should().NotBeNullOrEmpty(); + session.IsActive.Should().BeTrue(); + session.Country.Should().Be("Unknown"); // public IP, no external geo API configured + session.IsConcurrent.Should().BeFalse(); + session.ConcurrentSessionCount.Should().Be(1); + } + + [Fact] + public async Task CreateSessionAsync_LoopbackIp_FallsBackToXForwardedForHeader() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LoopbackIp_FallsBackToXForwardedForHeader)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["X-Forwarded-For"] = "203.0.113.5, 10.0.0.1"; + var sut = BuildService(factory, AccessorFor(ctx)); + + var session = await sut.CreateSessionAsync(1, 1, "127.0.0.1", "Mozilla/5.0"); + + session.IpAddress.Should().Be("203.0.113.5"); + } + + [Fact] + public async Task CreateSessionAsync_LoopbackIp_NoXff_FallsBackToXRealIpHeader() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LoopbackIp_NoXff_FallsBackToXRealIpHeader)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["X-Real-IP"] = "198.51.100.7"; + var sut = BuildService(factory, AccessorFor(ctx)); + + var session = await sut.CreateSessionAsync(1, 1, "::1", "Mozilla/5.0"); + + session.IpAddress.Should().Be("198.51.100.7"); + } + + [Fact] + public async Task CreateSessionAsync_LoopbackIp_NoXffNoXri_FallsBackToXOriginalForHeader() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LoopbackIp_NoXffNoXri_FallsBackToXOriginalForHeader)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["X-Original-For"] = "192.0.2.9"; + var sut = BuildService(factory, AccessorFor(ctx)); + + var session = await sut.CreateSessionAsync(1, 1, "127.0.0.1", "Mozilla/5.0"); + + session.IpAddress.Should().Be("192.0.2.9"); + } + + [Fact] + public async Task CreateSessionAsync_LoopbackIp_NoHeaders_FallsBackToRemoteIpAddress() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LoopbackIp_NoHeaders_FallsBackToRemoteIpAddress)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("172.16.5.9"); + var sut = BuildService(factory, AccessorFor(ctx)); + + var session = await sut.CreateSessionAsync(1, 1, "127.0.0.1", "Mozilla/5.0"); + + session.IpAddress.Should().Be("172.16.5.9"); + } + + [Fact] + public async Task CreateSessionAsync_LoopbackIp_NothingAvailable_FallsBackToUnknown() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LoopbackIp_NothingAvailable_FallsBackToUnknown)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + // No HttpContext at all -> every fallback returns null. + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, 1, "", "Mozilla/5.0"); + + session.IpAddress.Should().Be("Unknown"); + } + + [Theory] + [InlineData("Mozilla/5.0 (Linux; Android 13)", "Mobile")] + [InlineData("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "Mobile")] + [InlineData("Mozilla/5.0 (iPad; CPU OS 17_0)", "Tablet")] + [InlineData("Mozilla/5.0 (Linux; Tablet)", "Tablet")] + [InlineData("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Desktop")] + public async Task CreateSessionAsync_DetectsDeviceTypeFromUserAgent(string userAgent, string expectedDeviceType) + { + var factory = CreateFactory(nameof(CreateSessionAsync_DetectsDeviceTypeFromUserAgent) + expectedDeviceType + userAgent.GetHashCode()); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, 1, "8.8.8.8", userAgent); + + session.DeviceType.Should().Be(expectedDeviceType); + session.DeviceInfo.Should().Be(expectedDeviceType); + } + + [Theory] + [InlineData("Mozilla/5.0 Edg/120.0", "Edge")] + [InlineData("Mozilla/5.0 Chrome/120.0", "Chrome")] + [InlineData("Mozilla/5.0 Firefox/120.0", "Firefox")] + [InlineData("Mozilla/5.0 Safari/605.1", "Safari")] + [InlineData("Mozilla/5.0 (Windows NT 10.0) Opera/89.0", "Opera")] + [InlineData("SomeCustomBot/1.0", "Unknown")] + public async Task CreateSessionAsync_DetectsBrowserFromUserAgent(string userAgent, string expectedBrowser) + { + var factory = CreateFactory(nameof(CreateSessionAsync_DetectsBrowserFromUserAgent) + expectedBrowser); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, 1, "8.8.8.8", userAgent); + + session.Browser.Should().Be(expectedBrowser); + } + + [Theory] + [InlineData("Mozilla/5.0 (Windows NT 10.0)", "Windows")] + [InlineData("Mozilla/5.0 (Macintosh; Intel Mac OS X)", "macOS")] + [InlineData("Mozilla/5.0 (X11; Linux x86_64)", "Linux")] + [InlineData("Mozilla/5.0 (Android 13; Mobile)", "Android")] + [InlineData("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iOS")] + [InlineData("SomeCustomBot/1.0", "Unknown")] + public async Task CreateSessionAsync_DetectsOperatingSystemFromUserAgent(string userAgent, string expectedOs) + { + var factory = CreateFactory(nameof(CreateSessionAsync_DetectsOperatingSystemFromUserAgent) + expectedOs); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, 1, "8.8.8.8", userAgent); + + session.OperatingSystem.Should().Be(expectedOs); + } + + [Fact] + public async Task CreateSessionAsync_PrivateIpMatchingVpnSubnet_MarksVpnAndLogsSecurityEvent() + { + var factory = CreateFactory(nameof(CreateSessionAsync_PrivateIpMatchingVpnSubnet_MarksVpnAndLogsSecurityEvent)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var vpnConfig = new VpnDetectionConfig { VpnSubnets = ["192.168.3.*"], SubnetMatchConfidence = 95 }; + var sut = BuildService(factory, vpnConfig: vpnConfig); + + var session = await sut.CreateSessionAsync(1, 1, "192.168.3.45", "Mozilla/5.0"); + + session.IsVpn.Should().BeTrue(); + session.VpnConfidenceScore.Should().Be(95); + session.Country.Should().Be("Local Network"); + session.RiskFactors.Should().Contain("IP matches configured VPN subnet"); + + await using var db2 = factory.CreateDbContext(); + var securityEvent = await db2.SecurityEvents.SingleAsync(); + securityEvent.EventType.Should().Be("VPN_DETECTED"); + securityEvent.IsVpn.Should().BeTrue(); + securityEvent.SessionId.Should().Be(session.Id); + } + + [Fact] + public async Task CreateSessionAsync_VpnConfidenceInSeventiesRange_AddsMidTierRiskScore() + { + // Exercises the 70-89 confidence-score tier of CalculateRiskLevel (distinct from the + // >=90 tier already covered above). Final level is still Low since the max score + // achievable through the config-based VPN detector never reaches the Medium/High + // thresholds (see final report for details). + var factory = CreateFactory(nameof(CreateSessionAsync_VpnConfidenceInSeventiesRange_AddsMidTierRiskScore)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var vpnConfig = new VpnDetectionConfig { VpnSubnets = ["192.168.3.*"], SubnetMatchConfidence = 75 }; + var sut = BuildService(factory, vpnConfig: vpnConfig); + + var session = await sut.CreateSessionAsync(1, 1, "192.168.3.45", "Mozilla/5.0"); + + session.IsVpn.Should().BeTrue(); + session.VpnConfidenceScore.Should().Be(75); + session.RiskLevel.Should().Be(SessionRiskLevel.Low); + } + + [Fact] + public async Task CreateSessionAsync_VpnConfidenceInFiftiesRange_AddsLowTierRiskScore() + { + // Exercises the 50-69 confidence-score tier of CalculateRiskLevel. + var factory = CreateFactory(nameof(CreateSessionAsync_VpnConfidenceInFiftiesRange_AddsLowTierRiskScore)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var vpnConfig = new VpnDetectionConfig { VpnSubnets = ["192.168.3.*"], SubnetMatchConfidence = 55 }; + var sut = BuildService(factory, vpnConfig: vpnConfig); + + var session = await sut.CreateSessionAsync(1, 1, "192.168.3.45", "Mozilla/5.0"); + + session.IsVpn.Should().BeTrue(); + session.VpnConfidenceScore.Should().Be(55); + session.RiskLevel.Should().Be(SessionRiskLevel.Low); + } + + [Fact] + public async Task CreateSessionAsync_PrivateIpNotMatchingVpnSubnet_DoesNotLogSecurityEvent() + { + var factory = CreateFactory(nameof(CreateSessionAsync_PrivateIpNotMatchingVpnSubnet_DoesNotLogSecurityEvent)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory, vpnConfig: new VpnDetectionConfig { VpnSubnets = ["10.99.0.*"] }); + + var session = await sut.CreateSessionAsync(1, 1, "192.168.3.45", "Mozilla/5.0"); + + session.IsVpn.Should().BeFalse(); + session.Country.Should().Be("Local Network"); + + await using var db2 = factory.CreateDbContext(); + (await db2.SecurityEvents.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task CreateSessionAsync_ExistingActiveSessionForUser_MarksNewSessionAsConcurrent() + { + var factory = CreateFactory(nameof(CreateSessionAsync_ExistingActiveSessionForUser_MarksNewSessionAsConcurrent)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + db.UserSessions.Add(MakeSession("existing", userId: 1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.CreateSessionAsync(1, 1, "8.8.8.8", "Mozilla/5.0"); + + session.IsConcurrent.Should().BeTrue(); + session.ConcurrentSessionCount.Should().Be(2); + } + + [Fact] + public async Task CreateSessionAsync_LocalhostIp_SetsCountryLocalhost() + { + var factory = CreateFactory(nameof(CreateSessionAsync_LocalhostIp_SetsCountryLocalhost)); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(MakeUser(1)); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("127.0.0.1"); + var sut = BuildService(factory, AccessorFor(ctx)); + + // Explicit "127.0.0.1" param IP bypasses the fallback chain entirely + // and is passed straight into VPN/geo detection. + var session = await sut.CreateSessionAsync(1, 1, "127.0.0.1", "Mozilla/5.0"); + + session.Country.Should().Be("Localhost"); + session.IsVpn.Should().BeFalse(); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceLifecycleTests.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceLifecycleTests.cs new file mode 100644 index 0000000..e22009d --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceLifecycleTests.cs @@ -0,0 +1,492 @@ +using LagersystemLVHome.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static LagersystemLVHome.UnitTests.Services.Session.SessionManagementServiceTestSupport; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Covers session lifecycle mutations: activity/fingerprint updates, ending +/// sessions, forced logouts, concurrent-login checks and expired-session cleanup. +/// +public class SessionManagementServiceLifecycleTests +{ + [Fact] + public async Task UpdateSessionActivityAsync_UnknownSessionId_IsNoOp() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_UnknownSessionId_IsNoOp)); + var sut = BuildService(factory); + + await sut.UpdateSessionActivityAsync("missing"); // should not throw + + (await factory.CreateDbContext().SessionActivities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task UpdateSessionActivityAsync_NoHttpContext_IncrementsPageViewsAndRecordsActivity() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_NoHttpContext_IncrementsPageViewsAndRecordsActivity)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.UpdateSessionActivityAsync("s1", pageUrl: "/inventory"); + + await using var verify = factory.CreateDbContext(); + var session = await verify.UserSessions.SingleAsync(); + session.PageViewsCount.Should().Be(1); + session.LastPageUrl.Should().Be("/inventory"); + var activity = await verify.SessionActivities.SingleAsync(); + activity.ActivityType.Should().Be("PageView"); + activity.PageUrl.Should().Be("/inventory"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_IpChanged_UpdatesIpAddressAndLogs() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_IpChanged_UpdatesIpAddressAndLogs)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("2.2.2.2"); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("2.2.2.2"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_UsesXForwardedForHeaderWhenPresent() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_UsesXForwardedForHeaderWhenPresent)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("5.5.5.5"); // would apply only without XFF + ctx.Request.Headers["X-Forwarded-For"] = "203.0.113.9, 10.0.0.1"; + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("203.0.113.9"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_IpUnchanged_DoesNotTriggerGeoLookup() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_IpUnchanged_DoesNotTriggerGeoLookup)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "2.2.2.2", country: "OriginalCountry")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("2.2.2.2"); + var services = new ServiceCollection(); + var geo = Substitute.For(); + geo.IsAvailable.Returns(true); + services.AddSingleton(geo); + ctx.RequestServices = services.BuildServiceProvider(); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.Country.Should().Be("OriginalCountry"); // unchanged - IP didn't change + } + + [Fact] + public async Task UpdateSessionActivityAsync_LoopbackOrUnknownCurrentIp_DoesNotUpdateIp() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_LoopbackOrUnknownCurrentIp_DoesNotUpdateIp)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "9.9.9.9")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("::1"); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("9.9.9.9"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_GeoServiceAvailable_UpdatesGeoAndRiskLevel() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_GeoServiceAvailable_UpdatesGeoAndRiskLevel)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1", country: "OldCountry")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("8.8.4.4"); // public IP -> DetectVpnAsync returns Country="Unknown" + var services = new ServiceCollection(); + var geo = Substitute.For(); + geo.IsAvailable.Returns(true); + services.AddSingleton(geo); + ctx.RequestServices = services.BuildServiceProvider(); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("8.8.4.4"); + session.Country.Should().Be("Unknown"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_GeoServiceUnavailable_SkipsGeoUpdate() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_GeoServiceUnavailable_SkipsGeoUpdate)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1", country: "OldCountry")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("8.8.4.4"); + var services = new ServiceCollection(); + var geo = Substitute.For(); + geo.IsAvailable.Returns(false); + services.AddSingleton(geo); + ctx.RequestServices = services.BuildServiceProvider(); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("8.8.4.4"); // IP still updates + session.Country.Should().Be("OldCountry"); // but geo lookup is skipped + } + + [Fact] + public async Task UpdateSessionActivityAsync_GeoServiceThrows_IsCaughtAndDoesNotFailUpdate() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_GeoServiceThrows_IsCaughtAndDoesNotFailUpdate)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("8.8.4.4"); + var services = new ServiceCollection(); + var geo = Substitute.For(); + geo.IsAvailable.Returns(_ => throw new InvalidOperationException("boom")); + services.AddSingleton(geo); + ctx.RequestServices = services.BuildServiceProvider(); + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); // must not throw + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IpAddress.Should().Be("8.8.4.4"); // IP change itself already happened before the throw + } + + [Fact] + public async Task UpdateSessionActivityAsync_UserAgentChangedAndLongEnough_UpdatesUserAgent() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_UserAgentChangedAndLongEnough_UpdatesUserAgent)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", userAgent: "OldAgent/1.0")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["User-Agent"] = "Mozilla/5.0 (New Agent)"; + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.UserAgent.Should().Be("Mozilla/5.0 (New Agent)"); + } + + [Fact] + public async Task UpdateSessionActivityAsync_UserAgentTooShort_IsNotUpdated() + { + var factory = CreateFactory(nameof(UpdateSessionActivityAsync_UserAgentTooShort_IsNotUpdated)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", userAgent: "OriginalAgent/1.0")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["User-Agent"] = "short"; // length <= 10 + var sut = BuildService(factory, AccessorFor(ctx)); + + await sut.UpdateSessionActivityAsync("s1"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.UserAgent.Should().Be("OriginalAgent/1.0"); + } + + [Fact] + public async Task UpdateSessionFingerprintAsync_EmptySessionId_ReturnsFalse() + { + var factory = CreateFactory(nameof(UpdateSessionFingerprintAsync_EmptySessionId_ReturnsFalse)); + var sut = BuildService(factory); + + (await sut.UpdateSessionFingerprintAsync("", "fp")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateSessionFingerprintAsync_EmptyFingerprint_ReturnsFalse() + { + var factory = CreateFactory(nameof(UpdateSessionFingerprintAsync_EmptyFingerprint_ReturnsFalse)); + var sut = BuildService(factory); + + (await sut.UpdateSessionFingerprintAsync("s1", " ")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateSessionFingerprintAsync_UnknownSession_ReturnsFalse() + { + var factory = CreateFactory(nameof(UpdateSessionFingerprintAsync_UnknownSession_ReturnsFalse)); + var sut = BuildService(factory); + + (await sut.UpdateSessionFingerprintAsync("missing", "fp")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateSessionFingerprintAsync_KnownActiveSession_UpdatesFingerprintReturnsTrue() + { + var factory = CreateFactory(nameof(UpdateSessionFingerprintAsync_KnownActiveSession_UpdatesFingerprintReturnsTrue)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", deviceFingerprint: null)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var result = await sut.UpdateSessionFingerprintAsync("s1", "new-fp"); + + result.Should().BeTrue(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.DeviceFingerprint.Should().Be("new-fp"); + } + + [Fact] + public async Task EndSessionAsync_UnknownSession_IsNoOp() + { + var factory = CreateFactory(nameof(EndSessionAsync_UnknownSession_IsNoOp)); + var sut = BuildService(factory); + + await sut.EndSessionAsync("missing", SessionEndReason.UserLogout); // should not throw + } + + [Fact] + public async Task EndSessionAsync_KnownSession_SetsTerminationFields() + { + var factory = CreateFactory(nameof(EndSessionAsync_KnownSession_SetsTerminationFields)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.EndSessionAsync("s1", SessionEndReason.UserLogout, details: "manual logout"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsActive.Should().BeFalse(); + session.EndReason.Should().Be(SessionEndReason.UserLogout); + session.EndReasonDetails.Should().Be("manual logout"); + session.EndTime.Should().NotBeNull(); + session.WasForcedLogout.Should().BeFalse(); + session.TerminatedByUserId.Should().BeNull(); + } + + [Fact] + public async Task EndSessionAsync_WithTerminatedByUserId_MarksAsForcedLogout() + { + var factory = CreateFactory(nameof(EndSessionAsync_WithTerminatedByUserId_MarksAsForcedLogout)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.EndSessionAsync("s1", SessionEndReason.AdminForceLogout, terminatedByUserId: 42); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.WasForcedLogout.Should().BeTrue(); + session.TerminatedByUserId.Should().Be(42); + } + + [Fact] + public async Task ForceLogoutAsync_UnknownSession_IsNoOp() + { + var factory = CreateFactory(nameof(ForceLogoutAsync_UnknownSession_IsNoOp)); + var sut = BuildService(factory); + + await sut.ForceLogoutAsync("missing", adminUserId: 1, reason: "test"); + + (await factory.CreateDbContext().SecurityEvents.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task ForceLogoutAsync_KnownSession_EndsSessionAndLogsSecurityEvent() + { + var factory = CreateFactory(nameof(ForceLogoutAsync_KnownSession_EndsSessionAndLogsSecurityEvent)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.ForceLogoutAsync("s1", adminUserId: 7, reason: "policy violation"); + + await using var verify = factory.CreateDbContext(); + var session = await verify.UserSessions.SingleAsync(); + session.IsActive.Should().BeFalse(); + session.EndReason.Should().Be(SessionEndReason.AdminForceLogout); + session.WasForcedLogout.Should().BeTrue(); + session.TerminatedByUserId.Should().Be(7); + + var securityEvent = await verify.SecurityEvents.SingleAsync(); + securityEvent.EventType.Should().Be("ADMIN_FORCE_LOGOUT"); + securityEvent.UserId.Should().Be(session.UserId); + } + + [Fact] + public async Task ForceLogoutUserAsync_MultipleActiveSessions_LogsOutAllOfThem() + { + var factory = CreateFactory(nameof(ForceLogoutUserAsync_MultipleActiveSessions_LogsOutAllOfThem)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("s1", userId: 1), + MakeSession("s2", userId: 1), + MakeSession("other-user", userId: 2)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.ForceLogoutUserAsync(userId: 1, adminUserId: 9, reason: "bulk logout"); + + await using var verify = factory.CreateDbContext(); + (await verify.UserSessions.Where(s => s.UserId == 1).AllAsync(s => !s.IsActive)).Should().BeTrue(); + (await verify.UserSessions.SingleAsync(s => s.SessionId == "other-user")).IsActive.Should().BeTrue(); + } + + [Fact] + public async Task ForceLogoutUserAsync_NoActiveSessions_IsNoOp() + { + var factory = CreateFactory(nameof(ForceLogoutUserAsync_NoActiveSessions_IsNoOp)); + var sut = BuildService(factory); + + await sut.ForceLogoutUserAsync(userId: 1, adminUserId: 9, reason: "n/a"); // should not throw + } + + [Fact] + public async Task CheckConcurrentLoginAsync_NoOtherActiveSessions_ReturnsFalse() + { + var factory = CreateFactory(nameof(CheckConcurrentLoginAsync_NoOtherActiveSessions_ReturnsFalse)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", userId: 1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + (await sut.CheckConcurrentLoginAsync(1, "s1")).Should().BeFalse(); + } + + [Fact] + public async Task CheckConcurrentLoginAsync_OtherActiveSessionsExist_ReturnsTrue() + { + var factory = CreateFactory(nameof(CheckConcurrentLoginAsync_OtherActiveSessionsExist_ReturnsTrue)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange(MakeSession("s1", userId: 1), MakeSession("s2", userId: 1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + (await sut.CheckConcurrentLoginAsync(1, "s2")).Should().BeTrue(); + } + + [Fact] + public async Task TerminatePreviousSessionsAsync_EndsOtherActiveSessionsForUser_KeepsCurrentActive() + { + var factory = CreateFactory(nameof(TerminatePreviousSessionsAsync_EndsOtherActiveSessionsForUser_KeepsCurrentActive)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("old1", userId: 1), + MakeSession("old2", userId: 1), + MakeSession("current", userId: 1)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.TerminatePreviousSessionsAsync(1, "current"); + + await using var verify = factory.CreateDbContext(); + (await verify.UserSessions.SingleAsync(s => s.SessionId == "current")).IsActive.Should().BeTrue(); + (await verify.UserSessions.SingleAsync(s => s.SessionId == "old1")).EndReason.Should().Be(SessionEndReason.ConcurrentLogin); + (await verify.UserSessions.SingleAsync(s => s.SessionId == "old2")).IsActive.Should().BeFalse(); + } + + [Fact] + public async Task CleanupExpiredSessionsAsync_NoExpiredSessions_IsNoOp() + { + var factory = CreateFactory(nameof(CleanupExpiredSessionsAsync_NoExpiredSessions_IsNoOp)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.CleanupExpiredSessionsAsync(); + + (await factory.CreateDbContext().UserSessions.SingleAsync()).IsActive.Should().BeTrue(); + } + + [Fact] + public async Task CleanupExpiredSessionsAsync_StaleActiveSessions_AreEndedWithTimeoutReason() + { + var factory = CreateFactory(nameof(CleanupExpiredSessionsAsync_StaleActiveSessions_AreEndedWithTimeoutReason)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("expired", lastActivity: DateTime.UtcNow.AddMinutes(-45)), + MakeSession("fresh", lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.CleanupExpiredSessionsAsync(); + + await using var verify = factory.CreateDbContext(); + var expired = await verify.UserSessions.SingleAsync(s => s.SessionId == "expired"); + expired.IsActive.Should().BeFalse(); + expired.EndReason.Should().Be(SessionEndReason.Timeout); + expired.EndTime.Should().NotBeNull(); + + var fresh = await verify.UserSessions.SingleAsync(s => s.SessionId == "fresh"); + fresh.IsActive.Should().BeTrue(); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceQueryTests.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceQueryTests.cs new file mode 100644 index 0000000..9387b88 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceQueryTests.cs @@ -0,0 +1,474 @@ +using LagersystemLVHome.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using static LagersystemLVHome.UnitTests.Services.Session.SessionManagementServiceTestSupport; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Covers the read-only query surface of : +/// GetSessionAsync, both GetActiveSessionsAsync overloads, GetUserSessionsAsync, +/// GetSessionByUserAndFingerprintAsync, GetSuspiciousSessionsAsync, +/// GetSecurityEventsAsync and GetSessionStatisticsAsync. +/// +public class SessionManagementServiceQueryTests +{ + [Fact] + public async Task GetSessionAsync_UnknownSessionId_ReturnsNull() + { + var factory = CreateFactory(nameof(GetSessionAsync_UnknownSessionId_ReturnsNull)); + var sut = BuildService(factory); + + (await sut.GetSessionAsync("missing")).Should().BeNull(); + } + + [Fact] + public async Task GetSessionAsync_KnownSessionId_ReturnsSessionWithIncludes() + { + var factory = CreateFactory(nameof(GetSessionAsync_KnownSessionId_ReturnsSessionWithIncludes)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + db.SessionActivities.Add(new SessionActivity { SessionId = 1, ActivityType = "PageView" }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetSessionAsync("s1"); + + session.Should().NotBeNull(); + session!.User.Should().NotBeNull(); + session.Warehouse.Should().NotBeNull(); + session.Activities.Should().ContainSingle(); + } + + [Fact] + public async Task GetActiveSessionsAsync_ExcludesInactiveAndStaleSessions() + { + var factory = CreateFactory(nameof(GetActiveSessionsAsync_ExcludesInactiveAndStaleSessions)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("active-recent", isActive: true, lastActivity: DateTime.UtcNow), + MakeSession("active-stale", isActive: true, lastActivity: DateTime.UtcNow.AddMinutes(-40)), + MakeSession("inactive-recent", isActive: false, lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetActiveSessionsAsync(); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("active-recent"); + } + + [Fact] + public async Task GetActiveSessionsAsync_FiltersByWarehouseId() + { + var factory = CreateFactory(nameof(GetActiveSessionsAsync_FiltersByWarehouseId)); + await SeedWarehouseAndUserAsync(factory, userId: 1, warehouseId: 1); + await SeedWarehouseAndUserAsync(factory, userId: 2, warehouseId: 2); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("wh1", userId: 1, warehouseId: 1), + MakeSession("wh2", userId: 2, warehouseId: 2)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetActiveSessionsAsync(warehouseId: 2); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("wh2"); + } + + [Fact] + public async Task GetActiveSessionsAsync_OnlyActiveFalse_IncludesInactiveAndStaleSessions() + { + var factory = CreateFactory(nameof(GetActiveSessionsAsync_OnlyActiveFalse_IncludesInactiveAndStaleSessions)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("active-recent", isActive: true, lastActivity: DateTime.UtcNow), + MakeSession("active-stale", isActive: true, lastActivity: DateTime.UtcNow.AddMinutes(-90)), + MakeSession("inactive", isActive: false, lastActivity: DateTime.UtcNow.AddDays(-1))); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetActiveSessionsAsync(warehouseId: null, onlyActive: false); + + sessions.Should().HaveCount(3); + } + + [Fact] + public async Task GetActiveSessionsAsync_OnlyActiveTrue_BehavesLikeOtherOverload() + { + var factory = CreateFactory(nameof(GetActiveSessionsAsync_OnlyActiveTrue_BehavesLikeOtherOverload)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("active-recent", isActive: true, lastActivity: DateTime.UtcNow), + MakeSession("active-stale", isActive: true, lastActivity: DateTime.UtcNow.AddMinutes(-90))); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetActiveSessionsAsync(warehouseId: null, onlyActive: true); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("active-recent"); + } + + [Fact] + public async Task GetActiveSessionsAsync_OnlyActiveOverload_FiltersByWarehouseId() + { + var factory = CreateFactory(nameof(GetActiveSessionsAsync_OnlyActiveOverload_FiltersByWarehouseId)); + await SeedWarehouseAndUserAsync(factory, userId: 1, warehouseId: 1); + await SeedWarehouseAndUserAsync(factory, userId: 2, warehouseId: 2); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("wh1", userId: 1, warehouseId: 1), + MakeSession("wh2", userId: 2, warehouseId: 2)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetActiveSessionsAsync(warehouseId: 1, onlyActive: true); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("wh1"); + } + + [Fact] + public async Task GetUserSessionsAsync_DefaultOnlyActive_ExcludesInactiveSessions() + { + var factory = CreateFactory(nameof(GetUserSessionsAsync_DefaultOnlyActive_ExcludesInactiveSessions)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("a", userId: 1, isActive: true, startTime: DateTime.UtcNow.AddMinutes(-2)), + MakeSession("b", userId: 1, isActive: false, startTime: DateTime.UtcNow.AddMinutes(-1))); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetUserSessionsAsync(1); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("a"); + } + + [Fact] + public async Task GetUserSessionsAsync_OnlyActiveFalse_ReturnsAllOrderedByStartTimeDescending() + { + var factory = CreateFactory(nameof(GetUserSessionsAsync_OnlyActiveFalse_ReturnsAllOrderedByStartTimeDescending)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("older", userId: 1, isActive: false, startTime: DateTime.UtcNow.AddHours(-2)), + MakeSession("newer", userId: 1, isActive: true, startTime: DateTime.UtcNow.AddMinutes(-1))); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetUserSessionsAsync(1, onlyActive: false); + + sessions.Should().HaveCount(2); + sessions[0].SessionId.Should().Be("newer"); + sessions[1].SessionId.Should().Be("older"); + } + + [Fact] + public async Task GetSessionByUserAndFingerprintAsync_ExactFingerprintMatch_ReturnsIt() + { + var factory = CreateFactory(nameof(GetSessionByUserAndFingerprintAsync_ExactFingerprintMatch_ReturnsIt)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("fp-match", userId: 1, deviceFingerprint: "fp-1"), + MakeSession("other", userId: 1, deviceFingerprint: "fp-2")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetSessionByUserAndFingerprintAsync(1, "fp-1"); + + session.Should().NotBeNull(); + session!.SessionId.Should().Be("fp-match"); + } + + [Fact] + public async Task GetSessionByUserAndFingerprintAsync_NoFingerprintMatch_FallsBackToUserAgentMatch() + { + var factory = CreateFactory(nameof(GetSessionByUserAndFingerprintAsync_NoFingerprintMatch_FallsBackToUserAgentMatch)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("ua-match", userId: 1, deviceFingerprint: "old-fp", userAgent: "Chrome/120")); + await db.SaveChangesAsync(); + } + var ctx = new DefaultHttpContext(); + ctx.Request.Headers["User-Agent"] = "Chrome/120"; + var sut = BuildService(factory, AccessorFor(ctx)); + + var session = await sut.GetSessionByUserAndFingerprintAsync(1, deviceFingerprint: "unknown-fp"); + + session.Should().NotBeNull(); + session!.SessionId.Should().Be("ua-match"); + } + + [Fact] + public async Task GetSessionByUserAndFingerprintAsync_NoFingerprintNoUaMatch_FallsBackToMostRecentSession() + { + var factory = CreateFactory(nameof(GetSessionByUserAndFingerprintAsync_NoFingerprintNoUaMatch_FallsBackToMostRecentSession)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("older", userId: 1, userAgent: "Firefox/1", lastActivity: DateTime.UtcNow.AddMinutes(-10)), + MakeSession("newest", userId: 1, userAgent: "Firefox/2", lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); // no HttpContext -> no UA to match against + + var session = await sut.GetSessionByUserAndFingerprintAsync(1, deviceFingerprint: null); + + session.Should().NotBeNull(); + session!.SessionId.Should().Be("newest"); + } + + [Fact] + public async Task GetSessionByUserAndFingerprintAsync_ExcludesApiSessions() + { + var factory = CreateFactory(nameof(GetSessionByUserAndFingerprintAsync_ExcludesApiSessions)); + await using (var db = factory.CreateDbContext()) + { + var apiSession = MakeSession("api-1", userId: 1, deviceType: "API"); + db.UserSessions.Add(apiSession); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetSessionByUserAndFingerprintAsync(1, deviceFingerprint: null); + + session.Should().BeNull(); + } + + [Fact] + public async Task GetSessionByUserAndFingerprintAsync_OnlyActiveFalse_IncludesInactiveSessions() + { + var factory = CreateFactory(nameof(GetSessionByUserAndFingerprintAsync_OnlyActiveFalse_IncludesInactiveSessions)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("inactive-fp", userId: 1, isActive: false, deviceFingerprint: "fp-x")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var session = await sut.GetSessionByUserAndFingerprintAsync(1, "fp-x", onlyActive: false); + + session.Should().NotBeNull(); + session!.SessionId.Should().Be("inactive-fp"); + } + + [Fact] + public async Task GetSuspiciousSessionsAsync_FiltersByIsSuspiciousAndWarehouse() + { + var factory = CreateFactory(nameof(GetSuspiciousSessionsAsync_FiltersByIsSuspiciousAndWarehouse)); + await SeedWarehouseAndUserAsync(factory, userId: 1, warehouseId: 1); + await SeedWarehouseAndUserAsync(factory, userId: 2, warehouseId: 2); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("susp-wh1", userId: 1, warehouseId: 1, isSuspicious: true, lastSuspiciousActivity: DateTime.UtcNow), + MakeSession("not-susp", userId: 1, warehouseId: 1, isSuspicious: false), + MakeSession("susp-wh2", userId: 2, warehouseId: 2, isSuspicious: true, lastSuspiciousActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetSuspiciousSessionsAsync(warehouseId: 1); + + sessions.Should().ContainSingle().Which.SessionId.Should().Be("susp-wh1"); + } + + [Fact] + public async Task GetSuspiciousSessionsAsync_OrdersByLastSuspiciousActivityDescending() + { + var factory = CreateFactory(nameof(GetSuspiciousSessionsAsync_OrdersByLastSuspiciousActivityDescending)); + await SeedWarehouseAndUserAsync(factory); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("old", isSuspicious: true, lastSuspiciousActivity: DateTime.UtcNow.AddHours(-1)), + MakeSession("new", isSuspicious: true, lastSuspiciousActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var sessions = await sut.GetSuspiciousSessionsAsync(); + + sessions.Should().HaveCount(2); + sessions[0].SessionId.Should().Be("new"); + } + + [Fact] + public async Task GetSecurityEventsAsync_OrdersByTimestampDescendingAndRespectsCount() + { + var factory = CreateFactory(nameof(GetSecurityEventsAsync_OrdersByTimestampDescendingAndRespectsCount)); + await using (var db = factory.CreateDbContext()) + { + db.SecurityEvents.AddRange( + new SecurityEvent { EventType = "E1", Timestamp = DateTime.UtcNow.AddMinutes(-10) }, + new SecurityEvent { EventType = "E2", Timestamp = DateTime.UtcNow.AddMinutes(-5) }, + new SecurityEvent { EventType = "E3", Timestamp = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var events = await sut.GetSecurityEventsAsync(count: 2); + + events.Should().HaveCount(2); + events[0].EventType.Should().Be("E3"); + events[1].EventType.Should().Be("E2"); + } + + [Fact] + public async Task GetSecurityEventsAsync_FiltersByWarehouseIdViaSession() + { + var factory = CreateFactory(nameof(GetSecurityEventsAsync_FiltersByWarehouseIdViaSession)); + await SeedWarehouseAndUserAsync(factory, userId: 1, warehouseId: 1); + await SeedWarehouseAndUserAsync(factory, userId: 2, warehouseId: 2); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + MakeSession("s-wh1", userId: 1, warehouseId: 1), + MakeSession("s-wh2", userId: 2, warehouseId: 2)); + await db.SaveChangesAsync(); + + var s1 = await db.UserSessions.SingleAsync(s => s.SessionId == "s-wh1"); + var s2 = await db.UserSessions.SingleAsync(s => s.SessionId == "s-wh2"); + db.SecurityEvents.AddRange( + new SecurityEvent { EventType = "WH1_EVENT", SessionId = s1.Id, Timestamp = DateTime.UtcNow }, + new SecurityEvent { EventType = "WH2_EVENT", SessionId = s2.Id, Timestamp = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var events = await sut.GetSecurityEventsAsync(warehouseId: 1); + + events.Should().ContainSingle().Which.EventType.Should().Be("WH1_EVENT"); + } + + [Fact] + public async Task GetSessionStatisticsAsync_EmptyDatabase_ReturnsZeroedStatistics() + { + var factory = CreateFactory(nameof(GetSessionStatisticsAsync_EmptyDatabase_ReturnsZeroedStatistics)); + var sut = BuildService(factory); + + var stats = await sut.GetSessionStatisticsAsync(); + + stats.TotalSessions.Should().Be(0); + stats.AverageSessionDuration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public async Task GetSessionStatisticsAsync_ComputesAggregatesAcrossSessions() + { + var factory = CreateFactory(nameof(GetSessionStatisticsAsync_ComputesAggregatesAcrossSessions)); + var start = DateTime.UtcNow.AddHours(-1); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + new Domain.Models.UserSession + { + SessionId = "s1", + UserId = 1, + Username = "u1", + WarehouseId = 1, + StartTime = start, + EndTime = start.AddMinutes(10), + IsActive = false, + Country = "Germany", + DeviceType = "Desktop", + RiskLevel = SessionRiskLevel.Low, + PageViewsCount = 3, + ApiRequestsCount = 1 + }, + new Domain.Models.UserSession + { + SessionId = "s2", + UserId = 2, + Username = "u2", + WarehouseId = 1, + StartTime = start, + EndTime = start.AddMinutes(20), + IsActive = false, + Country = "Germany", + DeviceType = "Mobile", + RiskLevel = SessionRiskLevel.High, + IsSuspicious = true, + IsVpn = true, + IsConcurrent = true, + WasForcedLogout = true, + PageViewsCount = 2, + ApiRequestsCount = 0 + }, + new Domain.Models.UserSession + { + SessionId = "s3", + UserId = 3, + Username = "u3", + WarehouseId = 1, + StartTime = DateTime.UtcNow, + IsActive = true, + Country = "France", + DeviceType = "Desktop", + RiskLevel = SessionRiskLevel.Low + }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var stats = await sut.GetSessionStatisticsAsync(warehouseId: 1); + + stats.TotalSessions.Should().Be(3); + stats.ActiveSessions.Should().Be(1); + stats.SuspiciousSessions.Should().Be(1); + stats.VpnSessions.Should().Be(1); + stats.ConcurrentSessions.Should().Be(1); + stats.ForcedLogouts.Should().Be(1); + stats.AverageSessionDuration.Should().Be(TimeSpan.FromMinutes(15)); // (10+20)/2 + stats.TotalPageViews.Should().Be(5); + stats.TotalApiRequests.Should().Be(1); + stats.TopCountries.Should().ContainSingle(kvp => kvp.Key == "Germany" && kvp.Value == 2); + stats.DeviceTypes["Desktop"].Should().Be(2); + stats.DeviceTypes["Mobile"].Should().Be(1); + stats.RiskLevelDistribution[SessionRiskLevel.Low.ToString()].Should().Be(2); + stats.RiskLevelDistribution[SessionRiskLevel.High.ToString()].Should().Be(1); + } + + [Fact] + public async Task GetSessionStatisticsAsync_FiltersByFromAndTo() + { + var factory = CreateFactory(nameof(GetSessionStatisticsAsync_FiltersByFromAndTo)); + var inWindow = DateTime.UtcNow.AddDays(-1); + var outOfWindow = DateTime.UtcNow.AddDays(-10); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.AddRange( + new Domain.Models.UserSession { SessionId = "in", UserId = 1, Username = "u1", WarehouseId = 1, StartTime = inWindow }, + new Domain.Models.UserSession { SessionId = "out", UserId = 1, Username = "u1", WarehouseId = 1, StartTime = outOfWindow }); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var stats = await sut.GetSessionStatisticsAsync( + from: DateTime.UtcNow.AddDays(-2), to: DateTime.UtcNow); + + stats.TotalSessions.Should().Be(1); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceSecurityTests.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceSecurityTests.cs new file mode 100644 index 0000000..c0bab8b --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceSecurityTests.cs @@ -0,0 +1,250 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.EntityFrameworkCore; +using static LagersystemLVHome.UnitTests.Services.Session.SessionManagementServiceTestSupport; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Covers session-hijacking detection, suspicious-activity marking and the +/// configuration-based VPN detector (). +/// +public class SessionManagementServiceSecurityTests +{ + [Fact] + public async Task DetectSessionHijackingAsync_UnknownSession_ReturnsFalse() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_UnknownSession_ReturnsFalse)); + var sut = BuildService(factory); + + (await sut.DetectSessionHijackingAsync("missing", "1.2.3.4", "UA")).Should().BeFalse(); + } + + [Fact] + public async Task DetectSessionHijackingAsync_IpAndUserAgentBothChanged_MarksSuspiciousReturnsTrue() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_IpAndUserAgentBothChanged_MarksSuspiciousReturnsTrue)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1", userAgent: "OldAgent")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var result = await sut.DetectSessionHijackingAsync("s1", "9.9.9.9", "NewAgent"); + + result.Should().BeTrue(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeTrue(); + session.SuspiciousReason.Should().Be("IP and User-Agent changed"); + } + + [Fact] + public async Task DetectSessionHijackingAsync_OnlyUserAgentChanged_ReturnsFalse() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_OnlyUserAgentChanged_ReturnsFalse)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", ipAddress: "1.1.1.1", userAgent: "OldAgent")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var result = await sut.DetectSessionHijackingAsync("s1", "1.1.1.1", "NewAgent"); + + result.Should().BeFalse(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeFalse(); + } + + [Fact] + public async Task DetectSessionHijackingAsync_IpChanged_BothCountriesLocal_SkipsImpossibleTravel() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_IpChanged_BothCountriesLocal_SkipsImpossibleTravel)); + await using (var db = factory.CreateDbContext()) + { + // Old session was on a private-network IP (Country="Local Network"), same user agent. + db.UserSessions.Add(MakeSession("s1", ipAddress: "192.168.1.5", userAgent: "SameAgent", country: "Local Network")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + // New IP is also private and doesn't match a configured VPN subnet -> Country="Local Network" too. + var result = await sut.DetectSessionHijackingAsync("s1", "10.0.0.5", "SameAgent"); + + result.Should().BeFalse(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeFalse(); + } + + [Fact] + public async Task DetectSessionHijackingAsync_IpChanged_DifferentCountryRecentActivity_MarksSuspiciousReturnsTrue() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_IpChanged_DifferentCountryRecentActivity_MarksSuspiciousReturnsTrue)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession( + "s1", ipAddress: "1.1.1.1", userAgent: "SameAgent", + country: "Germany", lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + // Public IP -> DetectVpnAsync resolves Country="Unknown", which differs from "Germany" + // and "Germany" is not a local keyword, so impossible-travel logic kicks in. + var result = await sut.DetectSessionHijackingAsync("s1", "8.8.8.8", "SameAgent"); + + result.Should().BeTrue(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeTrue(); + session.SuspiciousReason.Should().Contain("Impossible travel"); + } + + [Fact] + public async Task DetectSessionHijackingAsync_IpChanged_DifferentCountryButOldActivity_ReturnsFalse() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_IpChanged_DifferentCountryButOldActivity_ReturnsFalse)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession( + "s1", ipAddress: "1.1.1.1", userAgent: "SameAgent", + country: "Germany", lastActivity: DateTime.UtcNow.AddHours(-2))); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var result = await sut.DetectSessionHijackingAsync("s1", "8.8.8.8", "SameAgent"); + + result.Should().BeFalse(); + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeFalse(); + } + + [Fact] + public async Task DetectSessionHijackingAsync_IpChanged_OldCountryUnset_TreatsAsNotLocal() + { + var factory = CreateFactory(nameof(DetectSessionHijackingAsync_IpChanged_OldCountryUnset_TreatsAsNotLocal)); + await using (var db = factory.CreateDbContext()) + { + // session.Country was never resolved (null) -> IsLocalCountry(null) short-circuits + // to false, so the "both local" skip does not apply and impossible-travel logic runs. + db.UserSessions.Add(MakeSession( + "s1", ipAddress: "1.1.1.1", userAgent: "SameAgent", + country: null, lastActivity: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + var result = await sut.DetectSessionHijackingAsync("s1", "8.8.8.8", "SameAgent"); + + result.Should().BeTrue(); + } + + [Fact] + public async Task MarkSessionSuspiciousAsync_UnknownSession_IsNoOp() + { + var factory = CreateFactory(nameof(MarkSessionSuspiciousAsync_UnknownSession_IsNoOp)); + var sut = BuildService(factory); + + await sut.MarkSessionSuspiciousAsync("missing", "test reason"); // should not throw + + (await factory.CreateDbContext().SecurityEvents.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task MarkSessionSuspiciousAsync_KnownSession_SetsFieldsAndLogsSecurityEvent() + { + var factory = CreateFactory(nameof(MarkSessionSuspiciousAsync_KnownSession_SetsFieldsAndLogsSecurityEvent)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1")); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.MarkSessionSuspiciousAsync("s1", "unusual behaviour"); + + await using var verify = factory.CreateDbContext(); + var session = await verify.UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeTrue(); + session.SuspiciousActivityCount.Should().Be(1); + session.SuspiciousReason.Should().Be("unusual behaviour"); + session.RiskLevel.Should().Be(SessionRiskLevel.High); + session.LastSuspiciousActivity.Should().NotBeNull(); + + var securityEvent = await verify.SecurityEvents.SingleAsync(); + securityEvent.EventType.Should().Be("SUSPICIOUS_ACTIVITY"); + securityEvent.Description.Should().Be("unusual behaviour"); + } + + [Fact] + public async Task MarkSessionSuspiciousAsync_WorksOnInactiveSessionsToo() + { + var factory = CreateFactory(nameof(MarkSessionSuspiciousAsync_WorksOnInactiveSessionsToo)); + await using (var db = factory.CreateDbContext()) + { + db.UserSessions.Add(MakeSession("s1", isActive: false)); + await db.SaveChangesAsync(); + } + var sut = BuildService(factory); + + await sut.MarkSessionSuspiciousAsync("s1", "post-mortem flag"); + + var session = await factory.CreateDbContext().UserSessions.SingleAsync(); + session.IsSuspicious.Should().BeTrue(); + } + + [Fact] + public async Task DetectVpnAsync_LocalhostIp_ReturnsLocalhostCountryNoVpn() + { + var factory = CreateFactory(nameof(DetectVpnAsync_LocalhostIp_ReturnsLocalhostCountryNoVpn)); + var sut = BuildService(factory); + + var result = await sut.DetectVpnAsync("127.0.0.1"); + + result.Country.Should().Be("Localhost"); + result.IsVpn.Should().BeFalse(); + } + + [Fact] + public async Task DetectVpnAsync_PrivateIpNotMatchingSubnet_ReturnsLocalNetworkNoVpn() + { + var factory = CreateFactory(nameof(DetectVpnAsync_PrivateIpNotMatchingSubnet_ReturnsLocalNetworkNoVpn)); + var sut = BuildService(factory, vpnConfig: new VpnDetectionConfig { VpnSubnets = ["10.99.0.*"] }); + + var result = await sut.DetectVpnAsync("192.168.1.50"); + + result.Country.Should().Be("Local Network"); + result.IsVpn.Should().BeFalse(); + } + + [Fact] + public async Task DetectVpnAsync_PrivateIpMatchingConfiguredSubnet_ReturnsVpnWithConfiguredConfidence() + { + var factory = CreateFactory(nameof(DetectVpnAsync_PrivateIpMatchingConfiguredSubnet_ReturnsVpnWithConfiguredConfidence)); + var sut = BuildService(factory, vpnConfig: new VpnDetectionConfig + { + VpnSubnets = ["10.0.5.*"], + SubnetMatchConfidence = 80 + }); + + var result = await sut.DetectVpnAsync("10.0.5.17"); + + result.IsVpn.Should().BeTrue(); + result.ConfidenceScore.Should().Be(80); + result.RiskFactors.Should().Contain("IP matches configured VPN subnet"); + } + + [Fact] + public async Task DetectVpnAsync_PublicIp_ReturnsUnknownCountryNoVpn() + { + var factory = CreateFactory(nameof(DetectVpnAsync_PublicIp_ReturnsUnknownCountryNoVpn)); + var sut = BuildService(factory); + + var result = await sut.DetectVpnAsync("203.0.113.42"); + + result.Country.Should().Be("Unknown"); + result.IsVpn.Should().BeFalse(); + result.RiskFactors.Should().Contain("Public IP - no VPN detection available"); + } +} diff --git a/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceTestSupport.cs b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceTestSupport.cs new file mode 100644 index 0000000..27d2674 --- /dev/null +++ b/LagersystemLVHome.UnitTests/Services/Session/SessionManagementServiceTestSupport.cs @@ -0,0 +1,126 @@ +using LagersystemLVHome.Application.Configuration; +using LagersystemLVHome.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace LagersystemLVHome.UnitTests.Services.Session; + +/// +/// Shared test infrastructure for the SessionManagementService test files +/// (Create/Query/Lifecycle/Security/ApiSession). Kept in one place since all +/// five files exercise the same SUT with the same InMemory + factory setup. +/// +internal static class SessionManagementServiceTestSupport +{ + internal sealed class InMemoryContextFactory(DbContextOptions options) + : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => new(options); + } + + internal static IDbContextFactory CreateFactory(string name) + => new InMemoryContextFactory( + new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + internal static SessionManagementService BuildService( + IDbContextFactory factory, + IHttpContextAccessor? httpContextAccessor = null, + VpnDetectionConfig? vpnConfig = null) + => new( + factory, + httpContextAccessor ?? NoHttpContextAccessor(), + NullLogger.Instance, + Options.Create(vpnConfig ?? new VpnDetectionConfig())); + + internal static IHttpContextAccessor NoHttpContextAccessor() + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns((HttpContext?)null); + return accessor; + } + + internal static IHttpContextAccessor AccessorFor(HttpContext context) + { + var accessor = Substitute.For(); + accessor.HttpContext.Returns(context); + return accessor; + } + + internal static Warehouse MakeWarehouse(int id = 1) => new() + { + Id = id, + Name = $"WH{id}", + Code = $"WH{id}", + Address = "Test Street 1", + IsActive = true + }; + + internal static User MakeUser(int id = 1, int warehouseId = 1, string username = "u1") => new() + { + Id = id, + Username = username, + Email = $"{username}@x.local", + DisplayName = username, + PasswordHash = "x", + WarehouseId = warehouseId, + ApprovalStatus = UserApprovalStatus.Approved, + Role = UserRole.User, + IsActive = true + }; + + /// + /// Seeds a Warehouse and a User for it. Required whenever a query under + /// test does Include(s => s.User)/.Include(s => s.Warehouse): UserSession's + /// FKs to both are non-nullable, so the EF Core InMemory provider treats + /// those Includes as INNER JOINs and silently drops rows without a match. + /// + internal static async Task SeedWarehouseAndUserAsync( + IDbContextFactory factory, int userId = 1, int warehouseId = 1) + { + await using var db = factory.CreateDbContext(); + if (!await db.Warehouses.AnyAsync(w => w.Id == warehouseId)) + db.Warehouses.Add(MakeWarehouse(warehouseId)); + if (!await db.Users.AnyAsync(u => u.Id == userId)) + db.Users.Add(MakeUser(userId, warehouseId, $"u{userId}")); + await db.SaveChangesAsync(); + } + + internal static Domain.Models.UserSession MakeSession( + string sessionId, + int userId = 1, + int warehouseId = 1, + bool isActive = true, + DateTime? startTime = null, + DateTime? lastActivity = null, + string? ipAddress = "1.1.1.1", + string? deviceFingerprint = null, + string? userAgent = "UA", + string? deviceType = "Desktop", + string? browser = null, + string? country = null, + bool isSuspicious = false, + DateTime? lastSuspiciousActivity = null, + SessionRiskLevel riskLevel = SessionRiskLevel.Low, + DateTime? endTime = null) => new() + { + SessionId = sessionId, + UserId = userId, + Username = $"u{userId}", + WarehouseId = warehouseId, + IsActive = isActive, + StartTime = startTime ?? DateTime.UtcNow.AddMinutes(-5), + LastActivity = lastActivity ?? DateTime.UtcNow, + EndTime = endTime, + IpAddress = ipAddress, + DeviceFingerprint = deviceFingerprint, + UserAgent = userAgent, + DeviceType = deviceType, + Browser = browser, + Country = country, + IsSuspicious = isSuspicious, + LastSuspiciousActivity = lastSuspiciousActivity, + RiskLevel = riskLevel + }; +} diff --git a/LagersystemLVHome.UnitTests/Services/UI/GamificationServiceTests.cs b/LagersystemLVHome.UnitTests/Services/UI/GamificationServiceTests.cs index 3aa4583..7855229 100644 --- a/LagersystemLVHome.UnitTests/Services/UI/GamificationServiceTests.cs +++ b/LagersystemLVHome.UnitTests/Services/UI/GamificationServiceTests.cs @@ -12,6 +12,18 @@ private sealed class InMemoryContextFactory(DbContextOptions public InventoryDbContext CreateDbContext() => new(options); } + /// + /// A context factory that always fails, used to exercise the try/catch fallback paths + /// that are otherwise unreachable with a healthy InMemory provider. + /// + private sealed class ThrowingContextFactory : IDbContextFactory + { + public InventoryDbContext CreateDbContext() => throw new InvalidOperationException("Simulated DB failure"); + + public Task CreateDbContextAsync(CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Simulated DB failure"); + } + private static IDbContextFactory CreateFactory(string name) => new InMemoryContextFactory( new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); @@ -19,6 +31,26 @@ private static IDbContextFactory CreateFactory(string name) private static GamificationService Build(IDbContextFactory factory) => new(factory, NullLogger.Instance); + private static User MakeUser(int id, int warehouseId = 1, bool isActive = true, bool isDeleted = false) => new() + { + Id = id, + Username = $"u{id}", + Email = $"u{id}@x.local", + DisplayName = $"User {id}", + PasswordHash = "x", + WarehouseId = warehouseId, + IsActive = isActive, + IsDeleted = isDeleted + }; + + private static AuditLog MakeAuditLog(int userId, string action, DateTime timestamp, string? details = null) => new() + { + UserId = userId, + Action = action, + Timestamp = timestamp, + Details = details + }; + [Fact] public async Task RecordActionAsync_NonPositiveUserId_NoOps() { @@ -84,4 +116,467 @@ public async Task RecordActionAsync_UnknownAction_StillCreatesStatsButIgnoresCou stats.TotalMovements.Should().Be(0); stats.TotalActiveDays.Should().Be(1, because: "the streak/active-days are updated regardless of action type"); } + + [Fact] + public async Task RecordActionAsync_StockMovementWithoutScanDetail_DoesNotIncrementScans() + { + var factory = CreateFactory(nameof(RecordActionAsync_StockMovementWithoutScanDetail_DoesNotIncrementScans)); + await Build(factory).RecordActionAsync(1, "STOCK_MOVEMENT", details: "Manual entry"); + + await using var db = factory.CreateDbContext(); + var stats = await db.UserGamificationStats.SingleAsync(); + stats.TotalMovements.Should().Be(1); + stats.TotalScans.Should().Be(0); + } + + [Theory] + [InlineData("PRODUCT_UPDATED", nameof(UserGamificationStats.ProductsUpdated))] + [InlineData("PRODUCT_DELETED", nameof(UserGamificationStats.ProductsDeleted))] + [InlineData("CATEGORY_CREATED", nameof(UserGamificationStats.CategoriesCreated))] + [InlineData("STORAGE_LOCATION_CREATED", nameof(UserGamificationStats.StorageLocationsCreated))] + [InlineData("ROOM_CREATED", nameof(UserGamificationStats.RoomsCreated))] + [InlineData("IMPORT_SUCCESS", nameof(UserGamificationStats.ImportsCompleted))] + [InlineData("DATA_IMPORT", nameof(UserGamificationStats.ImportsCompleted))] + [InlineData("EXPORT", nameof(UserGamificationStats.ExportsCompleted))] + [InlineData("DATA_EXPORT", nameof(UserGamificationStats.ExportsCompleted))] + [InlineData("PASSWORD_CHANGED", nameof(UserGamificationStats.PasswordChanges))] + [InlineData("PASSWORD_RESET_SUCCESS", nameof(UserGamificationStats.PasswordChanges))] + [InlineData("2FA_ENABLED", nameof(UserGamificationStats.TwoFactorToggles))] + [InlineData("2FA_DISABLED", nameof(UserGamificationStats.TwoFactorToggles))] + [InlineData("EMAIL_OTP_ENABLED", nameof(UserGamificationStats.TwoFactorToggles))] + [InlineData("EMAIL_OTP_DISABLED", nameof(UserGamificationStats.TwoFactorToggles))] + public async Task RecordActionAsync_KnownAction_IncrementsExpectedCounter(string action, string counterProperty) + { + var factory = CreateFactory($"{nameof(RecordActionAsync_KnownAction_IncrementsExpectedCounter)}_{action}"); + await Build(factory).RecordActionAsync(1, action); + + await using var db = factory.CreateDbContext(); + var stats = await db.UserGamificationStats.SingleAsync(); + var value = (int)typeof(UserGamificationStats).GetProperty(counterProperty)!.GetValue(stats)!; + value.Should().Be(1, because: $"action {action} should increment {counterProperty}"); + } + + [Fact] + public async Task RecordActionAsync_ConsecutiveDayCall_IncrementsCurrentStreakAndLongestStreak() + { + var factory = CreateFactory(nameof(RecordActionAsync_ConsecutiveDayCall_IncrementsCurrentStreakAndLongestStreak)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats + { + UserId = 1, + LastActiveDate = DateTime.UtcNow.Date.AddDays(-1), + CurrentStreak = 2, + LongestStreak = 2, + TotalActiveDays = 2 + }); + await db.SaveChangesAsync(); + } + + await Build(factory).RecordActionAsync(1, "LOGIN_SUCCESS"); + + await using var check = factory.CreateDbContext(); + var stats = await check.UserGamificationStats.SingleAsync(); + stats.CurrentStreak.Should().Be(3, "the previous active day was yesterday, so the streak continues"); + stats.LongestStreak.Should().Be(3); + stats.TotalActiveDays.Should().Be(3); + stats.LastActiveDate.Should().Be(DateTime.UtcNow.Date); + } + + [Fact] + public async Task RecordActionAsync_GapInActivity_ResetsCurrentStreakButKeepsLongest() + { + var factory = CreateFactory(nameof(RecordActionAsync_GapInActivity_ResetsCurrentStreakButKeepsLongest)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats + { + UserId = 1, + LastActiveDate = DateTime.UtcNow.Date.AddDays(-5), + CurrentStreak = 4, + LongestStreak = 4, + TotalActiveDays = 4 + }); + await db.SaveChangesAsync(); + } + + await Build(factory).RecordActionAsync(1, "LOGIN_SUCCESS"); + + await using var check = factory.CreateDbContext(); + var stats = await check.UserGamificationStats.SingleAsync(); + stats.CurrentStreak.Should().Be(1, "the last active day was more than a day ago, so the streak resets"); + stats.LongestStreak.Should().Be(4, "the historical best streak is preserved"); + } + + [Fact] + public async Task RecordActionAsync_SecondCallSameDay_DoesNotIncrementActiveDaysOrStreak() + { + var factory = CreateFactory(nameof(RecordActionAsync_SecondCallSameDay_DoesNotIncrementActiveDaysOrStreak)); + var sut = Build(factory); + + await sut.RecordActionAsync(1, "PRODUCT_CREATED"); + await sut.RecordActionAsync(1, "PRODUCT_CREATED"); + + await using var db = factory.CreateDbContext(); + var stats = await db.UserGamificationStats.SingleAsync(); + stats.ProductsCreated.Should().Be(2, "the action counter itself still increments on every call"); + stats.TotalActiveDays.Should().Be(1, "the streak/active-days block only runs once per calendar day"); + stats.CurrentStreak.Should().Be(1); + } + + [Fact] + public async Task RecordActionAsync_ContextFactoryThrows_DoesNotThrow() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.RecordActionAsync(1, "LOGIN_SUCCESS"); + + await act.Should().NotThrowAsync(); + } + + // ---- MigrateFromAuditLogsAsync ----------------------------------------- + + [Fact] + public async Task MigrateFromAuditLogsAsync_StatsAlreadyExist_NoOps() + { + var factory = CreateFactory(nameof(MigrateFromAuditLogsAsync_StatsAlreadyExist_NoOps)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats { UserId = 1, TotalMovements = 42 }); + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + + await Build(factory).MigrateFromAuditLogsAsync(1); + + await using var check = factory.CreateDbContext(); + (await check.UserGamificationStats.SingleAsync()).TotalMovements.Should().Be(42, "an existing stats row must not be overwritten"); + } + + [Fact] + public async Task MigrateFromAuditLogsAsync_NoAuditLogs_DoesNotCreateStats() + { + var factory = CreateFactory(nameof(MigrateFromAuditLogsAsync_NoAuditLogs_DoesNotCreateStats)); + + await Build(factory).MigrateFromAuditLogsAsync(1); + + await using var check = factory.CreateDbContext(); + (await check.UserGamificationStats.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task MigrateFromAuditLogsAsync_ContextFactoryThrows_DoesNotThrow() + { + var sut = Build(new ThrowingContextFactory()); + + var act = () => sut.MigrateFromAuditLogsAsync(1); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task MigrateFromAuditLogsAsync_ComputesCountersAndStreaksFromAuditHistory() + { + var factory = CreateFactory(nameof(MigrateFromAuditLogsAsync_ComputesCountersAndStreaksFromAuditHistory)); + var today = DateTime.UtcNow.Date; + + // Two runs of active dates: an older 5-day run (the longest streak) and a trailing + // 3-day run ending yesterday (the current streak, since "today" itself has no activity). + var longRun = new[] { -10, -9, -8, -7, -6 }.Select(d => today.AddDays(d)).ToArray(); + var recentRun = new[] { -3, -2, -1 }.Select(d => today.AddDays(d)).ToArray(); + var allDates = longRun.Concat(recentRun).ToArray(); + + await using (var db = factory.CreateDbContext()) + { + foreach (var date in allDates) + { + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", date.AddHours(8))); + } + // Two scans among the stock movements. + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", longRun[0].AddHours(9), details: "Scan via camera")); + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", longRun[1].AddHours(9), details: "Scan via camera")); + + // All other action types recorded on an already-active date so the active-date set is unchanged. + var d = longRun[0]; + db.AuditLogs.AddRange( + MakeAuditLog(1, "PRODUCT_CREATED", d), + MakeAuditLog(1, "PRODUCT_UPDATED", d), + MakeAuditLog(1, "PRODUCT_DELETED", d), + MakeAuditLog(1, "CATEGORY_CREATED", d), + MakeAuditLog(1, "STORAGE_LOCATION_CREATED", d), + MakeAuditLog(1, "ROOM_CREATED", d), + MakeAuditLog(1, "IMPORT_SUCCESS", d), + MakeAuditLog(1, "DATA_IMPORT", d), + MakeAuditLog(1, "EXPORT", d), + MakeAuditLog(1, "DATA_EXPORT", d), + MakeAuditLog(1, "PASSWORD_CHANGED", d), + MakeAuditLog(1, "PASSWORD_RESET_SUCCESS", d), + MakeAuditLog(1, "LOGIN_SUCCESS", d), + MakeAuditLog(1, "PASSKEY_LOGIN_SUCCESS", d), + MakeAuditLog(1, "MAGIC_LINK_LOGIN", d), + MakeAuditLog(1, "2FA_ENABLED", d), + MakeAuditLog(1, "2FA_DISABLED", d), + MakeAuditLog(1, "EMAIL_OTP_ENABLED", d), + MakeAuditLog(1, "EMAIL_OTP_DISABLED", d)); + + // Audit log for a different user must not leak into user 1's stats. + db.AuditLogs.Add(MakeAuditLog(2, "STOCK_MOVEMENT", today)); + await db.SaveChangesAsync(); + } + + await Build(factory).MigrateFromAuditLogsAsync(1); + + await using var check = factory.CreateDbContext(); + var stats = await check.UserGamificationStats.SingleAsync(s => s.UserId == 1); + + stats.TotalMovements.Should().Be(10, "8 baseline movements plus 2 extra scan movements added on top"); + stats.TotalScans.Should().Be(2); + stats.ProductsCreated.Should().Be(1); + stats.ProductsUpdated.Should().Be(1); + stats.ProductsDeleted.Should().Be(1); + stats.CategoriesCreated.Should().Be(1); + stats.StorageLocationsCreated.Should().Be(1); + stats.RoomsCreated.Should().Be(1); + stats.ImportsCompleted.Should().Be(2); + stats.ExportsCompleted.Should().Be(2); + stats.PasswordChanges.Should().Be(2); + stats.TotalLogins.Should().Be(3); + stats.TwoFactorToggles.Should().Be(4); + stats.TotalActiveDays.Should().Be(8); + stats.LongestStreak.Should().Be(5, "the 5-day run in the past is the longest observed run"); + stats.CurrentStreak.Should().Be(3, "the trailing run ending yesterday is 3 days long"); + stats.LastActiveDate.Should().Be(recentRun[^1]); + } + + [Fact] + public async Task MigrateFromAuditLogsAsync_ActiveToday_AnchorsCurrentStreakOnToday() + { + var factory = CreateFactory(nameof(MigrateFromAuditLogsAsync_ActiveToday_AnchorsCurrentStreakOnToday)); + var today = DateTime.UtcNow.Date; + + await using (var db = factory.CreateDbContext()) + { + // Three consecutive active days including today itself, exercising the + // "activeDates.Contains(today)" branch of the streak-anchor calculation. + db.AuditLogs.AddRange( + MakeAuditLog(1, "LOGIN_SUCCESS", today.AddDays(-2).AddHours(8)), + MakeAuditLog(1, "LOGIN_SUCCESS", today.AddDays(-1).AddHours(8)), + MakeAuditLog(1, "LOGIN_SUCCESS", today.AddHours(8))); + await db.SaveChangesAsync(); + } + + await Build(factory).MigrateFromAuditLogsAsync(1); + + await using var check = factory.CreateDbContext(); + var stats = await check.UserGamificationStats.SingleAsync(s => s.UserId == 1); + stats.CurrentStreak.Should().Be(3); + stats.LongestStreak.Should().Be(3); + stats.LastActiveDate.Should().Be(today); + } + + // ---- GetUserProfileAsync ----------------------------------------------- + + [Fact] + public async Task GetUserProfileAsync_NoExistingDataAtAll_ReturnsZeroedProfile() + { + var factory = CreateFactory(nameof(GetUserProfileAsync_NoExistingDataAtAll_ReturnsZeroedProfile)); + + var profile = await Build(factory).GetUserProfileAsync(1); + + profile.UserId.Should().Be(1); + profile.TotalXP.Should().Be(0); + profile.Level.Should().Be(1); + profile.XPForNextLevel.Should().Be(100); + profile.XPInCurrentLevel.Should().Be(0); + profile.MemberSince.Should().Be(default(DateTime), "no matching User row exists"); + } + + [Fact] + public async Task GetUserProfileAsync_ExactLevelBoundary_BumpsToNextLevelWithZeroRemainder() + { + var factory = CreateFactory(nameof(GetUserProfileAsync_ExactLevelBoundary_BumpsToNextLevelWithZeroRemainder)); + await using (var db = factory.CreateDbContext()) + { + // TotalActiveDays * 20 == 100 XP exactly, which is the level-1 threshold. + db.UserGamificationStats.Add(new UserGamificationStats { UserId = 1, TotalActiveDays = 5 }); + await db.SaveChangesAsync(); + } + + var profile = await Build(factory).GetUserProfileAsync(1); + + profile.TotalXP.Should().Be(100); + profile.Level.Should().Be(2); + profile.XPForNextLevel.Should().Be(200); + profile.XPInCurrentLevel.Should().Be(0); + } + + [Fact] + public async Task GetUserProfileAsync_ComputesXpFromAllCountersAndReadsMemberSince() + { + var factory = CreateFactory(nameof(GetUserProfileAsync_ComputesXpFromAllCountersAndReadsMemberSince)); + var memberSince = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + await using (var db = factory.CreateDbContext()) + { + db.Users.Add(new User { Id = 1, Username = "u1", Email = "u1@x.local", PasswordHash = "x", CreatedAt = memberSince }); + db.UserGamificationStats.Add(new UserGamificationStats + { + UserId = 1, + TotalMovements = 2, // 2*10=20 + TotalScans = 1, // 1*15=15 + ProductsCreated = 1, // 1*25=25 + }); + // One movement clearly within "this month and this week" (now itself). + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", DateTime.UtcNow)); + // One movement clearly before this month, excluded from both windows. + db.AuditLogs.Add(MakeAuditLog(1, "STOCK_MOVEMENT", DateTime.UtcNow.AddMonths(-2))); + await db.SaveChangesAsync(); + } + + var profile = await Build(factory).GetUserProfileAsync(1); + + profile.TotalXP.Should().Be(20 + 15 + 25); + profile.MemberSince.Should().Be(memberSince); + profile.MonthlyMovements.Should().Be(1); + profile.WeeklyMovements.Should().Be(1); + } + + // ---- GetLeaderboardAsync ------------------------------------------------ + + [Fact] + public async Task GetLeaderboardAsync_ExcludesInactiveDeletedAndZeroXpUsers_OrdersByXpDescending() + { + var factory = CreateFactory(nameof(GetLeaderboardAsync_ExcludesInactiveDeletedAndZeroXpUsers_OrdersByXpDescending)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.Add(new Warehouse { Id = 1, Name = "WH1", Code = "W001", Address = "a" }); + db.Users.AddRange( + MakeUser(1, warehouseId: 1), // active, has XP -> included + MakeUser(2, warehouseId: 1), // active, zero XP -> excluded + MakeUser(3, warehouseId: 1, isActive: false), // inactive -> excluded + MakeUser(4, warehouseId: 1, isDeleted: true)); // deleted -> excluded + db.UserGamificationStats.AddRange( + new UserGamificationStats { UserId = 1, TotalMovements = 5 }, // 50 XP + new UserGamificationStats { UserId = 2, TotalMovements = 0 }, // 0 XP + new UserGamificationStats { UserId = 3, TotalMovements = 5 }, + new UserGamificationStats { UserId = 4, TotalMovements = 5 }); + await db.SaveChangesAsync(); + } + + var leaderboard = await Build(factory).GetLeaderboardAsync(warehouseId: null); + + leaderboard.Should().ContainSingle().Which.UserId.Should().Be(1); + } + + [Fact] + public async Task GetLeaderboardAsync_FiltersByWarehouseAndOrdersDescending() + { + var factory = CreateFactory(nameof(GetLeaderboardAsync_FiltersByWarehouseAndOrdersDescending)); + await using (var db = factory.CreateDbContext()) + { + db.Warehouses.AddRange( + new Warehouse { Id = 1, Name = "WH1", Code = "W001", Address = "a" }, + new Warehouse { Id = 2, Name = "WH2", Code = "W002", Address = "b" }); + db.Users.AddRange( + MakeUser(1, warehouseId: 1), + MakeUser(2, warehouseId: 1), + MakeUser(3, warehouseId: 2)); + db.UserGamificationStats.AddRange( + new UserGamificationStats { UserId = 1, TotalMovements = 2 }, // 20 XP + new UserGamificationStats { UserId = 2, TotalMovements = 10 }, // 100 XP + new UserGamificationStats { UserId = 3, TotalMovements = 50 }); // other warehouse + await db.SaveChangesAsync(); + } + + var leaderboard = await Build(factory).GetLeaderboardAsync(warehouseId: 1); + + leaderboard.Should().HaveCount(2); + leaderboard[0].UserId.Should().Be(2, "higher XP should be ranked first"); + leaderboard[1].UserId.Should().Be(1); + } + + // ---- GetAchievementsAsync ----------------------------------------------- + + [Fact] + public async Task GetAchievementsAsync_ReturnsFullCatalogueWithCorrectUnlockState() + { + var factory = CreateFactory(nameof(GetAchievementsAsync_ReturnsFullCatalogueWithCorrectUnlockState)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats { UserId = 1, TotalMovements = 1 }); + await db.SaveChangesAsync(); + } + + var achievements = await Build(factory).GetAchievementsAsync(1); + + achievements.Should().HaveCount(49, "this mirrors the hard-coded achievement catalogue in GamificationService"); + var firstSteps = achievements.Single(a => a.Name == "Erste Schritte"); + firstSteps.IsUnlocked.Should().BeTrue(); + firstSteps.Progress.Should().Be(1.0); + + var fifty = achievements.Single(a => a.Name == "Fleißig"); + fifty.IsUnlocked.Should().BeFalse(); + fifty.Progress.Should().BeApproximately(1.0 / 50, 0.0001); + } + + // ---- GetStreakInfoAsync -------------------------------------------------- + + [Fact] + public async Task GetStreakInfoAsync_LastActiveToday_IsActiveTodayTrue() + { + var factory = CreateFactory(nameof(GetStreakInfoAsync_LastActiveToday_IsActiveTodayTrue)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats + { + UserId = 1, + CurrentStreak = 3, + LongestStreak = 5, + TotalActiveDays = 10, + LastActiveDate = DateTime.UtcNow.Date + }); + await db.SaveChangesAsync(); + } + + var info = await Build(factory).GetStreakInfoAsync(1); + + info.IsActiveToday.Should().BeTrue(); + info.CurrentStreak.Should().Be(3); + info.LongestStreak.Should().Be(5); + info.TotalActiveDays.Should().Be(10); + } + + [Fact] + public async Task GetStreakInfoAsync_LastActiveYesterday_IsActiveTodayFalse() + { + var factory = CreateFactory(nameof(GetStreakInfoAsync_LastActiveYesterday_IsActiveTodayFalse)); + await using (var db = factory.CreateDbContext()) + { + db.UserGamificationStats.Add(new UserGamificationStats + { + UserId = 1, + LastActiveDate = DateTime.UtcNow.Date.AddDays(-1) + }); + await db.SaveChangesAsync(); + } + + var info = await Build(factory).GetStreakInfoAsync(1); + + info.IsActiveToday.Should().BeFalse(); + } + + [Fact] + public async Task GetStreakInfoAsync_NoExistingStats_CreatesRowAndReturnsZeroed() + { + var factory = CreateFactory(nameof(GetStreakInfoAsync_NoExistingStats_CreatesRowAndReturnsZeroed)); + + var info = await Build(factory).GetStreakInfoAsync(1); + + info.CurrentStreak.Should().Be(0); + info.LongestStreak.Should().Be(0); + info.TotalActiveDays.Should().Be(0); + info.IsActiveToday.Should().BeFalse(); + + await using var check = factory.CreateDbContext(); + (await check.UserGamificationStats.CountAsync()).Should().Be(1, "GetOrCreateStatsAsync persists a new row on first access"); + } }