Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
edd4ef9
Implement ListingSourceCode feature with controller, service, and tests
Joshua-Lester3 Feb 5, 2026
30402fa
feat: add TryDotNet integration for interactive code execution
Joshua-Lester3 Feb 11, 2026
41d9c03
Merge branch 'main' into jlester/try-net
Joshua-Lester3 Feb 11, 2026
bd8d5d4
fix: ensure ListingSourceCode files are copied to output directory fo…
Joshua-Lester3 Feb 11, 2026
17e3b38
fix: enhance WebApplicationFactory to use TestData for IListingSource…
Joshua-Lester3 Feb 11, 2026
122cc0a
fix: update API routes in ListingSourceCodeController and tests for c…
Joshua-Lester3 Feb 12, 2026
c746e71
Update EssentialCSharp.Web.Tests/WebApplicationFactory.cs
Joshua-Lester3 Feb 12, 2026
6e2d455
refactor: convert ListingSourceCodeResponse to a record class for imp…
Joshua-Lester3 Feb 12, 2026
ee8a892
Merge branch 'jlester/try-net' of https://github.com/Joshua-Lester3/E…
Joshua-Lester3 Feb 12, 2026
30a3fd5
bug fix: Source code response syntax and instantiation
Joshua-Lester3 Feb 12, 2026
e7ebdff
fix: correct service removal in WebApplicationFactory for test data i…
Joshua-Lester3 Feb 12, 2026
d1e638e
refactor: update CreateService and GetTestDataPath methods to use Dir…
Joshua-Lester3 Feb 12, 2026
4ddcc59
refactor: temporarily (possibly use AutoMoq after I research it) repl…
Joshua-Lester3 Feb 12, 2026
76c08f4
refactor: update test project to use Moq.AutoMock for improved mockin…
Joshua-Lester3 Feb 12, 2026
2b1afe1
refactor: replace Mock with AutoMocker for IWebHostEnvironment in Web…
Joshua-Lester3 Feb 12, 2026
ecf4699
Merge branch 'main' into jlester/try-net
Joshua-Lester3 Feb 12, 2026
168c8d5
fix: accidentally removed system.data.common, added back here
Joshua-Lester3 Feb 12, 2026
36e9efd
Merge branch 'jlester/try-net' of https://github.com/Joshua-Lester3/E…
Joshua-Lester3 Feb 12, 2026
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Moq.AutoMock" Version="3.6.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.3" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Octokit" Version="14.0.0" />
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.AutoMock" />
<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/chapter/1/listing/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/chapter/999/listing/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/chapter/1/listing/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/chapter/1");

// 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/chapter/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 Moq;
using Moq.AutoMock;

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()
{
DirectoryInfo testDataRoot = GetTestDataPath();

AutoMocker mocker = new();
Mock<IWebHostEnvironment> mockWebHostEnvironment = mocker.GetMock<IWebHostEnvironment>();
mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot.FullName);
mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot.FullName));

return mocker.CreateInstance<ListingSourceCodeService>();
}

private static DirectoryInfo GetTestDataPath()
{
string baseDirectory = AppContext.BaseDirectory;
string testDataPath = Path.Combine(baseDirectory, "TestData");

DirectoryInfo testDataDirectory = new(testDataPath);

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

return testDataDirectory;
}
}
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