feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal file
223
apps/api-server.Tests/SttFailClosedTests.cs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace D3ROVoice.Api.Tests;
|
||||
|
||||
public sealed class SttFailClosedTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MissingProviderCredentials_FailsUnavailableWithoutUsage()
|
||||
{
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: string.Empty,
|
||||
_ => throw new InvalidOperationException("HTTP should not be called"));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
|
||||
() => fixture.Service.TranscribeAsync(
|
||||
7,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm"));
|
||||
|
||||
Assert.False(exception.ProviderAttempted);
|
||||
Assert.Equal(0, fixture.Handler.CallCount);
|
||||
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpstreamFailure_FailsBadGatewayWithoutLeakingOrRecordingUsage()
|
||||
{
|
||||
const string sensitiveBody = "secret-key-value original transcript text";
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: "configured-key",
|
||||
_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent(sensitiveBody)
|
||||
});
|
||||
|
||||
var exception = await Assert.ThrowsAsync<SttProviderUnavailableException>(
|
||||
() => fixture.Service.TranscribeAsync(
|
||||
9,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm"));
|
||||
|
||||
Assert.True(exception.ProviderAttempted);
|
||||
Assert.DoesNotContain(sensitiveBody, exception.Message, StringComparison.Ordinal);
|
||||
Assert.Empty(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
|
||||
var error = Assert.Single(await fixture.Db.ErrorLogs.ToListAsync());
|
||||
Assert.DoesNotContain(sensitiveBody, error.Message, StringComparison.Ordinal);
|
||||
Assert.Null(error.Endpoint);
|
||||
Assert.Null(error.StackTrace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealProviderSuccess_RecordsUsageAndReturnsTranscript()
|
||||
{
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey: "configured-key",
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{\"text\":\"real transcript\",\"duration\":1.25,\"language\":\"ko\"}",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
|
||||
var result = await fixture.Service.TranscribeAsync(
|
||||
11,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm");
|
||||
|
||||
Assert.Equal("real transcript", result.Text);
|
||||
Assert.Equal("groq", result.Provider);
|
||||
var usage = Assert.Single(await fixture.Db.SttUsageLogs.ToListAsync());
|
||||
Assert.Equal(11, usage.UserId);
|
||||
Assert.Equal(200, usage.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GoogleApiKey_IsSentInHeaderAndNeverInRequestUrl()
|
||||
{
|
||||
const string apiKey = "sensitive-google-key";
|
||||
await using var fixture = await SttFixture.CreateAsync(
|
||||
apiKey,
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"real transcript\"}]}}]}",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
},
|
||||
providerType: "google",
|
||||
endpointUrl: "https://generativelanguage.googleapis.com/v1beta/models/gemini:generateContent",
|
||||
modelId: "gemini-test");
|
||||
|
||||
var result = await fixture.Service.TranscribeAsync(
|
||||
12,
|
||||
"user@example.test",
|
||||
new SttTranscribeRequest(Language: "ko"),
|
||||
new byte[] { 1, 2, 3 },
|
||||
"audio/webm",
|
||||
"recording.webm");
|
||||
|
||||
Assert.Equal("real transcript", result.Text);
|
||||
Assert.Equal(apiKey, fixture.Handler.LastGoogleApiKey);
|
||||
Assert.DoesNotContain(apiKey, fixture.Handler.LastRequestUri, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class SttFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
private SttFixture(
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
RecordingHandler handler,
|
||||
SttProxyService service)
|
||||
{
|
||||
_connection = connection;
|
||||
Db = db;
|
||||
Handler = handler;
|
||||
Service = service;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public RecordingHandler Handler { get; }
|
||||
public SttProxyService Service { get; }
|
||||
|
||||
public static async Task<SttFixture> CreateAsync(
|
||||
string apiKey,
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responseFactory,
|
||||
string providerType = "groq",
|
||||
string endpointUrl = "https://provider.example.test/transcriptions",
|
||||
string modelId = "whisper-test")
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.SttProviderEndpoints.Add(new SttProviderEndpoint
|
||||
{
|
||||
Name = "Test provider",
|
||||
ProviderType = providerType,
|
||||
EndpointUrl = endpointUrl,
|
||||
ApiKey = apiKey,
|
||||
ModelId = modelId,
|
||||
Method = "multipart",
|
||||
Language = "ko",
|
||||
IsDefault = true,
|
||||
IsActive = true,
|
||||
FallbackPriority = 1
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var handler = new RecordingHandler(responseFactory);
|
||||
var factory = new StubHttpClientFactory(handler);
|
||||
var service = new SttProxyService(db, factory, NullLogger<SttProxyService>.Instance);
|
||||
return new SttFixture(connection, db, handler, service);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly RecordingHandler _handler;
|
||||
|
||||
public StubHttpClientFactory(RecordingHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
public sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
public string LastRequestUri { get; private set; } = string.Empty;
|
||||
public string? LastGoogleApiKey { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestUri = request.RequestUri?.ToString() ?? string.Empty;
|
||||
LastGoogleApiKey = request.Headers.TryGetValues("X-Goog-Api-Key", out var values)
|
||||
? values.SingleOrDefault()
|
||||
: null;
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue