From d2a44460ba6bd58224a116b0ee1473ddb99fc1f1 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 13:09:58 +0300 Subject: [PATCH 1/4] refactor(http): use IHttpClientFactory for ProductsService and fix DI config - Replace per-instance HttpClient with typed HttpClient via IHttpClientFactory - Remove BuildServiceProvider anti-pattern; accept IConfiguration in DefaultConfiguration - Inject HttpClient into ProductsService and make JSON deserialization null-safe with logging - Pass builder.Configuration into registration calls in Program.cs --- src/CSharpApp.Api/Program.cs | 4 +-- .../Products/ProductsService.cs | 36 +++++++++++++------ .../Configuration/DefaultConfiguration.cs | 11 ++---- .../Configuration/HttpConfiguration.cs | 24 +++++++++++-- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index b0eb6a0..8d1e35f 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -6,8 +6,8 @@ // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); -builder.Services.AddDefaultConfiguration(); -builder.Services.AddHttpConfiguration(); +builder.Services.AddDefaultConfiguration(builder.Configuration); +builder.Services.AddHttpConfiguration(builder.Configuration); builder.Services.AddProblemDetails(); builder.Services.AddApiVersioning(); diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 0f1d367..91b1cb6 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -6,22 +6,38 @@ public class ProductsService : IProductsService private readonly RestApiSettings _restApiSettings; private readonly ILogger _logger; - public ProductsService(IOptions restApiSettings, + public ProductsService(HttpClient httpClient, IOptions restApiSettings, ILogger logger) { - _httpClient = new HttpClient(); + _httpClient = httpClient; _restApiSettings = restApiSettings.Value; _logger = logger; } public async Task> GetProducts() { - _httpClient.BaseAddress = new Uri(_restApiSettings.BaseUrl!); - var response = await _httpClient.GetAsync(_restApiSettings.Products); - response.EnsureSuccessStatusCode(); - var content = await response.Content.ReadAsStringAsync(); - var res = JsonSerializer.Deserialize>(content); - - return res.AsReadOnly(); + try + { + // If Products path is set, request it; otherwise request root + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; + + var response = await _httpClient.GetAsync(path); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var res = JsonSerializer.Deserialize>(content, options) ?? new List(); + + return res.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching products from remote API"); + throw; + } } -} \ No newline at end of file +} diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs index 376d7cd..f4602d9 100755 --- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs @@ -2,16 +2,11 @@ namespace CSharpApp.Infrastructure.Configuration; public static class DefaultConfiguration { - public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services) + public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services, IConfiguration configuration) { - var serviceProvider = services.BuildServiceProvider(); - var configuration = serviceProvider.GetService(); - - services.Configure(configuration!.GetSection(nameof(RestApiSettings))); + services.Configure(configuration.GetSection(nameof(RestApiSettings))); services.Configure(configuration.GetSection(nameof(HttpClientSettings))); - services.AddSingleton(); - return services; } -} \ No newline at end of file +} diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index b4c5c6e..b5fd89d 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -1,9 +1,29 @@ +using Microsoft.Extensions.Options; + namespace CSharpApp.Infrastructure.Configuration; public static class HttpConfiguration { - public static IServiceCollection AddHttpConfiguration(this IServiceCollection services) + public static IServiceCollection AddHttpConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Register a typed HttpClient for ProductsService using IHttpClientFactory + services.AddHttpClient((sp, client) => + { + var rest = sp.GetRequiredService>().Value; + var httpSettings = sp.GetRequiredService>().Value; + + if (!string.IsNullOrWhiteSpace(rest.BaseUrl)) + { + client.BaseAddress = new Uri(rest.BaseUrl); + } + + // Configure timeout from settings (LifeTime is seconds) + if (httpSettings.LifeTime > 0) + { + client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); + } + }); + return services; } -} \ No newline at end of file +} From 7fdde6dc86174ff68a915f21e383968b63023f47 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 14:05:47 +0300 Subject: [PATCH 2/4] - Add get-by-id and create endpoints + unit tests - Add GetProductById/CreateProduct to IProductsService and ProductsService, exttending API endpoints, and include unit tests - Test project added + ProductService tests added --- src/CSharpApp.Api/GlobalUsings.cs | 4 +- src/CSharpApp.Api/Program.cs | 31 +++++- .../Products/ProductsService.cs | 62 ++++++++++++ .../Interfaces/IProductsService.cs | 2 + src/CSharpApp.sln | 29 +++--- .../CSharpApp.Tests/CSharpApp.Tests.csproj | 26 +++++ .../CSharpApp.Tests/ProductsServiceTests.cs | 97 +++++++++++++++++++ 7 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 src/test/CSharpApp.Tests/CSharpApp.Tests.csproj create mode 100644 src/test/CSharpApp.Tests/ProductsServiceTests.cs diff --git a/src/CSharpApp.Api/GlobalUsings.cs b/src/CSharpApp.Api/GlobalUsings.cs index 4fb0d71..65608b0 100644 --- a/src/CSharpApp.Api/GlobalUsings.cs +++ b/src/CSharpApp.Api/GlobalUsings.cs @@ -2,4 +2,6 @@ global using CSharpApp.Core.Interfaces; global using CSharpApp.Infrastructure.Configuration; -global using Serilog; \ No newline at end of file +global using Serilog; +global using CSharpApp.Core.Dtos; +global using Microsoft.AspNetCore.Http; diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 8d1e35f..3bc1da7 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -23,12 +23,39 @@ var versionedEndpointRouteBuilder = app.NewVersionedApi(); -versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/getproducts", async (IProductsService productsService) => +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products", async (IProductsService productsService) => { var products = await productsService.GetProducts(); - return products; + return Results.Ok(products); }) .WithName("GetProducts") .HasApiVersion(1.0); +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/products/{id}", async (IProductsService productsService, int id, CancellationToken ct) => + { + var product = await productsService.GetProductById(id, ct); + return product is null ? Results.NotFound() : Results.Ok(product); + }) + .WithName("GetProductById") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/products", async (IProductsService productsService, Product product, HttpContext http, CancellationToken ct) => + { + if (product == null) + return Results.BadRequest(); + + // Basic validation + if (string.IsNullOrWhiteSpace(product.Title) || (product.Price.HasValue && product.Price <= 0)) + return Results.BadRequest("Invalid product payload"); + + var created = await productsService.CreateProduct(product, ct); + if (created == null) + return Results.StatusCode(StatusCodes.Status502BadGateway); + + var location = $"/api/v1/products/{created.Id}"; + return Results.Created(location, created); + }) + .WithName("CreateProduct") + .HasApiVersion(1.0); + app.Run(); \ No newline at end of file diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 91b1cb6..57a8e9d 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -14,6 +14,68 @@ public ProductsService(HttpClient httpClient, IOptions restApiS _logger = logger; } + public async Task GetProductById(int id, CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? $"{id}" : $"{_restApiSettings.Products}/{id}"; + + var response = await _httpClient.GetAsync(path, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var product = JsonSerializer.Deserialize(content, options); + + return product; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching product {ProductId} from remote API", id); + throw; + } + } + + public async Task CreateProduct(Product product, CancellationToken cancellationToken = default) + { + try + { + // Basic validation + if (product is null) + throw new ArgumentNullException(nameof(product)); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var json = JsonSerializer.Serialize(product, options); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var path = string.IsNullOrWhiteSpace(_restApiSettings.Products) ? string.Empty : _restApiSettings.Products; + + var response = await _httpClient.PostAsync(path, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to create product. StatusCode: {StatusCode}", response.StatusCode); + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var created = JsonSerializer.Deserialize(responseContent, options); + + return created ?? product; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating product"); + throw; + } + } + public async Task> GetProducts() { try diff --git a/src/CSharpApp.Core/Interfaces/IProductsService.cs b/src/CSharpApp.Core/Interfaces/IProductsService.cs index 5f321f2..715f8e5 100644 --- a/src/CSharpApp.Core/Interfaces/IProductsService.cs +++ b/src/CSharpApp.Core/Interfaces/IProductsService.cs @@ -3,4 +3,6 @@ namespace CSharpApp.Core.Interfaces; public interface IProductsService { Task> GetProducts(); + Task GetProductById(int id, CancellationToken cancellationToken = default); + Task CreateProduct(Product product, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/CSharpApp.sln b/src/CSharpApp.sln index 62efd41..6382d09 100644 --- a/src/CSharpApp.sln +++ b/src/CSharpApp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11822.322 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{09E4065D-8EA9-43F4-AB9C-5E70ACC35F88}" EndProject @@ -15,20 +15,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Core", "CSharpApp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Infrastructure", "CSharpApp.Infrastructure\CSharpApp.Infrastructure.csproj", "{1D24449A-3896-48C5-B007-A33F8479456C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpApp.Tests", "test\CSharpApp.Tests\CSharpApp.Tests.csproj", "{FA0E527F-0190-B11D-0A5E-4007C794BEA2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {E3BB3FDA-5F80-48B0-B0A5-B29F89814132} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {5AEEC8AB-02B6-4051-A6EF-B785FC773E87} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {75D8AC89-79D6-4C05-88C5-E2BC6445F202} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - {1D24449A-3896-48C5-B007-A33F8479456C} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {E3BB3FDA-5F80-48B0-B0A5-B29F89814132}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E3BB3FDA-5F80-48B0-B0A5-B29F89814132}.Debug|Any CPU.Build.0 = Debug|Any CPU @@ -46,5 +39,19 @@ Global {1D24449A-3896-48C5-B007-A33F8479456C}.Debug|Any CPU.Build.0 = Debug|Any CPU {1D24449A-3896-48C5-B007-A33F8479456C}.Release|Any CPU.ActiveCfg = Release|Any CPU {1D24449A-3896-48C5-B007-A33F8479456C}.Release|Any CPU.Build.0 = Release|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA0E527F-0190-B11D-0A5E-4007C794BEA2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E3BB3FDA-5F80-48B0-B0A5-B29F89814132} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {5AEEC8AB-02B6-4051-A6EF-B785FC773E87} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {75D8AC89-79D6-4C05-88C5-E2BC6445F202} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {1D24449A-3896-48C5-B007-A33F8479456C} = {09E4065D-8EA9-43F4-AB9C-5E70ACC35F88} + {FA0E527F-0190-B11D-0A5E-4007C794BEA2} = {AEEC3AD8-B5EE-4590-8714-024364B7557A} EndGlobalSection EndGlobal diff --git a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj new file mode 100644 index 0000000..faa214f --- /dev/null +++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + false + + + + + + all + + + + + + + + + + + + + + + diff --git a/src/test/CSharpApp.Tests/ProductsServiceTests.cs b/src/test/CSharpApp.Tests/ProductsServiceTests.cs new file mode 100644 index 0000000..91301b3 --- /dev/null +++ b/src/test/CSharpApp.Tests/ProductsServiceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class ProductsServiceTests +{ + [Fact] + public async Task GetProductById_ReturnsProduct_WhenFound() + { + // Arrange + var productJson = "{ \"id\": 1, \"title\": \"Test\", \"price\": 100 }"; + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(productJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new System.Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Products = "products" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Products.ProductsService(httpClient, options, logger); + + // Act + var result = await svc.GetProductById(1, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result!.Id); + Assert.Equal("Test", result.Title); + } + + [Fact] + public async Task CreateProduct_ReturnsCreatedProduct_WhenSuccess() + { + // Arrange + var product = new CSharpApp.Core.Dtos.Product { Title = "New", Price = 50 }; + var responseJson = "{ \"id\": 10, \"title\": \"New\", \"price\": 50 }"; + + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(responseJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new System.Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Products = "products" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Products.ProductsService(httpClient, options, logger); + + // Act + var created = await svc.CreateProduct(product, CancellationToken.None); + + // Assert + Assert.NotNull(created); + Assert.Equal(10, created!.Id); + Assert.Equal("New", created.Title); + } +} + +// Helper stub handler +internal class DelegatingHandlerStub : DelegatingHandler +{ + private readonly Func> _responder; + + public DelegatingHandlerStub(Func> responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _responder.Invoke(request, cancellationToken); + } +} From d178c8811390e2d4982b88f7082859ee9f2064e2 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 14:57:45 +0300 Subject: [PATCH 3/4] - Add categories service, endpoints and unit tests --- src/CSharpApp.Api/Program.cs | 35 +++++++ .../Categories/CategoriesService.cs | 99 +++++++++++++++++++ .../Products/ProductsService.cs | 2 +- src/CSharpApp.Core/CSharpApp.Core.csproj | 1 - .../Interfaces/ICategoriesService.cs | 8 ++ .../Configuration/DefaultConfiguration.cs | 1 + .../Configuration/HttpConfiguration.cs | 22 +++++ .../CSharpApp.Tests/CategoriesServiceTests.cs | 81 +++++++++++++++ 8 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/CSharpApp.Application/Categories/CategoriesService.cs create mode 100644 src/CSharpApp.Core/Interfaces/ICategoriesService.cs create mode 100644 src/test/CSharpApp.Tests/CategoriesServiceTests.cs diff --git a/src/CSharpApp.Api/Program.cs b/src/CSharpApp.Api/Program.cs index 3bc1da7..2d13953 100644 --- a/src/CSharpApp.Api/Program.cs +++ b/src/CSharpApp.Api/Program.cs @@ -58,4 +58,39 @@ .WithName("CreateProduct") .HasApiVersion(1.0); +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService) => + { + var categories = await categoriesService.GetCategories(); + return Results.Ok(categories); + }) + .WithName("GetCategories") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapGet("api/v{version:apiVersion}/categories/{id}", async (ICategoriesService categoriesService, int id, CancellationToken ct) => + { + var category = await categoriesService.GetCategoryById(id, ct); + return category is null ? Results.NotFound() : Results.Ok(category); + }) + .WithName("GetCategoryById") + .HasApiVersion(1.0); + +versionedEndpointRouteBuilder.MapPost("api/v{version:apiVersion}/categories", async (ICategoriesService categoriesService, Category category, HttpContext http, CancellationToken ct) => + { + if (category == null) + return Results.BadRequest(); + + // Basic validation + if (string.IsNullOrWhiteSpace(category.Name)) + return Results.BadRequest("Invalid category payload"); + + var created = await categoriesService.CreateCategory(category, ct); + if (created == null) + return Results.StatusCode(StatusCodes.Status502BadGateway); + + var location = $"/api/v1/categories/{created.Id}"; + return Results.Created(location, created); + }) + .WithName("CreateCategory") + .HasApiVersion(1.0); + app.Run(); \ No newline at end of file diff --git a/src/CSharpApp.Application/Categories/CategoriesService.cs b/src/CSharpApp.Application/Categories/CategoriesService.cs new file mode 100644 index 0000000..f76a7ae --- /dev/null +++ b/src/CSharpApp.Application/Categories/CategoriesService.cs @@ -0,0 +1,99 @@ +namespace CSharpApp.Application.Categories; + +public class CategoriesService : ICategoriesService +{ + private readonly HttpClient _httpClient; + private readonly RestApiSettings _restApiSettings; + private readonly ILogger _logger; + + public CategoriesService(HttpClient httpClient, IOptions restApiSettings, + ILogger logger) + { + _httpClient = httpClient; + _restApiSettings = restApiSettings.Value; + _logger = logger; + } + + public async Task> GetCategories(CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? string.Empty : _restApiSettings.Categories; + + var response = await _httpClient.GetAsync(path, cancellationToken); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var res = JsonSerializer.Deserialize>(content, options) ?? new List(); + + return res.AsReadOnly(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching categories from remote API"); + throw; + } + } + + public async Task GetCategoryById(int id, CancellationToken cancellationToken = default) + { + try + { + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? $"{id}" : $"{_restApiSettings.Categories}/{id}"; + + var response = await _httpClient.GetAsync(path, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var category = JsonSerializer.Deserialize(content, options); + + return category; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching category {CategoryId} from remote API", id); + throw; + } + } + + public async Task CreateCategory(Category category, CancellationToken cancellationToken = default) + { + try + { + if (category is null) + throw new ArgumentNullException(nameof(category)); + + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var json = JsonSerializer.Serialize(category, options); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + var path = string.IsNullOrWhiteSpace(_restApiSettings.Categories) ? string.Empty : _restApiSettings.Categories; + + var response = await _httpClient.PostAsync(path, content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to create category. StatusCode: {StatusCode}", response.StatusCode); + return null; + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var created = JsonSerializer.Deserialize(responseContent, options); + + return created ?? category; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating category"); + throw; + } + } +} diff --git a/src/CSharpApp.Application/Products/ProductsService.cs b/src/CSharpApp.Application/Products/ProductsService.cs index 57a8e9d..e33fc44 100644 --- a/src/CSharpApp.Application/Products/ProductsService.cs +++ b/src/CSharpApp.Application/Products/ProductsService.cs @@ -102,4 +102,4 @@ public async Task> GetProducts() throw; } } -} +} \ No newline at end of file diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index f362f27..916e140 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -8,7 +8,6 @@ - diff --git a/src/CSharpApp.Core/Interfaces/ICategoriesService.cs b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs new file mode 100644 index 0000000..d661c7b --- /dev/null +++ b/src/CSharpApp.Core/Interfaces/ICategoriesService.cs @@ -0,0 +1,8 @@ +namespace CSharpApp.Core.Interfaces; + +public interface ICategoriesService +{ + Task> GetCategories(CancellationToken cancellationToken = default); + Task GetCategoryById(int id, CancellationToken cancellationToken = default); + Task CreateCategory(Category category, CancellationToken cancellationToken = default); +} diff --git a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs index f4602d9..6d53238 100755 --- a/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/DefaultConfiguration.cs @@ -4,6 +4,7 @@ public static class DefaultConfiguration { public static IServiceCollection AddDefaultConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Bind configuration sections to options without building a temporary provider services.Configure(configuration.GetSection(nameof(RestApiSettings))); services.Configure(configuration.GetSection(nameof(HttpClientSettings))); diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index b5fd89d..c201b6f 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -1,4 +1,9 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using CSharpApp.Application.Categories; +using CSharpApp.Application.Products; namespace CSharpApp.Infrastructure.Configuration; @@ -24,6 +29,23 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se } }); + // Register typed client for categories + services.AddHttpClient((sp, client) => + { + var rest = sp.GetRequiredService>().Value; + var httpSettings = sp.GetRequiredService>().Value; + + if (!string.IsNullOrWhiteSpace(rest.BaseUrl)) + { + client.BaseAddress = new Uri(rest.BaseUrl); + } + + if (httpSettings.LifeTime > 0) + { + client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); + } + }); + return services; } } diff --git a/src/test/CSharpApp.Tests/CategoriesServiceTests.cs b/src/test/CSharpApp.Tests/CategoriesServiceTests.cs new file mode 100644 index 0000000..e271bf2 --- /dev/null +++ b/src/test/CSharpApp.Tests/CategoriesServiceTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class CategoriesServiceTests +{ + [Fact] + public async Task GetCategoryById_ReturnsCategory_WhenFound() + { + // Arrange + var categoryJson = "{ \"id\": 2, \"name\": \"Electronics\", \"image\": \"/img.png\" }"; + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(categoryJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Categories = "categories" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Categories.CategoriesService(httpClient, options, logger); + + // Act + var result = await svc.GetCategoryById(2, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result!.Id); + Assert.Equal("Electronics", result.Name); + } + + [Fact] + public async Task CreateCategory_ReturnsCreatedCategory_WhenSuccess() + { + // Arrange + var category = new CSharpApp.Core.Dtos.Category { Name = "Books", Image = "/books.png" }; + var responseJson = "{ \"id\": 20, \"name\": \"Books\", \"image\": \"/books.png\" }"; + + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.Created) + { + Content = new StringContent(responseJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.test/") + }; + + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Categories = "categories" }); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Application.Categories.CategoriesService(httpClient, options, logger); + + // Act + var created = await svc.CreateCategory(category, CancellationToken.None); + + // Assert + Assert.NotNull(created); + Assert.Equal(20, created!.Id); + Assert.Equal("Books", created.Name); + } +} From 6b4ed2e15f28e403455a7b1221b03790ac4a2c77 Mon Sep 17 00:00:00 2001 From: Konstantinos Georgomitros Date: Sun, 5 Jul 2026 15:46:44 +0300 Subject: [PATCH 4/4] Add JWT token support for third-party API - Add TokenService, AuthenticationDelegatingHandler, DI wiring and memory cache - Attach Bearer token to typed HttpClients and add unit tests (token + handler) --- src/CSharpApp.Core/CSharpApp.Core.csproj | 1 - src/CSharpApp.Core/Dtos/TokenResponse.cs | 8 ++ .../Interfaces/ITokenService.cs | 6 + .../AuthenticationDelegatingHandler.cs | 57 +++++++++ .../Authentication/TokenService.cs | 76 ++++++++++++ .../CSharpApp.Infrastructure.csproj | 3 + .../Configuration/HttpConfiguration.cs | 13 +- .../AuthenticationDelegatingHandlerTests.cs | 112 ++++++++++++++++++ .../CSharpApp.Tests/CSharpApp.Tests.csproj | 1 + src/test/CSharpApp.Tests/TokenServiceTests.cs | 68 +++++++++++ 10 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 src/CSharpApp.Core/Dtos/TokenResponse.cs create mode 100644 src/CSharpApp.Core/Interfaces/ITokenService.cs create mode 100644 src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs create mode 100644 src/CSharpApp.Infrastructure/Authentication/TokenService.cs create mode 100644 src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs create mode 100644 src/test/CSharpApp.Tests/TokenServiceTests.cs diff --git a/src/CSharpApp.Core/CSharpApp.Core.csproj b/src/CSharpApp.Core/CSharpApp.Core.csproj index 916e140..b37ec90 100644 --- a/src/CSharpApp.Core/CSharpApp.Core.csproj +++ b/src/CSharpApp.Core/CSharpApp.Core.csproj @@ -7,7 +7,6 @@ - diff --git a/src/CSharpApp.Core/Dtos/TokenResponse.cs b/src/CSharpApp.Core/Dtos/TokenResponse.cs new file mode 100644 index 0000000..7e2431f --- /dev/null +++ b/src/CSharpApp.Core/Dtos/TokenResponse.cs @@ -0,0 +1,8 @@ +namespace CSharpApp.Core.Dtos; + +public sealed class TokenResponse +{ + public string? access_token { get; set; } + public int? expires_in { get; set; } + public string? token_type { get; set; } +} diff --git a/src/CSharpApp.Core/Interfaces/ITokenService.cs b/src/CSharpApp.Core/Interfaces/ITokenService.cs new file mode 100644 index 0000000..d777c69 --- /dev/null +++ b/src/CSharpApp.Core/Interfaces/ITokenService.cs @@ -0,0 +1,6 @@ +namespace CSharpApp.Core.Interfaces; + +public interface ITokenService +{ + Task GetTokenAsync(CancellationToken cancellationToken = default); +} diff --git a/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs b/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs new file mode 100644 index 0000000..05fa5c0 --- /dev/null +++ b/src/CSharpApp.Infrastructure/Authentication/AuthenticationDelegatingHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using CSharpApp.Core.Interfaces; + +namespace CSharpApp.Infrastructure.Authentication; + +public class AuthenticationDelegatingHandler : DelegatingHandler +{ + private readonly ITokenService _tokenService; + private readonly ILogger _logger; + + public AuthenticationDelegatingHandler(ITokenService tokenService, ILogger logger) + { + _tokenService = tokenService; + _logger = logger; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string token; + try + { + token = await _tokenService.GetTokenAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to acquire token"); + throw; + } + + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + // try refresh once + _logger.LogInformation("Received 401, attempting token refresh and retry"); + try + { + token = await _tokenService.GetTokenAsync(cancellationToken); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + response = await base.SendAsync(request, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Token refresh failed"); + throw; + } + } + + return response; + } +} diff --git a/src/CSharpApp.Infrastructure/Authentication/TokenService.cs b/src/CSharpApp.Infrastructure/Authentication/TokenService.cs new file mode 100644 index 0000000..8a40f7e --- /dev/null +++ b/src/CSharpApp.Infrastructure/Authentication/TokenService.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using CSharpApp.Core.Dtos; +using CSharpApp.Core.Interfaces; + +namespace CSharpApp.Infrastructure.Authentication; + +public class TokenService : ITokenService +{ + private readonly HttpClient _httpClient; + private readonly RestApiSettings _restApiSettings; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + private const string CacheKey = "ThirdPartyAccessToken"; + + public TokenService(HttpClient httpClient, IOptions restApiSettings, + IMemoryCache cache, ILogger logger) + { + _httpClient = httpClient; + _restApiSettings = restApiSettings.Value; + _cache = cache; + _logger = logger; + } + + public async Task GetTokenAsync(CancellationToken cancellationToken = default) + { + if (_cache.TryGetValue(CacheKey, out var token) && !string.IsNullOrWhiteSpace(token)) + { + return token; + } + + // Acquire token + if (string.IsNullOrWhiteSpace(_restApiSettings.Auth) || string.IsNullOrWhiteSpace(_restApiSettings.Username) || string.IsNullOrWhiteSpace(_restApiSettings.Password)) + { + _logger.LogError("Auth settings are not configured properly"); + throw new InvalidOperationException("Auth settings are not configured"); + } + + var payload = new { username = _restApiSettings.Username, password = _restApiSettings.Password }; + var json = JsonSerializer.Serialize(payload); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync(_restApiSettings.Auth, content, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to acquire token from auth endpoint. StatusCode: {StatusCode}", response.StatusCode); + throw new InvalidOperationException("Failed to acquire token from auth endpoint"); + } + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var tokenResponse = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (tokenResponse == null || string.IsNullOrWhiteSpace(tokenResponse.access_token)) + { + _logger.LogError("Auth endpoint returned invalid token response: {Response}", responseContent); + throw new InvalidOperationException("Invalid token response from auth endpoint"); + } + + var expiresIn = tokenResponse.expires_in ?? 3600; + var cacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(Math.Max(60, expiresIn - 60)) + }; + + _cache.Set(CacheKey, tokenResponse.access_token, cacheEntryOptions); + + return tokenResponse.access_token; + } +} diff --git a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj index be99053..f8207d2 100644 --- a/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj +++ b/src/CSharpApp.Infrastructure/CSharpApp.Infrastructure.csproj @@ -12,6 +12,9 @@ + + + diff --git a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs index c201b6f..84450d3 100755 --- a/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs +++ b/src/CSharpApp.Infrastructure/Configuration/HttpConfiguration.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Options; using CSharpApp.Application.Categories; using CSharpApp.Application.Products; +using CSharpApp.Infrastructure.Authentication; +using CSharpApp.Core.Interfaces; namespace CSharpApp.Infrastructure.Configuration; @@ -11,6 +13,11 @@ public static class HttpConfiguration { public static IServiceCollection AddHttpConfiguration(this IServiceCollection services, IConfiguration configuration) { + // Register token service, memory cache and authentication handler + services.AddMemoryCache(); + services.AddTransient(); + services.AddTransient(); + // Register a typed HttpClient for ProductsService using IHttpClientFactory services.AddHttpClient((sp, client) => { @@ -27,7 +34,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se { client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } - }); + }) + .AddHttpMessageHandler(); // Register typed client for categories services.AddHttpClient((sp, client) => @@ -44,7 +52,8 @@ public static IServiceCollection AddHttpConfiguration(this IServiceCollection se { client.Timeout = TimeSpan.FromSeconds(httpSettings.LifeTime); } - }); + }) + .AddHttpMessageHandler(); return services; } diff --git a/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs b/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs new file mode 100644 index 0000000..4bc876f --- /dev/null +++ b/src/test/CSharpApp.Tests/AuthenticationDelegatingHandlerTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using System.Collections.Generic; +using Xunit; + +namespace CSharpApp.Tests; + +public class AuthenticationDelegatingHandlerTests +{ + [Fact] + public async Task AddsAuthorizationHeader_WhenTokenAvailable() + { + // Arrange + var fakeTokenService = new FakeTokenService(() => Task.FromResult("token1")); + + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + Assert.True(request.Headers.Authorization != null); + Assert.Equal("Bearer", request.Headers.Authorization.Scheme); + Assert.Equal("token1", request.Headers.Authorization.Parameter); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act + var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task RetriesOn401_RefreshesToken() + { + // Arrange + var tokens = new Queue(new[] { "token1", "token2" }); + var fakeTokenService = new FakeTokenService(() => Task.FromResult(tokens.Dequeue())); + + var call = 0; + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + call++; + if (call == 1) + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); + + Assert.Equal("token2", request.Headers.Authorization.Parameter); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act + var response = await invoker.SendAsync(request, CancellationToken.None); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, call); + } + + [Fact] + public async Task PropagatesException_WhenTokenServiceFails() + { + // Arrange + var fakeTokenService = new FakeTokenService(() => throw new InvalidOperationException("fail")); + + var innerHandler = new DelegatingHandlerStub((request, ct) => + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + + var authHandler = new CSharpApp.Infrastructure.Authentication.AuthenticationDelegatingHandler(fakeTokenService, NullLogger.Instance) + { + InnerHandler = innerHandler + }; + + var invoker = new HttpMessageInvoker(authHandler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.test/"); + + // Act & Assert + await Assert.ThrowsAsync(() => invoker.SendAsync(request, CancellationToken.None)); + } +} + +internal class FakeTokenService : CSharpApp.Core.Interfaces.ITokenService +{ + private readonly Func> _responder; + + public FakeTokenService(Func> responder) + { + _responder = responder; + } + + public Task GetTokenAsync(CancellationToken cancellationToken = default) => _responder(); +} diff --git a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj index faa214f..5223399 100644 --- a/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj +++ b/src/test/CSharpApp.Tests/CSharpApp.Tests.csproj @@ -16,6 +16,7 @@ + diff --git a/src/test/CSharpApp.Tests/TokenServiceTests.cs b/src/test/CSharpApp.Tests/TokenServiceTests.cs new file mode 100644 index 0000000..0eabd7c --- /dev/null +++ b/src/test/CSharpApp.Tests/TokenServiceTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace CSharpApp.Tests; + +public class TokenServiceTests +{ + [Fact] + public async Task AcquireToken_ReturnsTokenAndCaches_WhenAuthSuccess() + { + // Arrange + var tokenJson = "{ \"access_token\": \"abc123\", \"expires_in\": 3600 }"; + var callCount = 0; + var handler = new DelegatingHandlerStub((request, ct) => + { + callCount++; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(tokenJson) + }; + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://auth.test/") }; + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Auth = "/auth/login", Username = "u", Password = "p" }); + var cache = new MemoryCache(new MemoryCacheOptions()); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Infrastructure.Authentication.TokenService(httpClient, options, cache, logger); + + // Act + var token1 = await svc.GetTokenAsync(CancellationToken.None); + var token2 = await svc.GetTokenAsync(CancellationToken.None); + + // Assert + Assert.Equal("abc123", token1); + Assert.Equal("abc123", token2); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AcquireToken_Throws_OnNonSuccessStatusCode() + { + // Arrange + var handler = new DelegatingHandlerStub((request, ct) => + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest); + return Task.FromResult(response); + }); + + var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://auth.test/") }; + var options = Options.Create(new CSharpApp.Core.Settings.RestApiSettings { Auth = "/auth/login", Username = "u", Password = "p" }); + var cache = new MemoryCache(new MemoryCacheOptions()); + var logger = NullLogger.Instance; + + var svc = new CSharpApp.Infrastructure.Authentication.TokenService(httpClient, options, cache, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => svc.GetTokenAsync(CancellationToken.None)); + } +}