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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/Build-Test-And-Deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ jobs:
ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }}
TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }}
with:
inlineScript: |
az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }}
Expand All @@ -157,7 +158,8 @@ jobs:
AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
TryDotNet__Origin=$TRYDOTNET_ORIGIN

- name: Logout of Azure CLI
if: always()
Expand Down Expand Up @@ -230,6 +232,7 @@ jobs:
ACR_USERNAME: ${{ secrets.ESSENTIALCSHARP_ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ESSENTIALCSHARP_ACR_PASSWORD }}
AZURECLIENTID: ${{ secrets.IDENTITY_CLIENT_ID }}
TRYDOTNET_ORIGIN: ${{ vars.TRYDOTNET_ORIGIN }}
with:
inlineScript: |
az containerapp identity assign -n ${{ vars.CONTAINER_APP_NAME }} -g ${{ vars.RESOURCEGROUP }} --user-assigned ${{ vars.CONTAINER_APP_IDENTITY }}
Expand All @@ -249,7 +252,8 @@ jobs:
AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \
AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \
AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring
AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \
TryDotNet__Origin=$TRYDOTNET_ORIGIN


- name: Logout of Azure CLI
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ wwwroot/Chapters
EssentialCSharp.Web/wwwroot/Chapters
EssentialCSharp.Web/wwwroot/sitemap.xml
EssentialCSharp.Web/Chapters/
EssentialCSharp.Web/ListingSourceCode
Utilities/EssentialCSharp.Web/Chapters/
Utilities/EssentialCSharp.Web/wwwroot/sitemap.xml
Utilities/EssentialCSharp.Web/wwwroot/Chapters/
Expand Down
12 changes: 12 additions & 0 deletions EssentialCSharp.Web.Tests/EssentialCSharp.Web.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Keboo is always sad to not see AutoMoq ;)

<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
Expand All @@ -35,4 +36,15 @@
<ProjectReference Include="..\EssentialCSharp.Web\EssentialCSharp.Web.csproj" />
</ItemGroup>

<ItemGroup>
<!-- Exclude test data files from compilation -->
<Compile Remove="TestData/**" />
<EmbeddedResource Remove="TestData/**" />

<!-- Explicitly include all test data files as content to copy to output -->
<Content Include="TestData/**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

</Project>
107 changes: 107 additions & 0 deletions EssentialCSharp.Web.Tests/ListingSourceCodeControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using System.Net;
using System.Net.Http.Json;
using EssentialCSharp.Web.Models;

namespace EssentialCSharp.Web.Tests;

public class ListingSourceCodeControllerTests
{
[Fact]
public async Task GetListing_WithValidChapterAndListing_Returns200WithContent()
{
// Arrange
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

// Act
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/1");

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

ListingSourceCodeResponse? result = await response.Content.ReadFromJsonAsync<ListingSourceCodeResponse>();
Assert.NotNull(result);
Assert.Equal(1, result.ChapterNumber);
Assert.Equal(1, result.ListingNumber);
Assert.NotEmpty(result.FileExtension);
Assert.NotEmpty(result.Content);
}


[Fact]
public async Task GetListing_WithInvalidChapter_Returns404()
{
// Arrange
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

// Act
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999/1");

// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task GetListing_WithInvalidListing_Returns404()
{
// Arrange
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

// Act
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1/999");

// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task GetListingsByChapter_WithValidChapter_ReturnsMultipleListings()
{
// Arrange
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

// Act
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/1");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The api seems a bit arbitrary from the outside of /1/1 for example, maybe more like /chapter/1/listing/3 is clearer? @Keboo thoughts on this? Maybe it's not necessary since we only use it and it's the advent of ai

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I think adding in a bit more context into the URL would be nice, just from a debugging perspective when looking at the logs.


// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

List<ListingSourceCodeResponse>? results = await response.Content.ReadFromJsonAsync<List<ListingSourceCodeResponse>>();
Assert.NotNull(results);
Assert.NotEmpty(results);

// Verify all results are from chapter 1
Assert.All(results, r => Assert.Equal(1, r.ChapterNumber));

// Verify results are ordered by listing number
Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results);

// Verify each listing has required properties
Assert.All(results, r =>
{
Assert.NotEmpty(r.FileExtension);
Assert.NotEmpty(r.Content);
});
}

[Fact]
public async Task GetListingsByChapter_WithInvalidChapter_ReturnsEmptyList()
{
// Arrange
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

// Act
using HttpResponseMessage response = await client.GetAsync("/api/ListingSourceCode/999");

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

List<ListingSourceCodeResponse>? results = await response.Content.ReadFromJsonAsync<List<ListingSourceCodeResponse>>();
Assert.NotNull(results);
Assert.Empty(results);
}
}
147 changes: 147 additions & 0 deletions EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Moq;

namespace EssentialCSharp.Web.Tests;

public class ListingSourceCodeServiceTests
{
[Fact]
public async Task GetListingAsync_WithValidChapterAndListing_ReturnsCorrectListing()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act
ListingSourceCodeResponse? result = await service.GetListingAsync(1, 1);

// Assert
Assert.NotNull(result);
Assert.Equal(1, result.ChapterNumber);
Assert.Equal(1, result.ListingNumber);
Assert.Equal("cs", result.FileExtension);
Assert.NotEmpty(result.Content);
}

[Fact]
public async Task GetListingAsync_WithInvalidChapter_ReturnsNull()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act
ListingSourceCodeResponse? result = await service.GetListingAsync(999, 1);

// Assert
Assert.Null(result);
}

