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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ cd src/Services/Identity/Identity.API
dotnet run
```

## Running tests

Run the automated Notification service tests from the repository root:

```bash
dotnet test src/Microservices.sln
```

The tests use SQLite in-memory storage and mocked messaging, so they do not
require external PostgreSQL or RabbitMQ infrastructure.

## Related Repositories

| Repo | Purpose |
Expand Down
32 changes: 32 additions & 0 deletions src/Microservices.sln
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,42 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Infrastructure", "Shared\Shared.Infrastructure\Shared.Infrastructure.csproj", "{D0000002-0000-0000-0000-000000000001}"
EndProject

Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{A1B2C3D4-0008-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.Tests", "..\tests\Notification.Tests\Notification.Tests.csproj", "{E0000001-0000-0000-0000-000000000001}"
EndProject

Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D0000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D0000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{E0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{A1B2C3D4-0002-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001}
{A1B2C3D4-0003-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001}
Expand All @@ -88,5 +119,6 @@ Global
{B5000001-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
{B5000002-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
{B5000003-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001}
{E0000001-0000-0000-0000-000000000001} = {A1B2C3D4-0008-0000-0000-000000000001}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<ItemGroup>
<ProjectReference Include="..\Notification.Domain\Notification.Domain.csproj" />
<ProjectReference Include="..\Notification.Infrastructure\Notification.Infrastructure.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\..\Shared\Shared.Infrastructure\Shared.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.*" />
Expand Down
1 change: 1 addition & 0 deletions tests/Notification.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Xunit;
29 changes: 29 additions & 0 deletions tests/Notification.Tests/Notification.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Services\Notification\Notification.API\Notification.API.csproj" />
<ProjectReference Include="..\..\src\Services\Notification\Notification.Domain\Notification.Domain.csproj" />
<ProjectReference Include="..\..\src\Services\Notification\Notification.Infrastructure\Notification.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Shared\Shared.Contracts\Shared.Contracts.csproj" />
</ItemGroup>
</Project>
117 changes: 117 additions & 0 deletions tests/Notification.Tests/NotificationControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using Notification.API.Controllers;
using Notification.API.Services;
using Notification.Domain.Entities;
using Notification.Domain.Interfaces;
using Shared.Contracts.Events;

namespace Notification.Tests;

public class NotificationControllerTests
{
[Fact]
public async Task GetById_ReturnsNotFoundWhenNotificationIsMissing()
{
var repository = new Mock<INotificationRepository>();
repository
.Setup(x => x.GetByIdAsync(It.IsAny<Guid>()))
.ReturnsAsync((OrderNotification?)null);
var controller = CreateController(repository.Object);

var result = await controller.GetById(Guid.NewGuid());

result.Should().BeOfType<NotFoundResult>();
}

[Fact]
public async Task GetById_ReturnsOkWhenNotificationExists()
{
var notification = CreateNotification();
var repository = new Mock<INotificationRepository>();
repository
.Setup(x => x.GetByIdAsync(notification.Id))
.ReturnsAsync(notification);
var controller = CreateController(repository.Object);

var result = await controller.GetById(notification.Id);

result.Should().BeOfType<OkObjectResult>();
}

[Fact]
public async Task GetPreview_ReturnsNotFoundWhenRenderedBodyIsEmpty()
{
var notification = CreateNotification();
notification.RenderedBody = string.Empty;
var repository = new Mock<INotificationRepository>();
repository
.Setup(x => x.GetByIdAsync(notification.Id))
.ReturnsAsync(notification);
var controller = CreateController(repository.Object);

var result = await controller.GetPreview(notification.Id);

result.Should().BeOfType<NotFoundObjectResult>();
}

[Fact]
public async Task ReceiveOrderPlacedEvent_MapsDtoAndReturnsCreatedAtAction()
{
var repository = new Mock<INotificationRepository>();
OrderNotification? addedNotification = null;
repository
.Setup(x => x.AddAsync(It.IsAny<OrderNotification>()))
.Callback<OrderNotification>(notification => addedNotification = notification)
.ReturnsAsync((OrderNotification notification) => notification);
var controller = CreateController(repository.Object);
var orderId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var placedAt = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc);
var dto = new OrderPlacedEventDto(orderId, customerId, 12345m, placedAt);

var result = await controller.ReceiveOrderPlacedEvent(dto);

var created = result.Should().BeOfType<CreatedAtActionResult>().Subject;
created.ActionName.Should().Be(nameof(NotificationController.GetPreview));
created.RouteValues.Should().ContainKey("id");
addedNotification.Should().NotBeNull();
addedNotification!.OrderId.Should().Be(orderId);
addedNotification.CustomerId.Should().Be(customerId);
addedNotification.OrderTotal.Should().Be(12345m);
addedNotification.Status.Should().Be(NotificationStatus.Rendered);
repository.Verify(x => x.AddAsync(It.IsAny<OrderNotification>()), Times.Once);
}

private static NotificationController CreateController(INotificationRepository repository)
{
var consumer = new OrderEventConsumer(
repository,
new NotificationRenderer(),
Mock.Of<ILogger<OrderEventConsumer>>());
return new NotificationController(
repository,
consumer,
Mock.Of<ILogger<NotificationController>>());
}

private static OrderNotification CreateNotification()
{
return new OrderNotification
{
Id = Guid.NewGuid(),
OrderId = Guid.NewGuid(),
CustomerId = Guid.NewGuid(),
OrderTotal = 12345m,
CustomerEmail = "customer@example.com",
CustomerName = "Valued Customer",
Type = NotificationType.OrderConfirmation,
Status = NotificationStatus.Rendered,
RenderedSubject = "Order Confirmed",
RenderedBody = "<p>Rendered</p>",
CreatedAt = DateTime.UtcNow
};
}
}
83 changes: 83 additions & 0 deletions tests/Notification.Tests/NotificationRendererTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Globalization;
using FluentAssertions;
using Notification.API.Services;
using Notification.Domain.Entities;

namespace Notification.Tests;

public class NotificationRendererTests
{
[Fact]
public void RenderNotification_OrderConfirmation_UsesOrderConfirmationSubject()
{
using var culture = new CultureScope("en-US");
var notification = CreateNotification(12345m);

var result = new NotificationRenderer().RenderNotification(notification);

result.subject.Should().Be("Order Confirmed — $123.45");
}

[Fact]
public void RenderNotification_NonOrderConfirmation_UsesDefaultFallback()
{
var notification = CreateNotification(12345m);
notification.Type = NotificationType.OrderShipped;

var result = new NotificationRenderer().RenderNotification(notification);

result.subject.Should().Be("Notification");
result.body.Should().Be($"<p>Notification for order {notification.OrderId}</p>");
}

[Fact]
public void RenderNotification_OrderConfirmation_FormatsCurrencyAndIncludesCustomerDetails()
{
using var culture = new CultureScope("en-US");
var orderId = Guid.Parse("abcdef12-3456-7890-abcd-ef1234567890");
var notification = CreateNotification(12345m);
notification.OrderId = orderId;

var result = new NotificationRenderer().RenderNotification(notification);

result.body.Should().Contain("$123.45");
result.body.Should().Contain(notification.CustomerName);
result.body.Should().Contain(notification.CustomerEmail);
result.body.Should().Contain("ABCDEF12");
}

private static OrderNotification CreateNotification(decimal orderTotal)
{
return new OrderNotification
{
Id = Guid.NewGuid(),
OrderId = Guid.NewGuid(),
CustomerId = Guid.NewGuid(),
OrderTotal = orderTotal,
CustomerEmail = "alex@example.com",
CustomerName = "Alex Customer",
Type = NotificationType.OrderConfirmation,
Status = NotificationStatus.Pending,
CreatedAt = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc)
};
}

private sealed class CultureScope : IDisposable
{
private readonly CultureInfo _originalCulture = CultureInfo.CurrentCulture;
private readonly CultureInfo _originalUICulture = CultureInfo.CurrentUICulture;

public CultureScope(string cultureName)
{
var culture = CultureInfo.GetCultureInfo(cultureName);
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
}

public void Dispose()
{
CultureInfo.CurrentCulture = _originalCulture;
CultureInfo.CurrentUICulture = _originalUICulture;
}
}
}
Loading