chore: initial commit

This commit is contained in:
Stevan Freeborn
2025-02-04 14:39:54 -06:00
commit a1a682916e
45 changed files with 1736 additions and 0 deletions
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="FluentAssertions" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<!--TODO: Add test coverage-->
<ItemGroup>
<Using Include="Xunit" />
<Using Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AltGen.API\AltGen.API.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Files\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.Test.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
namespace AltGen.API.Tests.EndToEnd;
public class EndToEndTest(AppFactory factory, TestConfiguration config) : IClassFixture<AppFactory>, IClassFixture<TestConfiguration>
{
protected HttpClient Client { get; } = factory.CreateClient();
protected TestConfiguration Config { get; } = config;
}
@@ -0,0 +1,31 @@
namespace AltGen.API.Tests.EndToEnd;
public class GenerateEndpointTests(
AppFactory factory,
TestConfiguration config
) : EndToEndTest(factory, config)
{
[Fact]
public async Task GenerateEndpoint_WhenCalled_ItShouldReturnOk()
{
var fileName = "library.jpg";
var file = await TestFileManager.GetFileAsync(fileName);
var byteContent = new ByteArrayContent(file);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
var content = new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent(Config.GeminiApiKey), "ProviderKey" },
{ byteContent, "File", fileName }
};
var response = await Client.PostAsync("/generate", content);
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
response.Should().HaveStatusCode(HttpStatusCode.OK);
altText!.AltText.Should().NotBeNullOrWhiteSpace();
}
}
@@ -0,0 +1,49 @@
namespace AltGen.API.Tests.EndToEnd;
public class PromptEvaluations(
AppFactory factory,
TestConfiguration config
) : EndToEndTest(factory, config)
{
// TODO: Use LLM-assisted prompt evaluation instead of
// doing partial string matching. Better suited due to
// subjective nature of the task.
[Theory]
[ClassData(typeof(TestData))]
public async Task Generate_WhenCalled_ItShouldRespondWithAltTextContainingKeyWords(string imageName, string[] keyWords)
{
var file = await TestFileManager.GetFileAsync(imageName);
var byteContent = new ByteArrayContent(file);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
var content = new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent(Config.GeminiApiKey), "ProviderKey" },
{ byteContent, "File", imageName }
};
var response = await Client.PostAsync("/generate", content);
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
response.Should().HaveStatusCode(HttpStatusCode.OK);
altText!.AltText.ToLowerInvariant().Should().ContainAll(keyWords);
}
class TestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { "library.jpg", new[] { "book", } };
yield return new object[] { "ascii_art.jpg", new[] { "test", "100", "success", } };
yield return new object[] { "living_room.jpg", new[] { "blue", "dog", } };
yield return new object[] { "podcast.jpg", new[] { "podcast", "scott", "mark", } };
yield return new object[] { "youtube_comment.jpg", new[] { "feedback", } };
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

