Skip to content

Commit 57c7779

Browse files
ritunjaymclaude
andcommitted
feat: add unit tests (70%+ coverage target) and Azure Bicep IaC
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f452328 commit 57c7779

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

infra/bicep/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Azure Bicep Deployment
2+
3+
## Deploy
4+
```bash
5+
az group create -n vectorscale-rg -l eastus
6+
az deployment group create -g vectorscale-rg -f main.bicep
7+
```
8+
9+
## Outputs
10+
```bash
11+
az deployment group show -g vectorscale-rg -n main --query properties.outputs.apiUrl.value
12+
```
13+
14+
## Cleanup
15+
```bash
16+
az group delete -n vectorscale-rg --yes
17+
```

infra/bicep/main.bicep

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
param location string = 'eastus'
2+
param environmentName string = 'vectorscale-env'
3+
4+
// Container Apps Environment
5+
resource containerAppEnv 'Microsoft.App/managedEnvironments@2023-05-01' = {
6+
name: environmentName
7+
location: location
8+
properties: {
9+
appLogsConfiguration: {
10+
destination: 'log-analytics'
11+
}
12+
}
13+
}
14+
15+
// Redis Cache
16+
resource redis 'Microsoft.Cache/redis@2023-08-01' = {
17+
name: 'vectorscale-redis'
18+
location: location
19+
properties: {
20+
sku: {
21+
name: 'Basic'
22+
family: 'C'
23+
capacity: 0
24+
}
25+
enableNonSslPort: false
26+
minimumTlsVersion: '1.2'
27+
}
28+
}
29+
30+
// Sidecar Container App
31+
resource sidecar 'Microsoft.App/containerApps@2023-05-01' = {
32+
name: 'vectorscale-sidecar'
33+
location: location
34+
properties: {
35+
managedEnvironmentId: containerAppEnv.id
36+
configuration: {
37+
ingress: {
38+
external: false
39+
targetPort: 50051
40+
}
41+
}
42+
template: {
43+
containers: [
44+
{
45+
name: 'sidecar'
46+
image: 'ghcr.io/ritunjaym/vectorscale-sidecar:latest'
47+
resources: {
48+
cpu: json('2.0')
49+
memory: '4Gi'
50+
}
51+
}
52+
]
53+
scale: {
54+
minReplicas: 1
55+
maxReplicas: 3
56+
}
57+
}
58+
}
59+
}
60+
61+
// API Container App
62+
resource api 'Microsoft.App/containerApps@2023-05-01' = {
63+
name: 'vectorscale-api'
64+
location: location
65+
properties: {
66+
managedEnvironmentId: containerAppEnv.id
67+
configuration: {
68+
ingress: {
69+
external: true
70+
targetPort: 8080
71+
}
72+
}
73+
template: {
74+
containers: [
75+
{
76+
name: 'api'
77+
image: 'ghcr.io/ritunjaym/vectorscale-api:latest'
78+
resources: {
79+
cpu: json('1.0')
80+
memory: '2Gi'
81+
}
82+
env: [
83+
{
84+
name: 'VectorScale__SidecarGrpcAddress'
85+
value: 'http://${sidecar.properties.configuration.ingress.fqdn}:50051'
86+
}
87+
{
88+
name: 'VectorScale__Redis__ConnectionString'
89+
value: '${redis.name}.redis.cache.windows.net:6380,password=${redis.listKeys().primaryKey},ssl=True'
90+
}
91+
]
92+
}
93+
]
94+
scale: {
95+
minReplicas: 1
96+
maxReplicas: 5
97+
}
98+
}
99+
}
100+
}
101+
102+
output apiUrl string = 'https://${api.properties.configuration.ingress.fqdn}'
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using FluentAssertions;
2+
using Microsoft.AspNetCore.Http;
3+
using VectorScale.Api.Infrastructure.Middleware;
4+
5+
namespace VectorScale.Api.Tests.Infrastructure;
6+
7+
public class CorrelationIdMiddlewareTests
8+
{
9+
private const string HeaderName = "X-Correlation-ID";
10+
11+
[Fact]
12+
public async Task InvokeAsync_NoIncomingHeader_GeneratesCorrelationId()
13+
{
14+
var context = new DefaultHttpContext();
15+
var middleware = new CorrelationIdMiddleware(_ => Task.CompletedTask);
16+
17+
await middleware.InvokeAsync(context);
18+
19+
context.Response.Headers.ContainsKey(HeaderName).Should().BeTrue();
20+
context.Response.Headers[HeaderName].ToString().Should().NotBeNullOrEmpty();
21+
}
22+
23+
[Fact]
24+
public async Task InvokeAsync_WithIncomingHeader_EchoesSameId()
25+
{
26+
var existingId = "abc123def456";
27+
var context = new DefaultHttpContext();
28+
context.Request.Headers[HeaderName] = existingId;
29+
var middleware = new CorrelationIdMiddleware(_ => Task.CompletedTask);
30+
31+
await middleware.InvokeAsync(context);
32+
33+
context.Response.Headers[HeaderName].ToString().Should().Be(existingId);
34+
}
35+
36+
[Fact]
37+
public async Task InvokeAsync_SetsCorrelationIdInContextItems()
38+
{
39+
var context = new DefaultHttpContext();
40+
var middleware = new CorrelationIdMiddleware(_ => Task.CompletedTask);
41+
42+
await middleware.InvokeAsync(context);
43+
44+
context.Items.ContainsKey("CorrelationId").Should().BeTrue();
45+
context.Items["CorrelationId"].Should().NotBeNull();
46+
}
47+
48+
[Fact]
49+
public async Task InvokeAsync_CallsNextMiddleware()
50+
{
51+
var nextCalled = false;
52+
var context = new DefaultHttpContext();
53+
var middleware = new CorrelationIdMiddleware(_ =>
54+
{
55+
nextCalled = true;
56+
return Task.CompletedTask;
57+
});
58+
59+
await middleware.InvokeAsync(context);
60+
61+
nextCalled.Should().BeTrue();
62+
}
63+
64+
[Fact]
65+
public async Task InvokeAsync_GeneratedId_Has16CharLength()
66+
{
67+
var context = new DefaultHttpContext();
68+
var middleware = new CorrelationIdMiddleware(_ => Task.CompletedTask);
69+
70+
await middleware.InvokeAsync(context);
71+
72+
var id = context.Response.Headers[HeaderName].ToString();
73+
id.Should().HaveLength(16);
74+
}
75+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using FluentAssertions;
3+
using VectorScale.Api.Models;
4+
5+
namespace VectorScale.Api.Tests.Models;
6+
7+
public class SearchModelsTests
8+
{
9+
private static bool TryValidate(object model, out List<ValidationResult> results)
10+
{
11+
results = new List<ValidationResult>();
12+
var context = new ValidationContext(model);
13+
return Validator.TryValidateObject(model, context, results, true);
14+
}
15+
16+
[Fact]
17+
public void SearchRequest_EmptyQuery_FailsValidation()
18+
{
19+
var request = new SearchRequest { Query = "", TopK = 5 };
20+
21+
var isValid = TryValidate(request, out var results);
22+
23+
isValid.Should().BeFalse();
24+
results.Should().ContainSingle(r => r.MemberNames.Contains(nameof(SearchRequest.Query)));
25+
}
26+
27+
[Fact]
28+
public void SearchRequest_ValidQuery_PassesValidation()
29+
{
30+
var request = new SearchRequest { Query = "test query", TopK = 10 };
31+
32+
var isValid = TryValidate(request, out var results);
33+
34+
isValid.Should().BeTrue();
35+
results.Should().BeEmpty();
36+
}
37+
38+
[Fact]
39+
public void SearchRequest_TopKOutOfRange_FailsValidation()
40+
{
41+
var request = new SearchRequest { Query = "test", TopK = 0 };
42+
43+
var isValid = TryValidate(request, out var results);
44+
45+
isValid.Should().BeFalse();
46+
results.Should().ContainSingle(r => r.MemberNames.Contains(nameof(SearchRequest.TopK)));
47+
}
48+
49+
[Fact]
50+
public void SearchRequest_TopKAtMaxBoundary_PassesValidation()
51+
{
52+
var request = new SearchRequest { Query = "test", TopK = 100 };
53+
54+
var isValid = TryValidate(request, out var results);
55+
56+
isValid.Should().BeTrue();
57+
}
58+
59+
[Fact]
60+
public void SearchRequest_QueryExceedsMaxLength_FailsValidation()
61+
{
62+
var request = new SearchRequest { Query = new string('x', 2001), TopK = 5 };
63+
64+
var isValid = TryValidate(request, out var results);
65+
66+
isValid.Should().BeFalse();
67+
}
68+
69+
[Fact]
70+
public void SearchResponse_DefaultValues_AreCorrect()
71+
{
72+
var response = new SearchResponse();
73+
74+
response.Results.Should().BeEmpty();
75+
response.CacheHit.Should().BeFalse();
76+
response.Page.Should().Be(0);
77+
}
78+
79+
[Fact]
80+
public void SearchResultItem_DefaultMetadata_IsEmpty()
81+
{
82+
var item = new SearchResultItem();
83+
84+
item.Metadata.Should().NotBeNull();
85+
item.Metadata.Should().BeEmpty();
86+
}
87+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using FluentAssertions;
2+
using Grpc.Core;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using Moq;
5+
using VectorScale.Api.Protos;
6+
using ApiServices = VectorScale.Api.Services;
7+
using ProtoServices = VectorScale.Api.Protos;
8+
9+
namespace VectorScale.Api.Tests.Services;
10+
11+
public class EmbeddingServiceTests
12+
{
13+
private readonly Mock<ProtoServices.EmbeddingService.EmbeddingServiceClient> _grpcClientMock = new();
14+
private readonly ApiServices.EmbeddingService _sut;
15+
16+
public EmbeddingServiceTests()
17+
{
18+
_sut = new ApiServices.EmbeddingService(_grpcClientMock.Object, NullLogger<ApiServices.EmbeddingService>.Instance);
19+
}
20+
21+
[Fact]
22+
public async Task GenerateEmbeddingAsync_ValidText_ReturnsVector()
23+
{
24+
var expectedVector = Enumerable.Range(0, 384).Select(i => (float)i / 384).ToArray();
25+
var response = new EmbeddingResponse { Dimension = 384, LatencyMs = 10 };
26+
response.Vector.AddRange(expectedVector);
27+
28+
var asyncUnaryCall = new AsyncUnaryCall<EmbeddingResponse>(
29+
Task.FromResult(response),
30+
Task.FromResult(new Metadata()),
31+
() => Status.DefaultSuccess,
32+
() => new Metadata(),
33+
() => { });
34+
35+
_grpcClientMock
36+
.Setup(c => c.GenerateEmbeddingAsync(
37+
It.IsAny<EmbeddingRequest>(),
38+
It.IsAny<Metadata>(),
39+
It.IsAny<DateTime?>(),
40+
It.IsAny<CancellationToken>()))
41+
.Returns(asyncUnaryCall);
42+
43+
var result = await _sut.GenerateEmbeddingAsync("test query");
44+
45+
result.Should().NotBeNull();
46+
result.Should().HaveCount(384);
47+
result.Should().BeEquivalentTo(expectedVector);
48+
}
49+
50+
[Fact]
51+
public async Task GenerateEmbeddingAsync_GrpcThrows_PropagatesException()
52+
{
53+
var asyncUnaryCall = new AsyncUnaryCall<EmbeddingResponse>(
54+
Task.FromException<EmbeddingResponse>(new RpcException(new Status(StatusCode.Unavailable, "gRPC unavailable"))),
55+
Task.FromResult(new Metadata()),
56+
() => Status.DefaultSuccess,
57+
() => new Metadata(),
58+
() => { });
59+
60+
_grpcClientMock
61+
.Setup(c => c.GenerateEmbeddingAsync(
62+
It.IsAny<EmbeddingRequest>(),
63+
It.IsAny<Metadata>(),
64+
It.IsAny<DateTime?>(),
65+
It.IsAny<CancellationToken>()))
66+
.Returns(asyncUnaryCall);
67+
68+
var act = async () => await _sut.GenerateEmbeddingAsync("test query");
69+
70+
await act.Should().ThrowAsync<RpcException>();
71+
}
72+
73+
[Fact]
74+
public async Task GenerateEmbeddingAsync_SendsCorrectModelName()
75+
{
76+
EmbeddingRequest? capturedRequest = null;
77+
var response = new EmbeddingResponse { Dimension = 384, LatencyMs = 5 };
78+
response.Vector.AddRange(new float[384]);
79+
80+
var asyncUnaryCall = new AsyncUnaryCall<EmbeddingResponse>(
81+
Task.FromResult(response),
82+
Task.FromResult(new Metadata()),
83+
() => Status.DefaultSuccess,
84+
() => new Metadata(),
85+
() => { });
86+
87+
_grpcClientMock
88+
.Setup(c => c.GenerateEmbeddingAsync(
89+
It.IsAny<EmbeddingRequest>(),
90+
It.IsAny<Metadata>(),
91+
It.IsAny<DateTime?>(),
92+
It.IsAny<CancellationToken>()))
93+
.Callback<EmbeddingRequest, Metadata, DateTime?, CancellationToken>((req, _, _, _) => capturedRequest = req)
94+
.Returns(asyncUnaryCall);
95+
96+
await _sut.GenerateEmbeddingAsync("hello world");
97+
98+
capturedRequest.Should().NotBeNull();
99+
capturedRequest!.Text.Should().Be("hello world");
100+
capturedRequest.ModelName.Should().Be("all-MiniLM-L6-v2");
101+
}
102+
}

0 commit comments

Comments
 (0)