[Fact]
public async Task GetListingAsync_WithInvalidListing_ReturnsNull()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act
ListingSourceCodeResponse? result = await service.GetListingAsync(1, 999);

// Assert
Assert.Null(result);
}

[Fact]
public async Task GetListingAsync_DifferentFileExtension_AutoDiscoversFileExtension()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act - Get an XML file (01.02.xml exists in Chapter 1)
ListingSourceCodeResponse? result = await service.GetListingAsync(1, 2);

// Assert
Assert.NotNull(result);
Assert.Equal("xml", result.FileExtension);
}

[Fact]
public async Task GetListingsByChapterAsync_WithValidChapter_ReturnsAllListings()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act
IReadOnlyList<ListingSourceCodeResponse> results = await service.GetListingsByChapterAsync(1);

// Assert
Assert.NotEmpty(results);
Assert.All(results, r => Assert.Equal(1, r.ChapterNumber));
Assert.All(results, r => Assert.NotEmpty(r.Content));
Assert.All(results, r => Assert.NotEmpty(r.FileExtension));

// Verify results are ordered
Assert.Equal(results.OrderBy(r => r.ListingNumber).ToList(), results);
}

[Fact]
public async Task GetListingsByChapterAsync_DirectoryContainsNonListingFiles_ExcludesNonListingFiles()
{
// Arrange - Chapter 10 has Employee.cs which doesn't match the pattern
ListingSourceCodeService service = CreateService();

// Act
IReadOnlyList<ListingSourceCodeResponse> results = await service.GetListingsByChapterAsync(10);

// Assert
Assert.NotEmpty(results);

// Ensure all results match the {CC}.{LL}.{ext} pattern
Assert.All(results, r =>
{
Assert.Equal(10, r.ChapterNumber);
Assert.InRange(r.ListingNumber, 1, 99);
});
}

[Fact]
public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList()
{
// Arrange
ListingSourceCodeService service = CreateService();

// Act
IReadOnlyList<ListingSourceCodeResponse> results = await service.GetListingsByChapterAsync(999);

// Assert
Assert.Empty(results);
}

private static ListingSourceCodeService CreateService()
{
string testDataRoot = GetTestDataPath();

var mockWebHostEnvironment = new Mock<IWebHostEnvironment>();
mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot);
mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot));

var mockLogger = new Mock<ILogger<ListingSourceCodeService>>();

return new ListingSourceCodeService(mockWebHostEnvironment.Object, mockLogger.Object);
Comment on lines +125 to +131
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a case where Moq.AutoMock would be helpful as it would decouple this from the constructor. Let me know if you struggle to use it.

}

private static string GetTestDataPath()
{
// Get the test project directory and navigate to TestData folder
string currentDirectory = Directory.GetCurrentDirectory();
string testDataPath = Path.Combine(currentDirectory, "TestData");

if (!Directory.Exists(testDataPath))
{
throw new InvalidOperationException($"TestData directory not found at: {testDataPath}");
}

return testDataPath;
}
Comment on lines +134 to +146
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple things here. When working with the file system it is better to return the stronger typed objects rather than strings. So in this case returning DirectoryInfo rather than a string.

I am a little curious how Directory.GetCurrentDirectory() makes an assumption on the working directory where the tests are run (consider running dotnet test from the test project's directory rather than dotnet test from the solution directory).

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Test listing 01.01
using System;

class Program
{
static void Main()
{
Console.WriteLine("Hello, World!");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Test XML listing 01.02 -->
<configuration>
<appSettings>
<add key="TestKey" value="TestValue" />
</appSettings>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Test listing 01.03
namespace TestNamespace
{
public class TestClass
{
public int TestProperty { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Test listing 10.01
public class Employee
{
public string Name { get; set; }
public int Id { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Test listing 10.02
public class Manager : Employee
{
public List<Employee> DirectReports { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// This file should NOT be picked up by the listing pattern
// It doesn't match {CC}.{LL}.{ext} format
public class EmployeeHelper
{
public static void DoSomething() { }
}
Loading
Loading