feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View 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));
}
}
}