Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -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);
}
}