diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..107fd19b 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,13 @@ - + Лабораторная работа - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №2 «Интеграционное тестирование» + Вариант №35 "Программный проект" + Выполнена Челаевым Петром 6512 + Ссылка на форк - + \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..833bda64 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5232/api/projects" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..ced8672b 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,18 @@ VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.AppHost", "ProgramProject.AppHost\ProgramProject.AppHost.csproj", "{7FA94C9A-CD2C-4D76-A42C-5C0EEDC6D68D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.ServiceDefaults", "ProgramProject.ServiceDefaults\ProgramProject.ServiceDefaults.csproj", "{213F5ADB-6769-02BA-8BFA-17C924D27341}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.GenerationService", "ProgramProject.GenerationService\ProgramProject.GenerationService.csproj", "{66DBD8A2-6672-889D-9A17-D156226DCB66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.Gateway", "ProgramProject.Gateway\ProgramProject.Gateway.csproj", "{0C26CAD8-F259-19D3-8E5B-FB32D481F591}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.FileService", "ProgramProject.FileService\ProgramProject.FileService.csproj", "{087EE229-CD64-E3B4-00A6-CE22B6C183AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProgramProject.IntegrationTests", "ProgramProject.IntegrationTests\ProgramProject.IntegrationTests.csproj", "{AD9978E4-F9F8-4ECB-BE1F-8B54B78EC81F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +27,30 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {7FA94C9A-CD2C-4D76-A42C-5C0EEDC6D68D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FA94C9A-CD2C-4D76-A42C-5C0EEDC6D68D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FA94C9A-CD2C-4D76-A42C-5C0EEDC6D68D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FA94C9A-CD2C-4D76-A42C-5C0EEDC6D68D}.Release|Any CPU.Build.0 = Release|Any CPU + {213F5ADB-6769-02BA-8BFA-17C924D27341}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {213F5ADB-6769-02BA-8BFA-17C924D27341}.Debug|Any CPU.Build.0 = Debug|Any CPU + {213F5ADB-6769-02BA-8BFA-17C924D27341}.Release|Any CPU.ActiveCfg = Release|Any CPU + {213F5ADB-6769-02BA-8BFA-17C924D27341}.Release|Any CPU.Build.0 = Release|Any CPU + {66DBD8A2-6672-889D-9A17-D156226DCB66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66DBD8A2-6672-889D-9A17-D156226DCB66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66DBD8A2-6672-889D-9A17-D156226DCB66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66DBD8A2-6672-889D-9A17-D156226DCB66}.Release|Any CPU.Build.0 = Release|Any CPU + {0C26CAD8-F259-19D3-8E5B-FB32D481F591}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C26CAD8-F259-19D3-8E5B-FB32D481F591}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C26CAD8-F259-19D3-8E5B-FB32D481F591}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C26CAD8-F259-19D3-8E5B-FB32D481F591}.Release|Any CPU.Build.0 = Release|Any CPU + {087EE229-CD64-E3B4-00A6-CE22B6C183AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {087EE229-CD64-E3B4-00A6-CE22B6C183AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {087EE229-CD64-E3B4-00A6-CE22B6C183AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {087EE229-CD64-E3B4-00A6-CE22B6C183AF}.Release|Any CPU.Build.0 = Release|Any CPU + {AD9978E4-F9F8-4ECB-BE1F-8B54B78EC81F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD9978E4-F9F8-4ECB-BE1F-8B54B78EC81F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD9978E4-F9F8-4ECB-BE1F-8B54B78EC81F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD9978E4-F9F8-4ECB-BE1F-8B54B78EC81F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProgramProject.AppHost/AppHost.cs b/ProgramProject.AppHost/AppHost.cs new file mode 100644 index 00000000..076a5877 --- /dev/null +++ b/ProgramProject.AppHost/AppHost.cs @@ -0,0 +1,58 @@ +using Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("cache").WithRedisCommander(); + +// Minio (объектное хранилище) +var minio = builder.AddContainer("minio", "minio/minio") + .WithArgs("server", "/data", "--console-address", ":9001") + .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") + .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console") + .WithVolume("minio-data", "/data"); + +// ElasticMQ +var sqs = builder.AddContainer("elasticmq", "softwaremill/elasticmq") + .WithHttpEndpoint(port: 9324, targetPort: 9324, name: "http"); + +// Создаём 5 генераторов в цикле +var generators = new List>(); + +for (var i = 1; i <= 5; i++) +{ + var generator = builder.AddProject($"generator-{i}") + .WithExternalHttpEndpoints() + .WithReference(cache) + .WaitFor(cache) + .WithEndpoint("http", endpoint => endpoint.Port = 6200 + i) + .WithEndpoint("https", endpoint => endpoint.Port = 7200 + i) + .WithEnvironment("SQS__ServiceURL", sqs.GetEndpoint("http")); + + generators.Add(generator); +} + +// Шлюз +var gateway = builder.AddProject("gateway") + .WithExternalHttpEndpoints(); + +foreach (var generator in generators) +{ + gateway.WaitFor(generator); +} + +// Файловый сервис +var fileService = builder.AddProject("programproject-fileservice") + .WithExternalHttpEndpoints() + .WithEnvironment("SQS__ServiceURL", sqs.GetEndpoint("http")) + .WithEnvironment("Minio__Endpoint", minio.GetEndpoint("api")) + .WithEnvironment("Minio__AccessKey", "minioadmin") + .WithEnvironment("Minio__SecretKey", "minioadmin") + .WaitFor(sqs) + .WaitFor(minio); + +// Клиент теперь связывается с генератором через шлюз +builder.AddProject("client-wasm") + .WithExternalHttpEndpoints() + .WaitFor(gateway); + +builder.Build().Run(); \ No newline at end of file diff --git a/ProgramProject.AppHost/ProgramProject.AppHost.csproj b/ProgramProject.AppHost/ProgramProject.AppHost.csproj new file mode 100644 index 00000000..8776665b --- /dev/null +++ b/ProgramProject.AppHost/ProgramProject.AppHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net8.0 + enable + enable + f95b6f17-c3f7-4ae5-a722-409a54dda80d + + + + + + + + + + + + + + + diff --git a/ProgramProject.AppHost/Properties/launchSettings.json b/ProgramProject.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..9e7ac03b --- /dev/null +++ b/ProgramProject.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17028;http://localhost:15016", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21209", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22094" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15016", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19079", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20183" + } + } + } +} diff --git a/ProgramProject.AppHost/appsettings.Development.json b/ProgramProject.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProgramProject.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProgramProject.AppHost/appsettings.json b/ProgramProject.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/ProgramProject.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/ProgramProject.FileService/Program.cs b/ProgramProject.FileService/Program.cs new file mode 100644 index 00000000..3e20b4bf --- /dev/null +++ b/ProgramProject.FileService/Program.cs @@ -0,0 +1,56 @@ +using Amazon.Runtime; +using Amazon.SQS; +using Minio; +using ProgramProject.FileService.Services; +using ProgramProject.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +Console.OutputEncoding = System.Text.Encoding.UTF8; + +builder.AddServiceDefaults(); + +// SQS ( AppHost) +var sqsServiceUrl = builder.Configuration["SQS:ServiceURL"] ?? "http://localhost:9324"; +var sqsConfig = new AmazonSQSConfig +{ + ServiceURL = sqsServiceUrl, + UseHttp = true, + AuthenticationRegion = "us-east-1" +}; +builder.Services.AddSingleton(sp => new AmazonSQSClient(new AnonymousAWSCredentials(), sqsConfig)); + +// Minio +var minioEndpoint = builder.Configuration["Minio:Endpoint"] ?? "http://localhost:9000"; +var minioAccessKey = builder.Configuration["Minio:AccessKey"] ?? "minioadmin"; +var minioSecretKey = builder.Configuration["Minio:SecretKey"] ?? "minioadmin"; + +builder.Services.AddSingleton(sp => +{ + return new Minio.MinioClient() + .WithEndpoint(minioEndpoint.Replace("http://", "").Replace("https://", "")) + .WithCredentials(minioAccessKey, minioSecretKey) + .WithSSL(false) + .Build(); +}); + +builder.Services.AddHostedService(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/ProgramProject.FileService/ProgramProject.FileService.csproj b/ProgramProject.FileService/ProgramProject.FileService.csproj new file mode 100644 index 00000000..afdaa6ae --- /dev/null +++ b/ProgramProject.FileService/ProgramProject.FileService.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/ProgramProject.FileService/Properties/launchSettings.json b/ProgramProject.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..8d9e9a53 --- /dev/null +++ b/ProgramProject.FileService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:38561", + "sslPort": 44371 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5028", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7063;http://localhost:5028", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProgramProject.FileService/Services/SqsBackgroundService.cs b/ProgramProject.FileService/Services/SqsBackgroundService.cs new file mode 100644 index 00000000..ebb04661 --- /dev/null +++ b/ProgramProject.FileService/Services/SqsBackgroundService.cs @@ -0,0 +1,146 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using Minio; +using Minio.DataModel.Args; +using System.Text.Json; +using ProgramProject.GenerationService.Models; + +namespace ProgramProject.FileService.Services; + +/// +/// Фоновый сервис для чтения сообщений из SQS и сохранения в Minio +/// +public class SqsBackgroundService : BackgroundService +{ + private readonly IAmazonSQS _sqsClient; + private readonly IMinioClient _minioClient; + private readonly ILogger _logger; + private readonly string _queueUrl; + private readonly string _bucketName; + + public SqsBackgroundService( + IAmazonSQS sqsClient, + IMinioClient minioClient, + IConfiguration configuration, + ILogger logger) + { + _sqsClient = sqsClient; + _minioClient = minioClient; + _logger = logger; + + _queueUrl = configuration["SQS:QueueUrl"] ?? "http://localhost:9324/queue/projects"; + _bucketName = configuration["Minio:BucketName"] ?? "projects"; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("SQS Background Service запущен"); + + // Создаём очередь, если её нет + await EnsureQueueExistsAsync(stoppingToken); + + // Создаём бакет в Minio, если его нет + await EnsureBucketExistsAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var request = new ReceiveMessageRequest + { + QueueUrl = _queueUrl, + MaxNumberOfMessages = 1, + WaitTimeSeconds = 10, + VisibilityTimeout = 30 + }; + + var response = await _sqsClient.ReceiveMessageAsync(request, stoppingToken); + + foreach (var message in response.Messages) + { + _logger.LogInformation("Получено сообщение: {MessageBody}", message.Body); + + // Десериализуем проект + var project = JsonSerializer.Deserialize(message.Body); + + if (project != null) + { + // Сохраняем в Minio + await SaveToMinioAsync(project, stoppingToken); + + // Удаляем сообщение из очереди + await _sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken); + _logger.LogInformation("Проект {ProjectId} сохранён и сообщение удалено", project.Id); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при обработке сообщения из SQS"); + await Task.Delay(5000, stoppingToken); + } + } + } + + private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken) + { + try + { + var createQueueRequest = new CreateQueueRequest + { + QueueName = "projects", + Attributes = new Dictionary + { + { "VisibilityTimeout", "30" } + } + }; + + var response = await _sqsClient.CreateQueueAsync(createQueueRequest, cancellationToken); + _logger.LogInformation("Очередь создана или уже существует: {QueueUrl}", response.QueueUrl); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при создании очереди"); + } + } + + private async Task EnsureBucketExistsAsync(CancellationToken cancellationToken) + { + try + { + var bucketExists = await _minioClient.BucketExistsAsync(new BucketExistsArgs().WithBucket(_bucketName), cancellationToken); + + if (!bucketExists) + { + await _minioClient.MakeBucketAsync(new MakeBucketArgs().WithBucket(_bucketName), cancellationToken); + _logger.LogInformation("Бакет {BucketName} создан", _bucketName); + } + else + { + _logger.LogInformation("Бакет {BucketName} уже существует", _bucketName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при создании бакета"); + } + } + + private async Task SaveToMinioAsync(ProgramProjectModel project, CancellationToken cancellationToken) + { + var jsonContent = JsonSerializer.SerializeToUtf8Bytes(project, new JsonSerializerOptions { WriteIndented = true }); + var fileName = $"project_{project.Id}.json"; + + using var memoryStream = new MemoryStream(jsonContent); + + var putObjectArgs = new PutObjectArgs() + .WithBucket(_bucketName) + .WithObject(fileName) + .WithStreamData(memoryStream) + .WithObjectSize(memoryStream.Length) + .WithContentType("application/json"); + + await _minioClient.PutObjectAsync(putObjectArgs, cancellationToken); + _logger.LogInformation("Проект {ProjectId} сохранён в Minio: {FileName}", project.Id, fileName); + } +} \ No newline at end of file diff --git a/ProgramProject.FileService/appsettings.Development.json b/ProgramProject.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProgramProject.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProgramProject.FileService/appsettings.json b/ProgramProject.FileService/appsettings.json new file mode 100644 index 00000000..cffc5516 --- /dev/null +++ b/ProgramProject.FileService/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "SQS": { + "ServiceURL": "http://localhost:9324", + "QueueUrl": "http://localhost:9324/queue/projects" + }, + "Minio": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "BucketName": "projects" + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/ProgramProject.Gateway/LoadBalancers/QueryBasedLoadBalancer.cs b/ProgramProject.Gateway/LoadBalancers/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..952fe97d --- /dev/null +++ b/ProgramProject.Gateway/LoadBalancers/QueryBasedLoadBalancer.cs @@ -0,0 +1,72 @@ +using Ocelot.LoadBalancer.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ProgramProject.Gateway.LoadBalancers; + +/// +/// Балансировщик для алгоритма Query Based +/// Распределяет запросы на основе query-параметра id +/// +public class QueryBasedLoadBalancer(Func>> serviceFactory, ILogger logger, + string queryParameterName = "id") : ILoadBalancer +{ + private readonly Func>> _serviceFactory = serviceFactory ?? throw new ArgumentNullException(nameof(serviceFactory)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + public string Type => nameof(QueryBasedLoadBalancer); + + public async Task> LeaseAsync(HttpContext httpContext) + { + try + { + var services = await _serviceFactory(); + + if (services.Count == 0) + { + _logger.LogError("Нет доступных сервисов для балансировки"); + return new ErrorResponse(new ServicesAreEmptyError("Нет доступных реплик")); + } + + var id = ExtractIdFromQuery(httpContext); + + var index = Math.Abs(id) % services.Count; + + var selectedService = services[index]; + _logger.LogInformation("Запрос с id={Id} (индекс={Index}) направлен на реплику {Host}:{Port}", + id, index, selectedService.HostAndPort.DownstreamHost, selectedService.HostAndPort.DownstreamPort); + + return new OkResponse(selectedService.HostAndPort); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при балансировке запроса"); + return new ErrorResponse(new ServicesAreEmptyError($"Ошибка балансировки: {ex.Message}")); + } + } + + private int ExtractIdFromQuery(HttpContext context) + { + if (context.Request.Query.TryGetValue(queryParameterName, out var idString)) + { + if (int.TryParse(idString, out var id)) + { + _logger.LogDebug("Извлечён id={Id} из запроса", id); + return id; + } + + _logger.LogWarning("Параметр {Param} содержит не число: {Value}", + queryParameterName, idString); + } + else + { + _logger.LogDebug("Параметр {Param} отсутствует в запросе", queryParameterName); + } + + return 0; + } + + public void Release(ServiceHostAndPort hostAndPort) + { } +} \ No newline at end of file diff --git a/ProgramProject.Gateway/Program.cs b/ProgramProject.Gateway/Program.cs new file mode 100644 index 00000000..560aea35 --- /dev/null +++ b/ProgramProject.Gateway/Program.cs @@ -0,0 +1,48 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using ProgramProject.Gateway.LoadBalancers; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +Console.OutputEncoding = System.Text.Encoding.UTF8; + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowClient", policy => + { + policy.SetIsOriginAllowed(origin => + { + try + { + var uri = new Uri(origin); + return uri.Host == "localhost"; + } + catch + { + return false; + } + }) + .WithMethods("GET") + .AllowAnyHeader() + .AllowCredentials(); + }); +}); + +builder.Services.AddOcelot() + .AddCustomLoadBalancer((sp, route, discoveryProvider) => + { + var logger = sp.GetRequiredService>(); + return new QueryBasedLoadBalancer(async () => (await discoveryProvider.GetAsync()).ToList(), logger, + route.LoadBalancerOptions?.Key ?? "id"); + }); + +builder.Services.AddServiceDiscovery(); + +var app = builder.Build(); + +app.UseCors("AllowClient"); + +await app.UseOcelot(); +app.Run(); \ No newline at end of file diff --git a/ProgramProject.Gateway/ProgramProject.Gateway.csproj b/ProgramProject.Gateway/ProgramProject.Gateway.csproj new file mode 100644 index 00000000..79d0b9b7 --- /dev/null +++ b/ProgramProject.Gateway/ProgramProject.Gateway.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/ProgramProject.Gateway/Properties/launchSettings.json b/ProgramProject.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..20dfc15d --- /dev/null +++ b/ProgramProject.Gateway/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:37101", + "sslPort": 44300 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5232", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7088;http://localhost:5232", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProgramProject.Gateway/appsettings.Development.json b/ProgramProject.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProgramProject.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProgramProject.Gateway/appsettings.json b/ProgramProject.Gateway/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/ProgramProject.Gateway/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ProgramProject.Gateway/ocelot.json b/ProgramProject.Gateway/ocelot.json new file mode 100644 index 00000000..dff23ed0 --- /dev/null +++ b/ProgramProject.Gateway/ocelot.json @@ -0,0 +1,42 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/api/projects", + "DownstreamScheme": "http", + "UpstreamPathTemplate": "/api/projects", + "UpstreamHttpMethod": [ "GET" ], + "HttpHandlerOptions": { + "AllowAutoRedirect": false + }, + "LoadBalancerOptions": { + "Type": "QueryBasedLoadBalancer", + "Key": "id" + }, + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 6201 + }, + { + "Host": "localhost", + "Port": 6202 + }, + { + "Host": "localhost", + "Port": 6203 + }, + { + "Host": "localhost", + "Port": 6204 + }, + { + "Host": "localhost", + "Port": 6205 + } + ] + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5232" + } +} \ No newline at end of file diff --git a/ProgramProject.GenerationService/Controllers/ProjectsController.cs b/ProgramProject.GenerationService/Controllers/ProjectsController.cs new file mode 100644 index 00000000..81560add --- /dev/null +++ b/ProgramProject.GenerationService/Controllers/ProjectsController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using ProgramProject.GenerationService.Models; +using ProgramProject.GenerationService.Services; + +namespace ProgramProject.GenerationService.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class ProjectsController(IProjectService projectService, ILogger logger) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(typeof(ProgramProjectModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetProject([FromQuery] int id) + { + if (id <= 0) + { + return BadRequest("ID должен быть положительным числом"); + } + + try + { + logger.LogInformation("Запрос проекта с ID {ProjectId}", id); + var project = await projectService.GetProjectByIdAsync(id); + return Ok(project); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при обработке запроса проекта ID {ProjectId}", id); + return StatusCode(500, "Внутренняя ошибка сервера"); + } + } +} \ No newline at end of file diff --git a/ProgramProject.GenerationService/Generator/IProgramProjectFaker.cs b/ProgramProject.GenerationService/Generator/IProgramProjectFaker.cs new file mode 100644 index 00000000..639bb602 --- /dev/null +++ b/ProgramProject.GenerationService/Generator/IProgramProjectFaker.cs @@ -0,0 +1,8 @@ +using ProgramProject.GenerationService.Models; + +namespace ProgramProject.GenerationService.Generator; + +public interface IProgramProjectFaker +{ + public ProgramProjectModel Generate(); +} diff --git a/ProgramProject.GenerationService/Generator/ProgramProjectFaker.cs b/ProgramProject.GenerationService/Generator/ProgramProjectFaker.cs new file mode 100644 index 00000000..903997dd --- /dev/null +++ b/ProgramProject.GenerationService/Generator/ProgramProjectFaker.cs @@ -0,0 +1,73 @@ +using Bogus; +using ProgramProject.GenerationService.Models; + +namespace ProgramProject.GenerationService.Generator; + +/// +/// Генератор нового проекта +/// +public class ProgramProjectFaker : IProgramProjectFaker +{ + private readonly Faker _faker; + + public ProgramProjectFaker() + { + _faker = new Faker("ru") + .RuleFor(p => p.Id, f => f.IndexVariable++) + + // Название проекта: комбинация из Commerce, Hacker, Finance, Lorem + .RuleFor(p => p.Name, f => + f.PickRandom( + f.Commerce.ProductName() + " " + f.Hacker.Abbreviation(), + "Project " + f.Hacker.Noun(), + f.Finance.AccountName() + " System", + f.Lorem.Word() + "-" + f.Lorem.Word() + )) + + // Заказчик — компания + .RuleFor(p => p.Customer, f => f.Company.CompanyName()) + + // Менеджер проекта — полное имя + .RuleFor(p => p.Manager, f => f.Name.FullName()) + + // Дата начала + .RuleFor(p => p.StartDate, f => + { + var start = f.Date.Past(5); + return DateOnly.FromDateTime(start); + }) + + // Плановая дата завершения: позже даты начала + .RuleFor(p => p.PlannedEndDate, (f, p) => + { + var planned = f.Date.Soon(180, p.StartDate.ToDateTime(TimeOnly.MinValue)); + return DateOnly.FromDateTime(planned); + }) + + // Бюджет: от 10k до 1M + .RuleFor(p => p.Budget, f => f.Finance.Amount(10000, 1000000, 2)) + + // Процент выполнения: от 0 до 100 + .RuleFor(p => p.CompletionPercentage, f => f.Random.Int(0, 100)) + + // Фактические затраты: пропорциональны бюджету (50-120%) + .RuleFor(p => p.ActualCost, (f, p) => + f.Finance.Amount(p.Budget * 0.5m, p.Budget * 1.2m, 2)) + + // Фактическая дата завершения: заполняется только если процент = 100 + .RuleFor(p => p.ActualEndDate, (f, p) => + { + if (p.CompletionPercentage == 100) + { + var actual = f.Date.Recent(30); + return DateOnly.FromDateTime(actual); + } + return null; + }); + } + /// + /// Метод вызова генерации + /// + public ProgramProjectModel Generate() => + _faker.Generate(); +} diff --git a/ProgramProject.GenerationService/Models/ProgramProjectModel.cs b/ProgramProject.GenerationService/Models/ProgramProjectModel.cs new file mode 100644 index 00000000..e7e95a1b --- /dev/null +++ b/ProgramProject.GenerationService/Models/ProgramProjectModel.cs @@ -0,0 +1,48 @@ +namespace ProgramProject.GenerationService.Models; + +/// +/// Модель программного проекта +/// +public class ProgramProjectModel +{ + /// + /// Идетификатор в системе + /// + public int Id { get; set; } + /// + /// Название проекта + /// + public string Name { get; set; } = string.Empty; + /// + /// Заказчик проекта + /// + public string Customer { get; set; } = string.Empty; + /// + /// Мененджер проекта + /// + public string Manager { get; set; } = string.Empty; + /// + /// Дата начала + /// + public DateOnly StartDate { get; set; } + /// + /// Плановая дата завершения + /// + public DateOnly PlannedEndDate { get; set; } + /// + /// Фактическая дата завершения + /// + public DateOnly? ActualEndDate { get; set; } + /// + /// Бюджет + /// + public decimal Budget { get; set; } + /// + /// Фактические затраты + /// + public decimal ActualCost { get; set; } + /// + /// Процент выполнения + /// + public int CompletionPercentage { get; set; } +} diff --git a/ProgramProject.GenerationService/Program.cs b/ProgramProject.GenerationService/Program.cs new file mode 100644 index 00000000..6d59c3df --- /dev/null +++ b/ProgramProject.GenerationService/Program.cs @@ -0,0 +1,44 @@ +using Amazon.Runtime; +using Amazon.SQS; +using ProgramProject.GenerationService.Generator; +using ProgramProject.GenerationService.Services; +using ProgramProject.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +Console.OutputEncoding = System.Text.Encoding.UTF8; + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("cache"); + +var sqsServiceUrl = builder.Configuration["SQS:ServiceURL"] ?? "http://localhost:9324"; +var sqsConfig = new AmazonSQSConfig +{ + ServiceURL = sqsServiceUrl, + UseHttp = true, + AuthenticationRegion = "us-east-1" +}; +builder.Services.AddSingleton(sp => new AmazonSQSClient(new AnonymousAWSCredentials(), sqsConfig)); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.UseCors("AllowClient"); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/ProgramProject.GenerationService/ProgramProject.GenerationService.csproj b/ProgramProject.GenerationService/ProgramProject.GenerationService.csproj new file mode 100644 index 00000000..1043ce05 --- /dev/null +++ b/ProgramProject.GenerationService/ProgramProject.GenerationService.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/ProgramProject.GenerationService/Properties/launchSettings.json b/ProgramProject.GenerationService/Properties/launchSettings.json new file mode 100644 index 00000000..f4dd3674 --- /dev/null +++ b/ProgramProject.GenerationService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:24595", + "sslPort": 44302 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5191", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7190;http://localhost:5191", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProgramProject.GenerationService/Services/IProjectService.cs b/ProgramProject.GenerationService/Services/IProjectService.cs new file mode 100644 index 00000000..8464b3b2 --- /dev/null +++ b/ProgramProject.GenerationService/Services/IProjectService.cs @@ -0,0 +1,8 @@ +using ProgramProject.GenerationService.Models; + +namespace ProgramProject.GenerationService.Services; + +public interface IProjectService +{ + public Task GetProjectByIdAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/ProgramProject.GenerationService/Services/ProjectService.cs b/ProgramProject.GenerationService/Services/ProjectService.cs new file mode 100644 index 00000000..5c427a9b --- /dev/null +++ b/ProgramProject.GenerationService/Services/ProjectService.cs @@ -0,0 +1,110 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using Microsoft.Extensions.Caching.Distributed; +using ProgramProject.GenerationService.Generator; +using ProgramProject.GenerationService.Models; +using System.Text.Json; + +namespace ProgramProject.GenerationService.Services; + +/// +/// Сервис работы с кэшем и с объектами +/// +public class ProjectService : IProjectService +{ + private readonly IDistributedCache _cache; + private readonly IProgramProjectFaker _faker; + private readonly ILogger _logger; + private readonly DistributedCacheEntryOptions _cacheOptions; + private readonly IAmazonSQS _sqsClient; + private readonly string _queueUrl; + + public ProjectService( + IDistributedCache cache, + IProgramProjectFaker faker, + ILogger logger, + IConfiguration configuration, + IAmazonSQS sqsClient) + { + _cache = cache; + _faker = faker; + _logger = logger; + _sqsClient = sqsClient; + + var cacheMinutes = configuration.GetValue("Cache:ExpirationMinutes", 5); + _cacheOptions = new DistributedCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromMinutes(cacheMinutes)); + + _queueUrl = configuration["SQS:QueueUrl"] ?? "http://localhost:9324/queue/projects"; + } + + public async Task GetProjectByIdAsync(int id, CancellationToken cancellationToken = default) + { + var cacheKey = $"project:{id}"; + + try + { + // Получаем из кэша + var cachedBytes = await _cache.GetAsync(cacheKey, cancellationToken); + + if (cachedBytes != null) + { + var cachedProject = JsonSerializer.Deserialize(cachedBytes); + + if (cachedProject != null) + { + _logger.LogInformation("Проект с ID {ProjectId} найден в кэше", id); + return cachedProject; + } + + _logger.LogWarning("Проект с ID {ProjectId} найден в кэше, но повреждён. Удаляем.", id); + await _cache.RemoveAsync(cacheKey, cancellationToken); + } + + _logger.LogInformation("Проект с ID {ProjectId} не найден в кэше. Генерируем новый", id); + + // Генерируем новый проект + var newProject = _faker.Generate(); + newProject.Id = id; + + // Сохраняем в кэш + var serializedProject = JsonSerializer.SerializeToUtf8Bytes(newProject); + await _cache.SetAsync(cacheKey, serializedProject, _cacheOptions, cancellationToken); + + _logger.LogInformation("Проект с ID {ProjectId} сгенерирован и сохранён в кэш", id); + + //Отправляем проект в SQS + await SendToSqsAsync(newProject, cancellationToken); + + return newProject; + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при получении проекта с ID {ProjectId}", id); + throw; + } + } + + /// + /// Отправка проекта в очередь SQS для последующего сохранения в Minio + /// + private async Task SendToSqsAsync(ProgramProjectModel project, CancellationToken cancellationToken) + { + try + { + var json = JsonSerializer.Serialize(project); + var sendRequest = new SendMessageRequest + { + QueueUrl = _queueUrl, + MessageBody = json + }; + + await _sqsClient.SendMessageAsync(sendRequest, cancellationToken); + _logger.LogInformation("Проект с ID {ProjectId} отправлен в SQS", project.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка при отправке проекта {ProjectId} в SQS", project.Id); + } + } +} \ No newline at end of file diff --git a/ProgramProject.GenerationService/appsettings.Development.json b/ProgramProject.GenerationService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProgramProject.GenerationService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProgramProject.GenerationService/appsettings.json b/ProgramProject.GenerationService/appsettings.json new file mode 100644 index 00000000..8c5e1be1 --- /dev/null +++ b/ProgramProject.GenerationService/appsettings.json @@ -0,0 +1,16 @@ +{ + "Cache": { + "ExpirationMinutes": 5 + }, + "SQS": { + "ServiceURL": "http://localhost:9324", + "QueueUrl": "http://localhost:9324/queue/projects" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/ProgramProject.IntegrationTests/AppHostFixture.cs b/ProgramProject.IntegrationTests/AppHostFixture.cs new file mode 100644 index 00000000..1c6dc2b6 --- /dev/null +++ b/ProgramProject.IntegrationTests/AppHostFixture.cs @@ -0,0 +1,116 @@ +using Amazon.Runtime; +using Amazon.S3; +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ProgramProject.IntegrationTests; + + +public class AppHostFixture : IAsyncLifetime +{ + public DistributedApplication App { get; private set; } = null!; + public IAmazonS3 S3Client { get; private set; } = null!; + public string SqsUrl { get; private set; } = null!; + + public async Task InitializeAsync() + { + var appHost = await DistributedApplicationTestingBuilder + .CreateAsync(); + + appHost.Services.ConfigureHttpClientDefaults(http => + http.AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(120); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(60); + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(120); + })); + + App = await appHost.BuildAsync(); + await App.StartAsync(); + + // Ждём готовности компонентов + await App.ResourceNotifications.WaitForResourceHealthyAsync("cache").WaitAsync(TimeSpan.FromSeconds(60)); + await App.ResourceNotifications.WaitForResourceHealthyAsync("generator-1").WaitAsync(TimeSpan.FromSeconds(60)); + await App.ResourceNotifications.WaitForResourceHealthyAsync("gateway").WaitAsync(TimeSpan.FromSeconds(60)); + + // Небольшая задержка для Minio + await Task.Delay(TimeSpan.FromSeconds(5)); + + // Получаем реальные URL от Aspire + using var minioClient = App.CreateHttpClient("minio", "api"); + var minioUrl = minioClient.BaseAddress!.ToString().TrimEnd('/'); + + using var sqsClient = App.CreateHttpClient("elasticmq", "http"); + SqsUrl = sqsClient.BaseAddress!.ToString().TrimEnd('/'); + + S3Client = new AmazonS3Client( + new BasicAWSCredentials("minioadmin", "minioadmin"), + new AmazonS3Config + { + ServiceURL = minioUrl, + ForcePathStyle = true, + AuthenticationRegion = "us-east-1" + }); + + // Убедимся, что бакет projects существует + try + { + try + { + await S3Client.GetBucketLocationAsync("projects"); + Console.WriteLine("Бакет projects существует"); + } + catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucket") + { + await S3Client.PutBucketAsync("projects"); + Console.WriteLine("Бакет projects создан"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Ошибка при проверке/создании бакета: {ex.Message}"); + } + } + + /// + /// Ожидает появления файла в Minio по указанному префиксу + /// + public async Task> WaitForS3ObjectAsync(string prefix, int maxAttempts = 15) + { + for (var i = 0; i < maxAttempts; i++) + { + try + { + var listResponse = await S3Client.ListObjectsV2Async(new Amazon.S3.Model.ListObjectsV2Request + { + BucketName = "projects", + Prefix = prefix + }); + + if (listResponse.S3Objects.Count > 0) + return listResponse.S3Objects; + } + catch (Exception ex) + { + Console.WriteLine($"Ошибка при проверке Minio (попытка {i + 1}): {ex.Message}"); + } + + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + return new List(); + } + + public async Task DisposeAsync() + { + S3Client?.Dispose(); + try + { + await App.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(30)); + } + catch (TimeoutException) { } + catch (OperationCanceledException) { } + } +} \ No newline at end of file diff --git a/ProgramProject.IntegrationTests/IntegrationTests.cs b/ProgramProject.IntegrationTests/IntegrationTests.cs new file mode 100644 index 00000000..e82c8c28 --- /dev/null +++ b/ProgramProject.IntegrationTests/IntegrationTests.cs @@ -0,0 +1,150 @@ +using Aspire.Hosting.Testing; +using ProgramProject.GenerationService.Models; +using System.Net; +using System.Net.Http.Json; +using Xunit; + +namespace ProgramProject.IntegrationTests; + +/// +/// Интеграционные тесты, проверяющие корректную совместную работу всех сервисов бекенда +/// +public class IntegrationTests(AppHostFixture fixture) : IClassFixture +{ + /// + /// Проверяет, что API отвечает на запрос с валидным ID + /// + [Fact] + public async Task Generator_ReturnsSuccess() + { + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + using var response = await httpClient.GetAsync("/api/projects?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// Проверяет, что запрос с конкретным ID возвращает корректный объект программного проекта + /// + [Fact] + public async Task Generator_GetById_ReturnsValidProject() + { + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + using var response = await httpClient.GetAsync("/api/projects?id=42"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var project = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(project); + Assert.Equal(42, project.Id); + Assert.False(string.IsNullOrEmpty(project.Name)); + Assert.False(string.IsNullOrEmpty(project.Customer)); + Assert.False(string.IsNullOrEmpty(project.Manager)); + Assert.True(project.Budget > 0); + Assert.InRange(project.CompletionPercentage, 0, 100); + } + + /// + /// Проверяет работу Redis-кэширования: Два последовательных запроса с одинаковым ID должны вернуть идентичные данные + /// + [Fact] + public async Task Redis_CachingWorks_ReturnsCachedData() + { + var testId = new Random().Next(100, 1000); + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + + using var firstResponse = await httpClient.GetAsync($"/api/projects?id={testId}"); + var firstContent = await firstResponse.Content.ReadAsStringAsync(); + + using var secondResponse = await httpClient.GetAsync($"/api/projects?id={testId}"); + var secondContent = await secondResponse.Content.ReadAsStringAsync(); + + Assert.Equal(firstContent, secondContent); + } + + /// + /// Проверяет, что генератор возвращает разные данные для разных ID + /// + [Fact] + public async Task DifferentIds_ReturnDifferentProjects() + { + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + + var project1 = await httpClient.GetFromJsonAsync("/api/projects?id=1001"); + var project2 = await httpClient.GetFromJsonAsync("/api/projects?id=1002"); + + Assert.NotNull(project1); + Assert.NotNull(project2); + Assert.Equal(1001, project1.Id); + Assert.Equal(1002, project2.Id); + Assert.NotEqual(project1.Name, project2.Name); + } + + /// + /// Проверяет, что все поля модели программного проекта заполнены корректно + /// + [Fact] + public async Task AllFieldsPopulated() + { + var testId = new Random().Next(2000, 3000); + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + + var project = await httpClient.GetFromJsonAsync($"/api/projects?id={testId}"); + + Assert.NotNull(project); + Assert.Equal(testId, project.Id); + Assert.False(string.IsNullOrEmpty(project.Name)); + Assert.False(string.IsNullOrEmpty(project.Customer)); + Assert.False(string.IsNullOrEmpty(project.Manager)); + Assert.NotEqual(default, project.StartDate); + Assert.NotEqual(default, project.PlannedEndDate); + Assert.True(project.Budget > 0); + Assert.True(project.ActualCost > 0); + Assert.InRange(project.CompletionPercentage, 0, 100); + + if (project.CompletionPercentage == 100) + { + Assert.NotNull(project.ActualEndDate); + } + } + + /// + /// Сквозной тест, проверяющий полный путь данных GenerationService → SQS → FileService → Minio. + /// + [Fact] + public async Task Minio_FileSaved() + { + using var client = fixture.App.CreateHttpClient("generator-1", "http"); + var testId = new Random().Next(5000, 6000); + + var response = await client.GetAsync($"/api/projects?id={testId}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var objects = await fixture.WaitForS3ObjectAsync($"project_{testId}.json"); + Assert.NotEmpty(objects); + } + + [Fact] + public async Task Minio_FileContentMatchesApiResponse() + { + var testId = new Random().Next(7000, 8000); + using var httpClient = fixture.App.CreateHttpClient("generator-1", "http"); + + var apiProject = await httpClient.GetFromJsonAsync($"/api/projects?id={testId}"); + Assert.NotNull(apiProject); + + var objects = await fixture.WaitForS3ObjectAsync($"project_{testId}.json"); + Assert.NotEmpty(objects); + + var getResponse = await fixture.S3Client.GetObjectAsync("projects", objects.First().Key); + using var reader = new StreamReader(getResponse.ResponseStream); + var json = await reader.ReadToEndAsync(); + var savedProject = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(savedProject); + Assert.Equal(apiProject.Id, savedProject.Id); + Assert.Equal(apiProject.Name, savedProject.Name); + Assert.Equal(apiProject.Customer, savedProject.Customer); + Assert.Equal(apiProject.Budget, savedProject.Budget); + } +} \ No newline at end of file diff --git a/ProgramProject.IntegrationTests/ProgramProject.IntegrationTests.csproj b/ProgramProject.IntegrationTests/ProgramProject.IntegrationTests.csproj new file mode 100644 index 00000000..e00e34a1 --- /dev/null +++ b/ProgramProject.IntegrationTests/ProgramProject.IntegrationTests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/ProgramProject.ServiceDefaults/Extensions.cs b/ProgramProject.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..b59822f6 --- /dev/null +++ b/ProgramProject.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace ProgramProject.ServiceDefaults; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/ProgramProject.ServiceDefaults/ProgramProject.ServiceDefaults.csproj b/ProgramProject.ServiceDefaults/ProgramProject.ServiceDefaults.csproj new file mode 100644 index 00000000..5d5bb49d --- /dev/null +++ b/ProgramProject.ServiceDefaults/ProgramProject.ServiceDefaults.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + + + + +