diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..8666b59
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,46 @@
+name: Build
+
+on:
+ push:
+ branches:
+ - main
+ - "feature/**"
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ SOLUTION_PATH: src/CSharpFundamentals/CSharpFundamentals.slnx
+
+jobs:
+ build:
+ name: Build (.NET 10)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up .NET 10
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore "$SOLUTION_PATH"
+
+ - name: Build in Release mode
+ run: dotnet build "$SOLUTION_PATH" --configuration Release --no-restore --warnaserror
+
+ - name: Verify executable examples
+ run: >-
+ dotnet run
+ --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj
+ --configuration Release
+ --no-build
+ --
+ verify
diff --git a/.github/workflows/markdown-links.yml b/.github/workflows/markdown-links.yml
new file mode 100644
index 0000000..7483df5
--- /dev/null
+++ b/.github/workflows/markdown-links.yml
@@ -0,0 +1,39 @@
+name: Markdown Links
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - "**/*.md"
+ - ".github/workflows/markdown-links.yml"
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "**/*.md"
+ - ".github/workflows/markdown-links.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ links:
+ name: Validate Markdown links
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Check links
+ uses: lycheeverse/lychee-action@v2.8.0
+ with:
+ args: >-
+ --root-dir "${{ github.workspace }}"
+ --verbose
+ --no-progress
+ "./**/*.md"
+ fail: true
+ jobSummary: true
diff --git a/README.md b/README.md
index e6e50ca..2d76394 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,12 @@