@@ -0,0 +1,11 @@
using Microsoft.Extensions.Logging;
namespace AltGen.API.Tests.Fixtures;
public class AppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureLogging(static l => l.ClearProviders());
}
}
@@ -0,0 +1,27 @@
using Microsoft.Extensions.Configuration;
namespace AltGen.API.Tests.Fixtures;
public class TestConfiguration
{
static IConfiguration Config { get; } = new ConfigurationBuilder()
.AddJsonFile("appsettings.Test.json")
.AddEnvironmentVariables()
.Build();
#pragma warning disable CA1822
public string GeminiApiKey => GetGeminiApiKey();
#pragma warning restore CA1822
static string GetGeminiApiKey()
{
var apiKey = Config.GetSection("Gemini").GetValue<string>("ApiKey");
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("Gemini__ApiKey is required");
}
return apiKey;
}
}
@@ -0,0 +1,211 @@
namespace AltGen.API.Tests.Integration;
public class GenerateEndpointTests : IntegrationTest
{
const string TestImageName = "library.jpg";
public GenerateEndpointTests(AppFactory factory) : base(factory)
{
MockGeminiServiceHandler.Clear();
}
[Theory]
[ClassData(typeof(InvalidRequestTestData))]
public async Task GenerateEndpoint_WhenCalledWithoutInvalidParameters_ItShouldReturnAProblemDetailWithStatusCode400(MultipartFormDataContent content, Dictionary<string, string[]> errors)
{
var response = await Client.PostAsync("/generate", content);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
response.Should().HaveStatusCode(HttpStatusCode.BadRequest);
problem!.Errors.Should().BeEquivalentTo(errors);
}
[Theory]
[ClassData(typeof(ValidRequestTestData))]
public async Task GenerateEndpoint_WhenCalledWithRequiredParameters_ItShouldReturnOkWithAltText(MultipartFormDataContent content)
{
MockGeminiServiceHandler
.When(HttpMethod.Post, "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent/?key=ProviderKey")
.Respond(
"application/json",
/*lang=json,strict*/
@"{
""candidates"": [
{
""content"": {
""parts"": [
{ ""text"": ""A long, perspective view of a classic library interior showcases richly colored wooden bookshelves filled with antique books, creating an atmosphere of history and scholarship.\n""
}
],
""role"": ""model""
},
""finishReason"": ""STOP"",
""avgLogprobs"": -0.3317602475484212
}
],
""usageMetadata"": {
""promptTokenCount"": 405,
""candidatesTokenCount"": 30,
""totalTokenCount"": 435,
""promptTokensDetails"": [
{
""modality"": ""TEXT"",
""tokenCount"": 147
},
{
""modality"": ""IMAGE"",
""tokenCount"": 258
}
],
""candidatesTokensDetails"": [
{
""modality"": ""TEXT"",
""tokenCount"": 30
}
]
},
""modelVersion"": ""gemini-1.5-flash""
}"
);
var response = await Client.PostAsync("/generate", content);
var altText = await response.Content.ReadFromJsonAsync<GenerateResponse>();
response.Should().HaveStatusCode(HttpStatusCode.OK);
altText!.AltText.Should().NotBeNullOrWhiteSpace();
}
class ValidRequestTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
var testImage = TestFileManager.GetFile(TestImageName);
var byteContent = new ByteArrayContent(testImage);
byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ byteContent, "File", TestImageName }
}
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ byteContent, "File", TestImageName }
}
};
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
class InvalidRequestTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent(string.Empty), "Provider" },
{ new StringContent(string.Empty), "ProviderKey" },
{ new ByteArrayContent([]), "File", "file.jpeg" }
},
new Dictionary<string, string[]>
{
{ "Provider", ["The Provider field is required."] },
{ "ProviderKey", ["The ProviderKey field is required."] },
{ "File", ["The File has no content."] }
},
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent(string.Empty), "ProviderKey" },
{ new ByteArrayContent([]), "File", "file.jpeg" }
},
new Dictionary<string, string[]>
{
{ "ProviderKey", ["The ProviderKey field is required."] },
{ "File", ["The File has no content."] }
},
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent(string.Empty), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ new ByteArrayContent([]), "File", "file.jpeg" }
},
new Dictionary<string, string[]>
{
{ "Provider", ["The Provider field is required."] },
{ "File", ["The File has no content."] }
},
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ new ByteArrayContent([]), "File", "file.jpeg" }
},
new Dictionary<string, string[]>
{
{ "File", ["The File has no content."] }
},
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("MadeUp"), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.jpeg" }
},
new Dictionary<string, string[]>()
{
{ "Provider", ["The Provider field is invalid."] }
},
};
yield return new object[]
{
new MultipartFormDataContent()
{
{ new StringContent("Gemini"), "Provider" },
{ new StringContent("ProviderKey"), "ProviderKey" },
{ new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.txt" }
},
new Dictionary<string, string[]>()
{
{ "File", ["The File must be a valid image file."] }
},
};
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
}
@@ -0,0 +1,20 @@
namespace AltGen.API.Tests.Integration;
public class IntegrationTest : IClassFixture<AppFactory>
{
protected MockHttpMessageHandler MockGeminiServiceHandler { get; } = new();
protected WebApplicationFactory<Program> Factory { get; }
protected HttpClient Client { get; }
public IntegrationTest(AppFactory factory)
{
Factory = factory.WithWebHostBuilder(
builder => builder.ConfigureTestServices(
services => services.AddHttpClient<IGeminiService, GeminiService>()
.ConfigurePrimaryHttpMessageHandler(() => MockGeminiServiceHandler)
)
);
Client = Factory.CreateClient();
}
}
+18
View File
@@ -0,0 +1,18 @@
global using System.Collections;
global using System.Net;
global using System.Net.Http.Headers;
global using System.Net.Http.Json;
global using System.Text;
global using AltGen.API.Generate;
global using AltGen.API.Generate.Providers.Gemini;
global using AltGen.API.Tests.Fixtures;
global using AltGen.API.Tests.Utils;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.AspNetCore.TestHost;
global using Microsoft.Extensions.DependencyInjection;
global using RichardSzalay.MockHttp;
@@ -0,0 +1,18 @@
namespace AltGen.API.Tests.Utils;
public static class TestFileManager
{
public static byte[] GetFile(string testFileName)
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName);
var file = File.ReadAllBytes(filePath);
return file;
}
public static Task<byte[]> GetFileAsync(string testFileName)
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName);
var file = File.ReadAllBytesAsync(filePath);
return file;
}
}
@@ -0,0 +1,5 @@
{
"Gemini": {
"ApiKey": "ApiKey"
}
}