From 2b015da905ba7e3d570e6ac55b020d7d5a04d6a3 Mon Sep 17 00:00:00 2001 From: "Basani, Sahith" Date: Sun, 28 Jun 2026 21:15:14 -0500 Subject: [PATCH 1/2] chore: add CI workflow and repository polish --- .github/workflows/build.yml | 37 ++++++++++++++++++++++++++ .github/workflows/markdown-links.yml | 39 ++++++++++++++++++++++++++++ README.md | 15 ++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/markdown-links.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e032a77 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +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 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..3857c20 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@
C# engineering reference + [![Build](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/build.yml/badge.svg)](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/build.yml) + [![Markdown Links](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/markdown-links.yml/badge.svg)](https://github.com/Sahithbasani/csharp-fundamentals/actions/workflows/markdown-links.yml) [![C#](https://img.shields.io/badge/C%23-engineering-512BD4?logo=csharp&logoColor=white)](https://learn.microsoft.com/dotnet/csharp/) [![.NET](https://img.shields.io/badge/.NET-10-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) [![Modules](https://img.shields.io/badge/modules-10-0F766E)](#module-index) - [![License](https://img.shields.io/badge/license-MIT-2563EB)](./LICENSE) + [![License](https://img.shields.io/github/license/Sahithbasani/csharp-fundamentals?color=2563EB)](./LICENSE)
# C# Engineering Fundamentals @@ -16,6 +18,7 @@ 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) @@ -34,6 +37,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 and builds the solution in Release mode. +- **Strict compilation:** CI treats compiler warnings as errors to prevent warning debt from entering the stable branch. +- **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. +- **Testing policy:** no automated test project exists yet; when one is introduced, its test job should become a required pull-request check. + ## Module Index | Module | Engineering area | Code examples | From b5b1275bb730d4d5617215a66702ab938f16a57c Mon Sep 17 00:00:00 2001 From: "Basani, Sahith" Date: Mon, 6 Jul 2026 22:57:24 -0500 Subject: [PATCH 2/2] feat: add executable example verification --- .github/workflows/build.yml | 9 + README.md | 48 ++-- examples/README.md | 28 ++- .../Examples/NotificationDispatchExample.cs | 61 ----- .../Examples/NotificationDispatchScenario.cs | 62 +++++ .../Examples/OrderProcessingExample.cs | 40 +--- .../Examples/OrderProcessingScenario.cs | 43 ++++ .../Examples/PaymentValidationExample.cs | 44 +--- .../Examples/PaymentValidationScenario.cs | 44 ++++ .../CSharpFundamentals/Program.cs | 6 +- .../Verification/ExampleVerificationRunner.cs | 225 ++++++++++++++++++ 11 files changed, 435 insertions(+), 175 deletions(-) create mode 100644 src/CSharpFundamentals/CSharpFundamentals/Examples/NotificationDispatchScenario.cs create mode 100644 src/CSharpFundamentals/CSharpFundamentals/Examples/OrderProcessingScenario.cs create mode 100644 src/CSharpFundamentals/CSharpFundamentals/Examples/PaymentValidationScenario.cs create mode 100644 src/CSharpFundamentals/CSharpFundamentals/Verification/ExampleVerificationRunner.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e032a77..8666b59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,3 +35,12 @@ jobs: - 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/README.md b/README.md index 3857c20..2d76394 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ This repository is not organized as a syntax tutorial. Each module is an enginee - [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) @@ -41,11 +42,11 @@ flowchart LR 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 and builds the solution in Release mode. +- **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. -- **Testing policy:** no automated test project exists yet; when one is introduced, its test job should become a required pull-request check. ## Module Index @@ -66,21 +67,22 @@ Repository quality is enforced through small, reviewable changes and automated v 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). @@ -92,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); +}