Skip to content

Commit

Permalink
ICosmosDBService dependency injection. Tests for parts of ItemControl…
Browse files Browse the repository at this point in the history
…ler functionality
  • Loading branch information
Torkelsen committed Nov 20, 2024
1 parent b6eb8b3 commit 23ce658
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 35 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace HandlenettAPI.Configurations
{
using System.ComponentModel.DataAnnotations;

public class AzureCosmosDBSettings
{
[Required]
public required string ConnectionString { get; set; }
//required property: Compile time validation
//required attribute: Runtime validation (deserialization, reflection ++ can buypass required property)

[Required]
public required string DatabaseName { get; set; }

[Required]
public required string ContainerName { get; set; }
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
using HandlenettAPI.Services;
using Microsoft.Azure.Cosmos;
using HandlenettAPI.DTO;
using System.Configuration;
using Microsoft.Extensions.Configuration;
using HandlenettAPI.Interfaces;

namespace HandlenettAPI.Controllers
{
Expand All @@ -17,30 +18,12 @@ namespace HandlenettAPI.Controllers
public class ItemController : ControllerBase
{
private readonly ILogger<ItemController> _logger;
private readonly IConfiguration _config;
private readonly CosmosDBService _cosmosDBService;
private readonly ICosmosDBService _cosmosDBService;

public ItemController(ILogger<ItemController> logger, IConfiguration config)
public ItemController(ILogger<ItemController> logger, ICosmosDBService cosmosDBService)
{
try
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_config = config ?? throw new ArgumentNullException(nameof(config));

if (string.IsNullOrEmpty(_config.GetValue<string>("AzureCosmosDBSettings:DatabaseName"))
|| string.IsNullOrEmpty(_config.GetValue<string>("AzureCosmosDBSettings:ContainerName"))
|| string.IsNullOrEmpty(_config.GetValue<string>("AzureCosmosDBSettings:ConnectionString")))
{
throw new ConfigurationErrorsException("Missing CosmosDB config values");
}

_cosmosDBService = new CosmosDBService(new CosmosClient(_config.GetValue<string>("AzureCosmosDBSettings:ConnectionString")), _config.GetValue<string>("AzureCosmosDBSettings:DatabaseName"), _config.GetValue<string>("AzureCosmosDBSettings:ContainerName"));
}
catch (Exception ex)
{
logger.LogError(ex, "Failed in constructor");
throw new Exception("Something went wrong");
}
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_cosmosDBService = cosmosDBService ?? throw new ArgumentNullException(nameof(cosmosDBService));
}

[HttpGet(Name = "GetItems")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using HandlenettAPI.DTO;
using HandlenettAPI.Models;
using Microsoft.AspNetCore.Mvc;

namespace HandlenettAPI.Interfaces
{
public interface ICosmosDBService
{
Task<List<Item>> GetByQuery(string cosmosQuery);
Task<Item> Add(ItemPostDTO item, string username);
Task Delete(string id, string partition);
Item? GetById(string id);
Task<Item> Update(string id, ItemPutDTO item, string username);
}
}
42 changes: 37 additions & 5 deletions handlenett-backend/web-api/HandlenettAPI/Program.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using Azure.Identity;
using HandlenettAPI.Configurations;
using HandlenettAPI.Interfaces;
using HandlenettAPI.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Options;
using Microsoft.Graph;
using Microsoft.Graph.ExternalConnectors;
using Microsoft.Identity.Web;
Expand Down Expand Up @@ -48,30 +52,58 @@
builder.AllowAnyHeader().AllowAnyOrigin().AllowAnyMethod().WithOrigins("http://localhost:3000", "http://localhost:3000");
});
});
builder.Services.AddHttpClient<WeatherService>();

//Inject specific HttpClients
builder.Services.AddHttpClient<WeatherService>();
builder.Services.AddHttpClient("SlackClient", client =>
{
client.BaseAddress = new Uri("https://slack.com/api/");
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
});
builder.Services.AddScoped<SlackService>();

var keyVaultName = builder.Configuration["AzureKeyVaultNameProd"];
if (string.IsNullOrEmpty(keyVaultName))
{
throw new InvalidOperationException("Missing Azure Key Vault configuration: AzureKeyVaultNameProd");
}
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{builder.Configuration["AzureKeyVaultNameProd"]}.vault.azure.net/"),
new Uri($"https://{keyVaultName}.vault.azure.net/"),
new DefaultAzureCredential());
//DefaultAzureCredential() is handled by enabling system assigned identity on container app and creating access policy in kv

//Add strongly-typed configuration with runtime validation
builder.Services.AddOptions<AzureCosmosDBSettings>()
.Bind(builder.Configuration.GetSection("AzureCosmosDBSettings"))
.ValidateDataAnnotations() // Validates [Required] attributes at runtime
.ValidateOnStart(); // Ensures validation happens at application startup


// Add services to the container.
//TODO: null handling errors for config sections
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi()
.AddMicrosoftGraph(builder.Configuration.GetSection("MicrosoftGraph"))
.AddInMemoryTokenCaches();



//Dependency Service Injections
builder.Services.AddScoped<SlackService>();
builder.Services.AddScoped<ICosmosDBService>(provider =>
{
var settings = provider.GetRequiredService<IOptions<AzureCosmosDBSettings>>().Value;

return new CosmosDBService(
new CosmosClient(settings.ConnectionString),
settings.DatabaseName,
settings.ContainerName
);
});