+ [](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/build.yml)
+ [](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/markdown-links.yml)
[](https://learn.microsoft.com/dotnet/csharp/)
[](https://dotnet.microsoft.com/)
[](#module-index)
- [](./LICENSE)
+ [](./LICENSE)
# C# Engineering Fundamentals
@@ -16,10 +18,12 @@ This repository is not organized as a syntax tutorial. Each module is an enginee
## Table of Contents
- [Engineering Model](#engineering-model)
+- [Engineering Quality](#engineering-quality)
- [Module Index](#module-index)
- [Documentation Standard](#documentation-standard)
- [Runnable Scenarios](#runnable-scenarios)
- [Build and Validate](#build-and-validate)
+- [Technical Blog Watch](#technical-blog-watch)
- [Repository Structure](#repository-structure)
- [Contribution Standard](#contribution-standard)
@@ -34,6 +38,16 @@ flowchart LR
Operations --> Review["Review and interview reasoning"]
```
+## Engineering Quality
+
+Repository quality is enforced through small, reviewable changes and automated verification:
+
+- **Continuous integration:** every push to `main` or a feature branch and every pull request to `main` restores, builds, and verifies the executable examples in Release mode.
+- **Strict compilation:** CI treats compiler warnings as errors to prevent warning debt from entering the stable branch.
+- **Behavior verification:** `dotnet run -- verify` exercises the example decision paths and fails fast when expected outcomes drift.
+- **Documentation integrity:** Markdown links are checked automatically when documentation changes.
+- **Reproducible tooling:** workflows install the .NET 10 SDK explicitly instead of relying on the runner's preinstalled SDKs.
+
## Module Index
| Module | Engineering area | Code examples |
@@ -53,21 +67,22 @@ flowchart LR
Every module contains:
-- `README.md` — decision-oriented overview and navigation;
-- `Theory.md` — language and runtime semantics;
-- `BestPractices.md` — implementation and review checklist;
-- `CommonMistakes.md` — production failure patterns;
-- `InterviewQuestions.md` — 10 senior-level questions with answers;
-- `ProductionNotes.md` — enterprise use, memory, performance, and Microsoft-aligned recommendations;
-- compileable `.cs` examples using orders, payments, users, claims, services, and infrastructure boundaries.
+- `README.md` - decision-oriented overview and navigation
+- `Theory.md` - language and runtime semantics
+- `BestPractices.md` - implementation and review checklist
+- `CommonMistakes.md` - production failure patterns
+- `InterviewQuestions.md` - 10 senior-level questions with answers
+- `ProductionNotes.md` - enterprise use, memory, performance, and Microsoft-aligned recommendations
+- compileable `.cs` examples using orders, payments, users, claims, services, and infrastructure boundaries
## Runnable Scenarios
-The application includes executable order processing, payment validation, and notification dispatch examples.
+The application includes executable order processing, payment validation, and notification dispatch examples, plus a built-in verification command for deterministic behavior checks.
```bash
dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj
dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj -- payment
+dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj -- verify
```
See the [execution guide](./examples/README.md).
@@ -79,27 +94,23 @@ Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0).
```bash
dotnet restore src/CSharpFundamentals/CSharpFundamentals.slnx
dotnet build src/CSharpFundamentals/CSharpFundamentals.slnx --configuration Release
+dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj --configuration Release --no-build -- verify
```
+## Technical Blog Watch
+
+- [.NET Team: Announcing .NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) explains the runtime, language, and tooling release this repository targets.
+
## Repository Structure
```text
csharp-fundamentals/
-├── assets/
-├── examples/
-└── src/CSharpFundamentals/CSharpFundamentals/
- ├── Examples/ # Executable cross-topic scenarios
- └── Modules/
- ├── 01-Variables/
- ├── 02-DataTypes/
- ├── 03-TypeConversion/
- ├── 04-Operators/
- ├── 05-Strings/
- ├── 06-Arrays/
- ├── 07-Methods/
- ├── 08-ControlFlow/
- ├── 09-ExceptionHandling/
- └── 10-Namespaces/
+|-- assets/
+|-- examples/
+`-- src/CSharpFundamentals/CSharpFundamentals/
+ |-- Examples/ # Executable cross-topic scenarios
+ |-- Modules/
+ `-- Verification/ # Built-in behavior checks for the examples
```
## Contribution Standard
diff --git a/examples/README.md b/examples/README.md
index 9c1a2f4..dd908a7 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,6 +1,6 @@
# Runnable Backend Examples
-[← Repository home](../README.md) · [Module index](../README.md#module-index)
+[Repository home](../README.md) | [Module index](../README.md#module-index)
These examples are small enough to run locally while using domains and design concerns found in backend services.
@@ -10,6 +10,14 @@ These examples are small enough to run locally while using domains and design co
dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj
```
+## Verify Expected Behavior
+
+```bash
+dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamentals.csproj -- verify
+```
+
+The `verify` command checks the reusable order, payment, and notification decision paths so regressions surface before a pull request is opened.
+
## Available Examples
| Argument | Scenario | Concepts |
@@ -26,9 +34,9 @@ dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamental
Expected behavior:
-- validates customer and line data;
-- calculates subtotal, tax, and total;
-- flags orders at or above the review threshold.
+- validates customer and line data
+- calculates subtotal, tax, and total
+- flags orders at or above the review threshold
### Payment Validation
@@ -38,9 +46,9 @@ dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamental
Expected behavior:
-- rejects invalid amounts or unsupported currencies;
-- routes high-risk or large payments to manual review;
-- returns a decision object rather than using exceptions for expected outcomes.
+- rejects invalid amounts or unsupported currencies
+- routes high-risk or large payments to manual review
+- returns a decision object rather than using exceptions for expected outcomes
### Notification Dispatch
@@ -50,9 +58,9 @@ dotnet run --project src/CSharpFundamentals/CSharpFundamentals/CSharpFundamental
Expected behavior:
-- resolves a user through a repository interface;
-- respects notification preferences;
-- sends through an asynchronous gateway with cancellation support.
+- resolves a user through a repository interface
+- respects notification preferences
+- sends through an asynchronous gateway with cancellation support
## Practice Extensions
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchExample.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchExample.cs
index c2fb016..e31e60b 100644
--- a/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchExample.cs
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchExample.cs
@@ -21,65 +21,4 @@ public async Task RunAsync(CancellationToken cancellationToken)
Console.WriteLine($"Notification status: {result.Status}");
}
-
- private interface IUserRepository
- {
- Task FindAsync(Guid userId, CancellationToken cancellationToken);
- }
-
- private interface IMessageGateway
- {
- Task SendAsync(string destination, string message, CancellationToken cancellationToken);
- }
-
- private sealed class NotificationService(
- IUserRepository users,
- IMessageGateway gateway)
- {
- public async Task NotifyAsync(
- Guid userId,
- string message,
- CancellationToken cancellationToken)
- {
- User? user = await users.FindAsync(userId, cancellationToken);
-
- if (user is null)
- return new NotificationResult("User not found");
-
- if (!user.NotificationsEnabled)
- return new NotificationResult("Notifications disabled");
-
- await gateway.SendAsync(user.Email, message, cancellationToken);
- return new NotificationResult("Sent");
- }
- }
-
- private sealed class InMemoryUserRepository(IEnumerable users) : IUserRepository
- {
- private readonly IReadOnlyDictionary _users =
- users.ToDictionary(user => user.Id);
-
- public Task FindAsync(Guid userId, CancellationToken cancellationToken)
- {
- cancellationToken.ThrowIfCancellationRequested();
- _users.TryGetValue(userId, out User? user);
- return Task.FromResult(user);
- }
- }
-
- private sealed class ConsoleMessageGateway : IMessageGateway
- {
- public async Task SendAsync(
- string destination,
- string message,
- CancellationToken cancellationToken)
- {
- await Task.Delay(25, cancellationToken);
- Console.WriteLine($"Sending to {destination}: {message}");
- }
- }
-
- private sealed record User(Guid Id, string Email, bool NotificationsEnabled);
-
- private sealed record NotificationResult(string Status);
}
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchScenario.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchScenario.cs
new file mode 100644
index 0000000..ee90b2d
--- /dev/null
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchScenario.cs
@@ -0,0 +1,62 @@
+namespace CSharpFundamentals.Examples;
+
+internal interface IUserRepository
+{
+ Task FindAsync(Guid userId, CancellationToken cancellationToken);
+}
+
+internal interface IMessageGateway
+{
+ Task SendAsync(string destination, string message, CancellationToken cancellationToken);
+}
+
+internal sealed class NotificationService(
+ IUserRepository users,
+ IMessageGateway gateway)
+{
+ public async Task NotifyAsync(
+ Guid userId,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ User? user = await users.FindAsync(userId, cancellationToken);
+
+ if (user is null)
+ return new NotificationResult("User not found");
+
+ if (!user.NotificationsEnabled)
+ return new NotificationResult("Notifications disabled");
+
+ await gateway.SendAsync(user.Email, message, cancellationToken);
+ return new NotificationResult("Sent");
+ }
+}
+
+internal sealed class InMemoryUserRepository(IEnumerable users) : IUserRepository
+{
+ private readonly IReadOnlyDictionary _users =
+ users.ToDictionary(user => user.Id);
+
+ public Task FindAsync(Guid userId, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _users.TryGetValue(userId, out User? user);
+ return Task.FromResult(user);
+ }
+}
+
+internal sealed class ConsoleMessageGateway : IMessageGateway
+{
+ public async Task SendAsync(
+ string destination,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ await Task.Delay(25, cancellationToken);
+ Console.WriteLine($"Sending to {destination}: {message}");
+ }
+}
+
+internal sealed record User(Guid Id, string Email, bool NotificationsEnabled);
+
+internal sealed record NotificationResult(string Status);
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingExample.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingExample.cs
index 2d92e8c..58b91b3 100644
--- a/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingExample.cs
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingExample.cs
@@ -2,6 +2,8 @@ namespace CSharpFundamentals.Examples;
public sealed class OrderProcessingExample : IBackendExample
{
+ private readonly OrderProcessingService _service = new();
+
public string Name => "Order processing";
public string Description =>
@@ -18,7 +20,7 @@ public Task RunAsync(CancellationToken cancellationToken)
new OrderLine("REPORTING", 1, 225.50m)
]);
- OrderResult result = Process(request);
+ OrderResult result = _service.Process(request);
Console.WriteLine($"Order ID: {result.OrderId}");
Console.WriteLine($"Subtotal: {result.Subtotal:C}");
@@ -28,40 +30,4 @@ public Task RunAsync(CancellationToken cancellationToken)
return Task.CompletedTask;
}
-
- private static OrderResult Process(PlaceOrderRequest request)
- {
- ArgumentNullException.ThrowIfNull(request);
-
- if (request.CustomerId == Guid.Empty)
- throw new ArgumentException("CustomerId is required.", nameof(request));
-
- if (request.Lines.Count == 0)
- throw new ArgumentException("At least one order line is required.", nameof(request));
-
- if (request.Lines.Any(line => line.Quantity <= 0 || line.UnitPrice <= 0))
- throw new ArgumentException("Order lines require positive quantity and price.", nameof(request));
-
- decimal subtotal = request.Lines.Sum(line => line.Quantity * line.UnitPrice);
- decimal tax = decimal.Round(subtotal * 0.0825m, 2, MidpointRounding.AwayFromZero);
- decimal total = subtotal + tax;
-
- return new OrderResult(
- Guid.NewGuid(),
- subtotal,
- tax,
- total,
- total >= 1_000m);
- }
-
- private sealed record PlaceOrderRequest(Guid CustomerId, IReadOnlyList Lines);
-
- private sealed record OrderLine(string ProductCode, int Quantity, decimal UnitPrice);
-
- private sealed record OrderResult(
- Guid OrderId,
- decimal Subtotal,
- decimal Tax,
- decimal Total,
- bool RequiresManualReview);
}
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingScenario.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingScenario.cs
new file mode 100644
index 0000000..14607f1
--- /dev/null
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingScenario.cs
@@ -0,0 +1,43 @@
+namespace CSharpFundamentals.Examples;
+
+internal sealed class OrderProcessingService
+{
+ private const decimal TaxRate = 0.0825m;
+ private const decimal ManualReviewThreshold = 1_000m;
+
+ public OrderResult Process(PlaceOrderRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (request.CustomerId == Guid.Empty)
+ throw new ArgumentException("CustomerId is required.", nameof(request));
+
+ if (request.Lines.Count == 0)
+ throw new ArgumentException("At least one order line is required.", nameof(request));
+
+ if (request.Lines.Any(line => line.Quantity <= 0 || line.UnitPrice <= 0))
+ throw new ArgumentException("Order lines require positive quantity and price.", nameof(request));
+
+ decimal subtotal = request.Lines.Sum(line => line.Quantity * line.UnitPrice);
+ decimal tax = decimal.Round(subtotal * TaxRate, 2, MidpointRounding.AwayFromZero);
+ decimal total = subtotal + tax;
+
+ return new OrderResult(
+ Guid.NewGuid(),
+ subtotal,
+ tax,
+ total,
+ total >= ManualReviewThreshold);
+ }
+}
+
+internal sealed record PlaceOrderRequest(Guid CustomerId, IReadOnlyList Lines);
+
+internal sealed record OrderLine(string ProductCode, int Quantity, decimal UnitPrice);
+
+internal sealed record OrderResult(
+ Guid OrderId,
+ decimal Subtotal,
+ decimal Tax,
+ decimal Total,
+ bool RequiresManualReview);
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationExample.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationExample.cs
index 31afbcb..cd4cd65 100644
--- a/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationExample.cs
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationExample.cs
@@ -2,6 +2,8 @@ namespace CSharpFundamentals.Examples;
public sealed class PaymentValidationExample : IBackendExample
{
+ private readonly PaymentDecisionEngine _engine = new();
+
public string Name => "Payment validation";
public string Description =>
@@ -17,7 +19,7 @@ public Task RunAsync(CancellationToken cancellationToken)
"USD",
RiskLevel.High);
- PaymentDecision decision = Evaluate(request);
+ PaymentDecision decision = _engine.Evaluate(request);
Console.WriteLine($"Payment ID: {request.PaymentId}");
Console.WriteLine($"Decision: {decision.Status}");
@@ -25,44 +27,4 @@ public Task RunAsync(CancellationToken cancellationToken)
return Task.CompletedTask;
}
-
- private static PaymentDecision Evaluate(PaymentRequest request) =>
- request switch
- {
- { Amount: <= 0 } =>
- PaymentDecision.Rejected("Amount must be positive."),
- { Currency: not "USD" and not "CAD" } =>
- PaymentDecision.Rejected("Currency is not supported."),
- { RiskLevel: RiskLevel.High } =>
- PaymentDecision.Review("High-risk payment requires manual review."),
- { Amount: > 10_000m } =>
- PaymentDecision.Review("Large payment requires manual review."),
- _ =>
- PaymentDecision.Approved()
- };
-
- private sealed record PaymentRequest(
- Guid PaymentId,
- decimal Amount,
- string Currency,
- RiskLevel RiskLevel);
-
- private sealed record PaymentDecision(string Status, string Reason)
- {
- public static PaymentDecision Approved() =>
- new("Approved", "Payment passed validation.");
-
- public static PaymentDecision Review(string reason) =>
- new("Manual review", reason);
-
- public static PaymentDecision Rejected(string reason) =>
- new("Rejected", reason);
- }
-
- private enum RiskLevel
- {
- Low,
- Medium,
- High
- }
}
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationScenario.cs b/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationScenario.cs
new file mode 100644
index 0000000..271aac4
--- /dev/null
+++ b/src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationScenario.cs
@@ -0,0 +1,44 @@
+namespace CSharpFundamentals.Examples;
+
+internal sealed class PaymentDecisionEngine
+{
+ public PaymentDecision Evaluate(PaymentRequest request) =>
+ request switch
+ {
+ { Amount: <= 0 } =>
+ PaymentDecision.Rejected("Amount must be positive."),
+ { Currency: not "USD" and not "CAD" } =>
+ PaymentDecision.Rejected("Currency is not supported."),
+ { RiskLevel: RiskLevel.High } =>
+ PaymentDecision.Review("High-risk payment requires manual review."),
+ { Amount: > 10_000m } =>
+ PaymentDecision.Review("Large payment requires manual review."),
+ _ =>
+ PaymentDecision.Approved()
+ };
+}
+
+internal sealed record PaymentRequest(
+ Guid PaymentId,
+ decimal Amount,
+ string Currency,
+ RiskLevel RiskLevel);
+
+internal sealed record PaymentDecision(string Status, string Reason)
+{
+ public static PaymentDecision Approved() =>
+ new("Approved", "Payment passed validation.");
+
+ public static PaymentDecision Review(string reason) =>
+ new("Manual review", reason);
+
+ public static PaymentDecision Rejected(string reason) =>
+ new("Rejected", reason);
+}
+
+internal enum RiskLevel
+{
+ Low,
+ Medium,
+ High
+}
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Program.cs b/src/CSharpFundamentals/CSharpFundamentals/Program.cs
index 66e3e77..188aee2 100644
--- a/src/CSharpFundamentals/CSharpFundamentals/Program.cs
+++ b/src/CSharpFundamentals/CSharpFundamentals/Program.cs
@@ -1,5 +1,6 @@
using CSharpFundamentals.Examples;
using CSharpFundamentals.Utilities;
+using CSharpFundamentals.Verification;
Console.Title = "C# Fundamentals: Backend Examples";
@@ -13,10 +14,13 @@
if (args.Length > 0)
{
+ if (string.Equals(args[0], "verify", StringComparison.OrdinalIgnoreCase))
+ return await new ExampleVerificationRunner().RunAsync(Console.Out, CancellationToken.None);
+
if (!examples.TryGetValue(args[0], out var selected))
{
Console.Error.WriteLine($"Unknown example '{args[0]}'.");
- Console.Error.WriteLine($"Available examples: {string.Join(", ", examples.Keys)}");
+ Console.Error.WriteLine($"Available commands: verify, {string.Join(", ", examples.Keys)}");
return 1;
}
diff --git a/src/CSharpFundamentals/CSharpFundamentals/Verification/ExampleVerificationRunner.cs b/src/CSharpFundamentals/CSharpFundamentals/Verification/ExampleVerificationRunner.cs
new file mode 100644
index 0000000..fbf013a
--- /dev/null
+++ b/src/CSharpFundamentals/CSharpFundamentals/Verification/ExampleVerificationRunner.cs
@@ -0,0 +1,225 @@
+using CSharpFundamentals.Examples;
+
+namespace CSharpFundamentals.Verification;
+
+internal sealed class ExampleVerificationRunner
+{
+ public async Task RunAsync(TextWriter output, CancellationToken cancellationToken)
+ {
+ var checks = new List();
+
+ RunCheck(
+ checks,
+ "Order totals stay deterministic for a valid request",
+ () =>
+ {
+ var service = new OrderProcessingService();
+ var result = service.Process(
+ new PlaceOrderRequest(
+ Guid.Parse("5a91d8d4-2727-4061-95f0-59a616e8024f"),
+ [
+ new OrderLine("API-SUPPORT", 1, 200m),
+ new OrderLine("REPORTING", 1, 250m)
+ ]));
+
+ AssertEqual(450m, result.Subtotal, "Expected order subtotal to match line totals.");
+ AssertEqual(37.13m, result.Tax, "Expected order tax to be rounded away from zero.");
+ AssertEqual(487.13m, result.Total, "Expected order total to include tax.");
+ AssertEqual(false, result.RequiresManualReview, "Expected smaller orders to avoid manual review.");
+ });
+
+ RunCheck(
+ checks,
+ "Order validation rejects missing order lines",
+ () =>
+ {
+ var service = new OrderProcessingService();
+
+ ArgumentException exception = AssertThrows(
+ () => service.Process(
+ new PlaceOrderRequest(
+ Guid.Parse("67c2c86d-cc51-4f8c-b0cb-1d8e2e30197f"),
+ [])));
+
+ AssertStartsWith(
+ "At least one order line is required.",
+ exception.Message,
+ "Expected an actionable validation message for empty orders.");
+ });
+
+ RunCheck(
+ checks,
+ "Payment validation approves a supported low-risk payment",
+ () =>
+ {
+ var engine = new PaymentDecisionEngine();
+ var decision = engine.Evaluate(
+ new PaymentRequest(
+ Guid.Parse("0b3d73f9-84e2-41b0-8f3d-f9b1dbc1aa90"),
+ 250m,
+ "USD",
+ RiskLevel.Low));
+
+ AssertEqual("Approved", decision.Status, "Expected low-risk supported payments to be approved.");
+ });
+
+ RunCheck(
+ checks,
+ "Payment validation routes high-risk payments to review",
+ () =>
+ {
+ var engine = new PaymentDecisionEngine();
+ var decision = engine.Evaluate(
+ new PaymentRequest(
+ Guid.Parse("5a18890d-8be0-46df-9810-cd32e0409f9a"),
+ 250m,
+ "USD",
+ RiskLevel.High));
+
+ AssertEqual("Manual review", decision.Status, "Expected high-risk payments to be reviewed.");
+ });
+
+ RunCheck(
+ checks,
+ "Payment validation rejects unsupported currencies",
+ () =>
+ {
+ var engine = new PaymentDecisionEngine();
+ var decision = engine.Evaluate(
+ new PaymentRequest(
+ Guid.Parse("d0e0f54b-9c75-4839-bfd8-ecb957b1ac63"),
+ 250m,
+ "EUR",
+ RiskLevel.Low));
+
+ AssertEqual("Rejected", decision.Status, "Expected unsupported currencies to be rejected.");
+ });
+
+ await RunCheckAsync(
+ checks,
+ "Notification dispatch respects disabled preferences",
+ async () =>
+ {
+ var userId = Guid.Parse("a3282695-7245-4889-bd29-481c4ff8bf40");
+ var service = new NotificationService(
+ new InMemoryUserRepository(
+ [new User(userId, "ops@example.com", false)]),
+ new RecordingMessageGateway());
+
+ var result = await service.NotifyAsync(userId, "Deploy finished.", cancellationToken);
+
+ AssertEqual(
+ "Notifications disabled",
+ result.Status,
+ "Expected disabled recipients to stop dispatch before gateway calls.");
+ });
+
+ await RunCheckAsync(
+ checks,
+ "Notification dispatch sends a message for an enabled user",
+ async () =>
+ {
+ var userId = Guid.Parse("a97fe9de-2227-4d77-b503-cb6cd42bd0c7");
+ var gateway = new RecordingMessageGateway();
+ var service = new NotificationService(
+ new InMemoryUserRepository(
+ [new User(userId, "alerts@example.com", true)]),
+ gateway);
+
+ var result = await service.NotifyAsync(userId, "Order 7421 approved.", cancellationToken);
+
+ AssertEqual("Sent", result.Status, "Expected enabled users to receive notifications.");
+ AssertEqual(1, gateway.SentMessages.Count, "Expected a single gateway dispatch.");
+ AssertEqual("alerts@example.com", gateway.SentMessages[0].Destination, "Expected the user email to be the destination.");
+ });
+
+ foreach (var check in checks)
+ {
+ string status = check.Passed ? "[PASS]" : "[FAIL]";
+ output.WriteLine($"{status} {check.Name}");
+
+ if (!string.IsNullOrWhiteSpace(check.Detail))
+ output.WriteLine($" {check.Detail}");
+ }
+
+ output.WriteLine();
+ output.WriteLine($"{checks.Count(result => result.Passed)}/{checks.Count} checks passed.");
+
+ return checks.All(result => result.Passed) ? 0 : 1;
+ }
+
+ private static void RunCheck(
+ ICollection checks,
+ string name,
+ Action action)
+ {
+ try
+ {
+ action();
+ checks.Add(new VerificationCheckResult(name, true, null));
+ }
+ catch (Exception exception)
+ {
+ checks.Add(new VerificationCheckResult(name, false, exception.Message));
+ }
+ }
+
+ private static async Task RunCheckAsync(
+ ICollection checks,
+ string name,
+ Func action)
+ {
+ try
+ {
+ await action();
+ checks.Add(new VerificationCheckResult(name, true, null));
+ }
+ catch (Exception exception)
+ {
+ checks.Add(new VerificationCheckResult(name, false, exception.Message));
+ }
+ }
+
+ private static void AssertEqual(T expected, T actual, string message)
+ {
+ if (!EqualityComparer.Default.Equals(expected, actual))
+ throw new InvalidOperationException($"{message} Expected: {expected}; Actual: {actual}.");
+ }
+
+ private static void AssertStartsWith(string expectedPrefix, string actual, string message)
+ {
+ if (!actual.StartsWith(expectedPrefix, StringComparison.Ordinal))
+ throw new InvalidOperationException($"{message} Expected prefix: {expectedPrefix}; Actual: {actual}.");
+ }
+
+ private static TException AssertThrows(Action action)
+ where TException : Exception
+ {
+ try
+ {
+ action();
+ }
+ catch (TException exception)
+ {
+ return exception;
+ }
+
+ throw new InvalidOperationException($"Expected {typeof(TException).Name} to be thrown.");
+ }
+
+ private sealed record VerificationCheckResult(string Name, bool Passed, string? Detail);
+
+ private sealed class RecordingMessageGateway : IMessageGateway
+ {
+ public List SentMessages { get; } = [];
+
+ public Task SendAsync(string destination, string message, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ SentMessages.Add(new SentMessage(destination, message));
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed record SentMessage(string Destination, string Message);
+}