SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// Тесты storage-service поднимают Kestrel-хост и меняют env-переменные процесса на время сценария —
|
||||
// параллельный прогон классов дал бы гонки на env, поэтому тесты сериализованы.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="6.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
|
||||
<PackageReference Include="Grpc.HealthCheck" Version="2.83.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Deal.Storage\Deal.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Deal.Grpc.Storage;
|
||||
using Grpc.Core;
|
||||
using Grpc.Health.V1;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
namespace Deal.Storage.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты хоста storage-service: health и проверка токена.
|
||||
/// </summary>
|
||||
public sealed class StorageServiceHostTests
|
||||
{
|
||||
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
|
||||
private const string EndpointEnvKey = "DEAL_STORAGE_ENDPOINT";
|
||||
private const string AccessKeyEnvKey = "DEAL_STORAGE_ACCESS_KEY";
|
||||
private const string SecretKeyEnvKey = "DEAL_STORAGE_SECRET_KEY";
|
||||
private const string ServiceTokenMetadataKey = "service-token";
|
||||
private const string ValidToken = "storage-test-token";
|
||||
private const int RpcDeadlineSeconds = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Health отвечает SERVING — хост поднялся.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HealthCheck_ReturnsServing()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
async channel =>
|
||||
{
|
||||
var health = new Health.HealthClient(channel);
|
||||
HealthCheckResponse response = await health.CheckAsync(
|
||||
new HealthCheckRequest(),
|
||||
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stat без токена отклоняется.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Stat_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
channel => AssertRejectedAsync(channel, tokenHeader: null));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stat с неверным токеном отклоняется.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Stat_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
await RunHostScenarioAsync(
|
||||
ValidToken,
|
||||
channel => AssertRejectedAsync(channel, tokenHeader: "wrong-token"));
|
||||
}
|
||||
|
||||
private static async Task AssertRejectedAsync(GrpcChannel channel, string? tokenHeader)
|
||||
{
|
||||
var client = new StorageService.StorageServiceClient(channel);
|
||||
Metadata metadata = new();
|
||||
if (tokenHeader is not null)
|
||||
{
|
||||
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
|
||||
}
|
||||
|
||||
var callOptions = new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
|
||||
AsyncUnaryCall<StatReply> call = client.StatAsync(new StatRequest { Id = "x" }, callOptions);
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
|
||||
Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
|
||||
{
|
||||
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
|
||||
string? originalEndpoint = Environment.GetEnvironmentVariable(EndpointEnvKey);
|
||||
string? originalAccess = Environment.GetEnvironmentVariable(AccessKeyEnvKey);
|
||||
string? originalSecret = Environment.GetEnvironmentVariable(SecretKeyEnvKey);
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
|
||||
Environment.SetEnvironmentVariable(EndpointEnvKey, "127.0.0.1:9000");
|
||||
Environment.SetEnvironmentVariable(AccessKeyEnvKey, "test");
|
||||
Environment.SetEnvironmentVariable(SecretKeyEnvKey, "test");
|
||||
|
||||
WebApplication? app = null;
|
||||
GrpcChannel? channel = null;
|
||||
try
|
||||
{
|
||||
int port = FreeTcpPort();
|
||||
app = StorageServiceHost.Create(port);
|
||||
await app.StartAsync();
|
||||
|
||||
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
|
||||
await scenario(channel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (channel is not null)
|
||||
{
|
||||
channel.Dispose();
|
||||
}
|
||||
|
||||
if (app is not null)
|
||||
{
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
|
||||
Environment.SetEnvironmentVariable(EndpointEnvKey, originalEndpoint);
|
||||
Environment.SetEnvironmentVariable(AccessKeyEnvKey, originalAccess);
|
||||
Environment.SetEnvironmentVariable(SecretKeyEnvKey, originalSecret);
|
||||
}
|
||||
}
|
||||
|
||||
private static int FreeTcpPort()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Deal.Storage.Services;
|
||||
|
||||
namespace Deal.Storage.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты определения типа и MIME по содержимому.
|
||||
/// </summary>
|
||||
public sealed class FileKindSnifferTests
|
||||
{
|
||||
/// <summary>
|
||||
/// PNG определяется как изображение.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Png_IsImage()
|
||||
{
|
||||
byte[] head = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, null);
|
||||
Assert.Equal("image", kind);
|
||||
Assert.Equal("image/png", mime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF определяется как документ.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Pdf_IsDocument()
|
||||
{
|
||||
byte[] head = [0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, null);
|
||||
Assert.Equal("document", kind);
|
||||
Assert.Equal("application/pdf", mime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MP4 определяется как видео.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Mp4_IsVideo()
|
||||
{
|
||||
byte[] head = [0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, null);
|
||||
Assert.Equal("video", kind);
|
||||
Assert.Equal("video/mp4", mime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MP3 определяется как аудио.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Mp3_IsAudio()
|
||||
{
|
||||
byte[] head = [0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, null);
|
||||
Assert.Equal("audio", kind);
|
||||
Assert.Equal("audio/mpeg", mime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Неизвестное содержимое с известным MIME берёт тип из MIME.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UnknownWithDeclaredText_IsDocument()
|
||||
{
|
||||
byte[] head = [0x00, 0x01, 0x02, 0x03];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, "text/plain");
|
||||
Assert.Equal("document", kind);
|
||||
Assert.Equal("text/plain", mime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Полностью неизвестное содержимое — «other».
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UnknownWithoutDeclared_IsOther()
|
||||
{
|
||||
byte[] head = [0x00, 0x01, 0x02, 0x03];
|
||||
(string kind, string mime) = FileKindSniffer.Detect(head, null);
|
||||
Assert.Equal(FileKindSniffer.KindOther, kind);
|
||||
Assert.Equal("application/octet-stream", mime);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user