//builder.Services.AddSingleton(graphClient); // A single instance is shared across the entire application lifetime, do not use for databaseconnections (maybe just a static cache)
//TODO: add AzureSQLContext singleton
//builder.Services.AddSingleton(graphClient);



Expand Down Expand Up @@ -115,4 +147,4 @@

ConfigurationHelper.Initialize(app.Configuration);

app.Run();
app.Run();
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace HandlenettAPI.Services
{
public class CosmosDBService//<T> where T : IBase //Lag generisk, støtte for andre enn Item struktur, basert på containerName?
public class CosmosDBService : ICosmosDBService //<T> where T : IBase //Lag generisk, støtte for andre enn Item struktur, basert på containerName?
{
private readonly Container _container;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Azure.Storage.Blobs;
using HandlenettAPI.Models;
using System.Configuration;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
Expand Down Expand Up @@ -33,7 +32,7 @@ public async Task<string> CopyImageToAzureBlobStorage(string oauthToken, SlackUs
{
var containerName = _config.GetValue<string>("AzureStorage:ContainerNameUserImages");
var accountName = _config.GetValue<string>("AzureStorage:AccountName");
if (string.IsNullOrEmpty(containerName) || string.IsNullOrEmpty(accountName)) throw new ConfigurationErrorsException("Missing storage config");
if (string.IsNullOrEmpty(containerName) || string.IsNullOrEmpty(accountName)) throw new InvalidOperationException("Missing storage config");

var blobService = new AzureBlobStorageService(containerName, _config);
await blobService.UploadBlobAsync(slackUser.Id + ".jpg", imageStream);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using HandlenettAPI.DTO;
using Newtonsoft.Json.Linq;
using HandlenettAPI.Models;
using System.Configuration;

namespace HandlenettAPI.Services
{
Expand Down Expand Up @@ -51,7 +50,7 @@ public List<UserDTO> GetUsers()

private string GetAzureBlobStorageUserImageSASToken()
{
var containerName = _config.GetValue<string>("AzureStorage:ContainerNameUserImages") ?? throw new ConfigurationErrorsException("Missing storage config");
var containerName = _config.GetValue<string>("AzureStorage:ContainerNameUserImages") ?? throw new InvalidOperationException("Missing storage config");
var azureService = new AzureBlobStorageService(containerName, _config);
return azureService.GenerateContainerSasToken();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using HandlenettAPI.Models;
using Newtonsoft.Json.Linq;
using StackExchange.Redis;
using System.Configuration;
using System.Text.Json;

namespace HandlenettAPI.Services
Expand All @@ -16,7 +15,7 @@ public WeatherService(HttpClient httpClient, IConfiguration config)
{
_httpClient = httpClient;
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Miles Haugesund Handlenett/1.0 (roger.torkelsen@miles.no)");
var redisConnString = config.GetConnectionString("AzureRedisCache") ?? throw new ConfigurationErrorsException("Missing redis config");
var redisConnString = config.GetConnectionString("AzureRedisCache") ?? throw new InvalidOperationException("Missing redis config");
_redis = ConnectionMultiplexer.Connect(redisConnString);

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
using System.Collections.Generic;
using System.Threading.Tasks;
using HandlenettAPI.Controllers;
using HandlenettAPI.Services;
using Microsoft.Extensions.Configuration;
using HandlenettAPI.Models;
using HandlenettAPI.Interfaces;

namespace HandlenettAPITests.Controllers
{
public class ItemControllerTests
{
private readonly Mock<ILogger<ItemController>> _mockLogger;
private readonly Mock<ICosmosDBService> _mockCosmosDBService;
private readonly ItemController _controller;

//TODO: Add test for config values

public ItemControllerTests()
{
// Set up mocks
_mockLogger = new Mock<ILogger<ItemController>>();
_mockCosmosDBService = new Mock<ICosmosDBService>();

// Create the controller with mocked dependencies
_controller = new ItemController(_mockLogger.Object, _mockCosmosDBService.Object);
}

[Fact]
public async Task Get_ReturnsOkWithItems_WhenItemsExist()
{
// Arrange
var expectedItems = new List<Item>
{
new Item { Id = "1", Name = "Test Item 1" },
new Item { Id = "2", Name = "Test Item 2" }
};

_mockCosmosDBService
.Setup(service => service.GetByQuery(It.IsAny<string>()))
.ReturnsAsync(expectedItems);

// Act
var result = await _controller.Get();

// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result); // Check for 200 OK
var items = Assert.IsAssignableFrom<List<Item>>(okResult.Value); // Ensure correct type
Assert.Equal(expectedItems.Count, items.Count); // Verify returned items match
}

[Fact]
public async Task Get_ReturnsInternalServerError_WhenExceptionIsThrown()
{
// Arrange
_mockCosmosDBService
.Setup(service => service.GetByQuery(It.IsAny<string>()))
.ThrowsAsync(new Exception("Simulated exception"));

// Act
var result = await _controller.Get();

// Assert
var statusCodeResult = Assert.IsType<ObjectResult>(result.Result); // Check for 500
Assert.Equal(500, statusCodeResult.StatusCode); // Verify status code
Assert.NotNull(statusCodeResult.Value); // Ensure response body is not null

// Verify logging
_mockLogger.Verify(
logger => logger.Log(
It.Is<LogLevel>(level => level == LogLevel.Error),
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString().Contains("Failed to get items")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception, string>>()),
Times.Once);
}
}
}

0 comments on commit 23ce658

Please sign in to comment.