From 46f008aefaad40cec164106b799315155f0d4495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Thu, 12 Feb 2026 16:11:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + src/Host/Program.cs | 10 +- .../20260212130229_GeoInitial.Designer.cs | 61 +++++++ .../Migrations/20260212130229_GeoInitial.cs | 22 +++ .../20260212130220_OrderInitial.Designer.cs | 167 ++++++++++++++++++ .../Migrations/20260212130220_OrderInitial.cs | 22 +++ .../Commands/AddEvidenceCommand.cs | 39 ++++ .../Commands/CreateReviewCommand.cs | 46 +++++ .../Commands/OpenDisputeCommand.cs | 40 +++++ .../Commands/ResolveDisputeCommand.cs | 35 ++++ .../Events/DisputeOpenedIntegrationEvent.cs | 9 + .../Events/ReviewCreatedEventHandler.cs | 32 ++++ .../Queries/GetReviewsByTargetQuery.cs | 36 ++++ .../Reputation/Domain/Entities/Dispute.cs | 127 +++++++++++++ .../Domain/Entities/DisputeEvidence.cs | 58 ++++++ .../Reputation/Domain/Entities/Review.cs | 118 +++++++++++++ .../Reputation/Domain/Enums/DisputeStatus.cs | 27 +++ .../Domain/Events/ReviewCreatedEvent.cs | 20 +++ .../Domain/Repositories/IRepositories.cs | 23 +++ .../Domain/Services/RatingCalculator.cs | 40 +++++ .../BackgroundJobs/AutoReviewJob.cs | 66 +++++++ .../Infrastructure/DependencyInjection.cs | 36 ++++ .../20260212125841_InitialCreate.Designer.cs | 134 ++++++++++++++ .../20260212125841_InitialCreate.cs | 105 +++++++++++ .../ReputationDbContextModelSnapshot.cs | 131 ++++++++++++++ ...l.Modules.Reputation.Infrastructure.csproj | 1 + .../Configurations/Configurations.cs | 36 ++++ .../Repositories/DisputeRepository.cs | 34 ++++ .../Repositories/ReviewRepository.cs | 34 ++++ .../Persistence/ReputationDbContext.cs | 23 +++ ...hel.Modules.Reputation.Presentation.csproj | 17 +- .../Presentation/ReputationEndpoints.cs | 146 +++++++++++++++ .../Tests/Application/CreateReviewTests.cs | 44 +++++ .../Tests/Application/OpenDisputeTests.cs | 39 ++++ .../Reputation/Tests/Domain/DisputeTests.cs | 38 ++++ .../Reputation/Tests/Domain/ReviewTests.cs | 57 ++++++ .../Tests/Services/RatingCalculatorTests.cs | 58 ++++++ src/Modules/Reputation/Tests/Tests.csproj | 28 +++ 38 files changed, 1959 insertions(+), 4 deletions(-) create mode 100644 src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs create mode 100644 src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs create mode 100644 src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs create mode 100644 src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs create mode 100644 src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs create mode 100644 src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs create mode 100644 src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs create mode 100644 src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs create mode 100644 src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs create mode 100644 src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs create mode 100644 src/Modules/Reputation/Domain/Entities/Dispute.cs create mode 100644 src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs create mode 100644 src/Modules/Reputation/Domain/Entities/Review.cs create mode 100644 src/Modules/Reputation/Domain/Enums/DisputeStatus.cs create mode 100644 src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs create mode 100644 src/Modules/Reputation/Domain/Repositories/IRepositories.cs create mode 100644 src/Modules/Reputation/Domain/Services/RatingCalculator.cs create mode 100644 src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs create mode 100644 src/Modules/Reputation/Infrastructure/DependencyInjection.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs create mode 100644 src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs create mode 100644 src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs create mode 100644 src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs create mode 100644 src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs create mode 100644 src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs create mode 100644 src/Modules/Reputation/Presentation/ReputationEndpoints.cs create mode 100644 src/Modules/Reputation/Tests/Application/CreateReviewTests.cs create mode 100644 src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs create mode 100644 src/Modules/Reputation/Tests/Domain/DisputeTests.cs create mode 100644 src/Modules/Reputation/Tests/Domain/ReviewTests.cs create mode 100644 src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs create mode 100644 src/Modules/Reputation/Tests/Tests.csproj diff --git a/.gitignore b/.gitignore index f69adab..d6472e2 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,7 @@ src/Modules/Order/Tests/obj/ src/Modules/Collaboration/Tests/bin/ src/Modules/Collaboration/Tests/obj/ + +src/Modules/Reputation/Tests/obj/ + +src/Modules/Reputation/Tests/bin/ diff --git a/src/Host/Program.cs b/src/Host/Program.cs index d325846..56f68e0 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -12,6 +12,8 @@ using Nashel.Modules.Order.Infrastructure; using Nashel.Modules.Order.Presentation; using Nashel.Modules.Collaboration.Infrastructure; using Nashel.Modules.Collaboration.Presentation; +using Nashel.Modules.Reputation.Infrastructure; +using Nashel.Modules.Reputation.Presentation; var builder = WebApplication.CreateBuilder(args); @@ -62,10 +64,15 @@ builder.Services.AddCatalogModule(builder.Configuration); // Регистрация модуля Geo builder.Services.AddGeoModule(builder.Configuration); -// Регистрация модуля Order // Регистрация модуля Collaboartion builder.Services.AddCollaborationModule(builder.Configuration); +// Регистрация модуля Order +builder.Services.AddOrderModule(builder.Configuration); + +// Регистрация модуля Reputation +builder.Services.AddReputationModule(builder.Configuration); + // Настройка аутентификации builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -106,5 +113,6 @@ app.MapCatalogEndpoints(); app.MapGeoEndpoints(); app.MapOrderEndpoints(); app.MapCollaborationEndpoints(); +app.MapReputationEndpoints(); app.Run(); diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs new file mode 100644 index 0000000..f58cb7b --- /dev/null +++ b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.Designer.cs @@ -0,0 +1,61 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Geo.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Geo.Infrastructure.Migrations +{ + [DbContext(typeof(GeoDbContext))] + [Migration("20260212130229_GeoInitial")] + partial class GeoInitial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Geo.Domain.Aggregates.LiveStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusyUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .IsRequired() + .HasColumnType("geography (point)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Location"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Location"), "gist"); + + b.ToTable("LiveStatuses", "geo"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs new file mode 100644 index 0000000..a4fcfe3 --- /dev/null +++ b/src/Modules/Geo/Infrastructure/Migrations/20260212130229_GeoInitial.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Geo.Infrastructure.Migrations +{ + /// + public partial class GeoInitial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs new file mode 100644 index 0000000..54cc57d --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.Designer.cs @@ -0,0 +1,167 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Order.Infrastructure.Persistence; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + [DbContext(typeof(OrderDbContext))] + [Migration("20260212130220_OrderInitial")] + partial class OrderInitial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("ordering") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CustomerId") + .HasColumnType("uuid"); + + b.Property("Deadline") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedPerformerId") + .HasColumnType("uuid"); + + b.Property("ServiceId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Orders", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.ToTable("OrderApplications", "ordering"); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.OrderLocation", "Location", b1 => + { + b1.Property("OrderId") + .HasColumnType("uuid"); + + b1.Property("Address") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Address"); + + b1.Property("Point") + .IsRequired() + .HasColumnType("geography (point)"); + + b1.HasKey("OrderId"); + + b1.HasIndex("Point"); + + NpgsqlIndexBuilderExtensions.HasMethod(b1.HasIndex("Point"), "gist"); + + b1.ToTable("Orders", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderId"); + }); + + b.Navigation("Location") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Entities.OrderApplication", b => + { + b.HasOne("Nashel.Modules.Order.Domain.Aggregates.Order", null) + .WithMany("Applications") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Nashel.Modules.Order.Domain.ValueObjects.Money", "Price", b1 => + { + b1.Property("OrderApplicationId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.HasKey("OrderApplicationId"); + + b1.ToTable("OrderApplications", "ordering"); + + b1.WithOwner() + .HasForeignKey("OrderApplicationId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Order.Domain.Aggregates.Order", b => + { + b.Navigation("Applications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs new file mode 100644 index 0000000..d0a1377 --- /dev/null +++ b/src/Modules/Order/Infrastructure/Migrations/20260212130220_OrderInitial.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Order.Infrastructure.Migrations +{ + /// + public partial class OrderInitial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs b/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs new file mode 100644 index 0000000..3797b80 --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/AddEvidenceCommand.cs @@ -0,0 +1,39 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +/// +/// Добавление улик к спору. +/// +public record AddEvidenceCommand(Guid DisputeId, Guid UploaderId, string FileUrl) : IRequest; + +public class AddEvidenceHandler : IRequestHandler +{ + private readonly IDisputeRepository _repository; + + public AddEvidenceHandler(IDisputeRepository repository) + { + _repository = repository; + } + + public async Task Handle(AddEvidenceCommand request, CancellationToken cancellationToken) + { + var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken); + if (dispute == null) + { + throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден."); + } + + var evidence = DisputeEvidence.Create( + request.DisputeId, + request.UploaderId, + request.FileUrl + ); + + dispute.AddEvidence(evidence); + + await _repository.UpdateAsync(dispute, cancellationToken); + } +} diff --git a/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs b/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs new file mode 100644 index 0000000..58c4c1a --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/CreateReviewCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +/// +/// Команда создания отзыва. +/// +public record CreateReviewCommand(Guid OrderId, Guid AuthorId, Guid TargetId, int Rating, string Text) : IRequest; + +public class CreateReviewHandler : IRequestHandler +{ + private readonly IReviewRepository _repository; + private readonly IMediator _mediator; + + public CreateReviewHandler(IReviewRepository repository, IMediator mediator) + { + _repository = repository; + _mediator = mediator; + } + + public async Task Handle(CreateReviewCommand request, CancellationToken cancellationToken) + { + // В реальном приложении здесь должна быть проверка существования заказа и ролей. + + var review = Review.Create( + request.OrderId, + request.AuthorId, + request.TargetId, + request.Rating, + request.Text + ); + + await _repository.AddAsync(review, cancellationToken); + + // Публикация доменных событий + foreach (var domainEvent in review.DomainEvents) + { + await _mediator.Publish(domainEvent, cancellationToken); + } + review.ClearDomainEvents(); + + return review.Id; + } +} diff --git a/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs b/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs new file mode 100644 index 0000000..fd9b547 --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/OpenDisputeCommand.cs @@ -0,0 +1,40 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +/// +/// Команда на открытие спора (арбитраж). +/// +public record OpenDisputeCommand(Guid OrderId, Guid InitiatorId, string Reason) : IRequest; + +public class OpenDisputeHandler : IRequestHandler +{ + private readonly IDisputeRepository _repository; + private readonly IPublisher _publisher; + + public OpenDisputeHandler(IDisputeRepository repository, IPublisher publisher) + { + _repository = repository; + _publisher = publisher; + } + + public async Task Handle(OpenDisputeCommand request, CancellationToken cancellationToken) + { + // В реальном приложении: проверка существования заказа и роли инициатора. + + var dispute = Dispute.Open( + request.OrderId, + request.InitiatorId, + request.Reason + ); + + await _repository.AddAsync(dispute, cancellationToken); + + // Публикуем интеграционное событие для блокировки выплат в модуле Order + await _publisher.Publish(new Events.DisputeOpenedIntegrationEvent(dispute.Id, dispute.OrderId), cancellationToken); + + return dispute.Id; + } +} diff --git a/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs b/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs new file mode 100644 index 0000000..e12a99b --- /dev/null +++ b/src/Modules/Reputation/Application/Commands/ResolveDisputeCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Commands; + +/// +/// Команда для разрешения спора модератором/администратором. +/// +public record ResolveDisputeCommand(Guid DisputeId, string ResolutionDetails) : IRequest; + +public class ResolveDisputeHandler : IRequestHandler +{ + private readonly IDisputeRepository _repository; + + public ResolveDisputeHandler(IDisputeRepository repository) + { + _repository = repository; + } + + public async Task Handle(ResolveDisputeCommand request, CancellationToken cancellationToken) + { + var dispute = await _repository.GetByIdAsync(request.DisputeId, cancellationToken); + if (dispute == null) + { + throw new KeyNotFoundException($"Спор с ID {request.DisputeId} не найден."); + } + + dispute.Resolve(request.ResolutionDetails); + + await _repository.UpdateAsync(dispute, cancellationToken); + + // Здесь можно было бы отправить еще одно интеграционное событие, + // например, чтобы разблокировать выплату стороне-победителю в модуле Order. + } +} diff --git a/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs b/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs new file mode 100644 index 0000000..8617533 --- /dev/null +++ b/src/Modules/Reputation/Application/Events/DisputeOpenedIntegrationEvent.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace Nashel.Modules.Reputation.Application.Events; + +/// +/// Интеграционное событие: Спор открыт. +/// Используется для уведомления других модулей (например, модуля Order для блокировки оплаты). +/// +public record DisputeOpenedIntegrationEvent(Guid DisputeId, Guid OrderId) : INotification; diff --git a/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs b/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs new file mode 100644 index 0000000..1d07ae8 --- /dev/null +++ b/src/Modules/Reputation/Application/Events/ReviewCreatedEventHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using Nashel.Modules.Reputation.Domain.Events; +using Nashel.Modules.Reputation.Domain.Services; + +namespace Nashel.Modules.Reputation.Application.Events; + +/// +/// Обработчик события создания отзыва. +/// +public class ReviewCreatedEventHandler : INotificationHandler +{ + private readonly RatingCalculator _ratingCalculator; + private readonly ILogger _logger; + + public ReviewCreatedEventHandler(RatingCalculator ratingCalculator, ILogger logger) + { + _ratingCalculator = ratingCalculator; + _logger = logger; + } + + public async Task Handle(ReviewCreatedEvent notification, CancellationToken cancellationToken) + { + _logger.LogInformation("Пересчет рейтинга для пользователя {UserId} после нового отзыва...", notification.TargetId); + + var newAverageRating = await _ratingCalculator.CalculateAverage(notification.TargetId, cancellationToken); + + _logger.LogInformation("Новый средний рейтинг пользователя {UserId}: {Rating}", notification.TargetId, newAverageRating); + + // Здесь можно обновить кэш или таблицу профилей пользователей с их текущим рейтингом. + } +} diff --git a/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs new file mode 100644 index 0000000..f8849a6 --- /dev/null +++ b/src/Modules/Reputation/Application/Queries/GetReviewsByTargetQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Application.Queries; + +/// +/// Запрос получения отзывов по пользователю. +/// +/// ID пользователя. +public record GetReviewsByTargetQuery(Guid TargetId) : IRequest>; + +public record ReviewDto(Guid ReviewId, int Rating, string Text, string[] MediaUrls, DateTime CreatedAt); + +public class GetReviewsByTargetHandler : IRequestHandler> +{ + private readonly IReviewRepository _repository; + + public GetReviewsByTargetHandler(IReviewRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetReviewsByTargetQuery request, CancellationToken cancellationToken) + { + var reviews = await _repository.GetByTargetIdAsync(request.TargetId, cancellationToken); + + return reviews.Select(r => new ReviewDto( + r.Id, + r.Rating, + r.Text, + r.MediaUrls.ToArray(), + r.CreatedAt + )).ToList(); + } +} diff --git a/src/Modules/Reputation/Domain/Entities/Dispute.cs b/src/Modules/Reputation/Domain/Entities/Dispute.cs new file mode 100644 index 0000000..ab3ca4d --- /dev/null +++ b/src/Modules/Reputation/Domain/Entities/Dispute.cs @@ -0,0 +1,127 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Reputation.Domain.Enums; + +namespace Nashel.Modules.Reputation.Domain.Entities; + +/// +/// Агрегат Спора (Арбитража) по заказу. +/// +public class Dispute : AggregateRoot +{ + private DateTime _createdAt; + + /// + /// ID заказа, по которому возник спор. + /// + public Guid OrderId { get; private set; } + + /// + /// ID инициатора спора (кто открыл). + /// + public Guid InitiatorId { get; private set; } + + /// + /// Причина спора / Жалоба. + /// + public string Reason { get; private set; } + + /// + /// Текущий статус спора. + /// + public DisputeStatus Status { get; private set; } + + /// + /// Список улик / доказательств (фото/видео). + /// + private readonly List _evidences = new(); + public IReadOnlyCollection Evidences => _evidences.AsReadOnly(); + + /// + /// Дата открытия спора. + /// + public DateTime CreatedAt => _createdAt; + + // Для EF Core + private Dispute() + { + Reason = null!; + } + + private Dispute(Guid id, Guid orderId, Guid initiatorId, string reason) + { + Id = id; + OrderId = orderId; + InitiatorId = initiatorId; + Reason = reason; + Status = DisputeStatus.Created; + _createdAt = DateTime.UtcNow; + } + + /// + /// Открывает новый спор. + /// + public static Dispute Open(Guid orderId, Guid initiatorId, string reason) + { + var dispute = new Dispute(Guid.NewGuid(), orderId, initiatorId, reason); + // При создании сразу переводим в статус сбора улик + dispute.StartEvidenceCollection(); + return dispute; + } + + /// + /// Начинает этап сбора улик (72 часа на предоставление доказательств). + /// + public void StartEvidenceCollection() + { + Status = DisputeStatus.EvidenceCollection; + // Здесь можно было бы добавить Domain Event: DisputeEvidenceCollectionStarted + } + + /// + /// Добавляет улику (фото/видео) к спору. + /// + public void AddEvidence(DisputeEvidence evidence) + { + if (Status != DisputeStatus.EvidenceCollection) + { + throw new InvalidOperationException("Добавление улик возможно только на этапе сбора доказательств."); + } + + // Проверка времени 72 часа (можно сделать через Background Job, + // но здесь тоже проверим для надежности). + if (DateTime.UtcNow > _createdAt.AddHours(72)) + { + throw new InvalidOperationException("Время для сбора улик истекло (72 часа)."); + } + + _evidences.Add(evidence); + } + + /// + /// Переводит спор в статус ожидания решения модератора (после истечения времени сбора улик). + /// + public void MoveToPendingDecision() + { + if (Status != DisputeStatus.EvidenceCollection) + { + // Логика: можно перевести из Created, если сразу все предоставили? + // Допустим, только из EvidenceCollection. + } + + Status = DisputeStatus.PendingDecision; + } + + /// + /// Разрешает спор (администратором/модератором). + /// + public void Resolve(string resolutionDetails) + { + if (Status == DisputeStatus.Resolved) + { + throw new InvalidOperationException("Спор уже разрешен."); + } + + Status = DisputeStatus.Resolved; + // Здесь можно добавить Domain Event: DisputeResolvedEvent(Id, resolutionDetails) + } +} diff --git a/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs b/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs new file mode 100644 index 0000000..3ccae8b --- /dev/null +++ b/src/Modules/Reputation/Domain/Entities/DisputeEvidence.cs @@ -0,0 +1,58 @@ +using Nashel.BuildingBlocks.Domain; + +namespace Nashel.Modules.Reputation.Domain.Entities; + +/// +/// Улика (фото/видео/документ) в споре. +/// +public class DisputeEvidence : Entity +{ + private DateTime _createdAt; + + /// + /// ID спора. + /// + public Guid DisputeId { get; private set; } + + /// + /// ID пользователя, загрузившего улику. + /// + public Guid UploaderId { get; private set; } + + /// + /// Ссылка на файл (фото/видео). + /// + public string FileUrl { get; private set; } + + /// + /// Тип файла (image, video, document). + /// + public string FileType { get; private set; } + + /// + /// Дата загрузки. + /// + public DateTime CreatedAt => _createdAt; + + // Для EF Core + private DisputeEvidence() + { + FileUrl = null!; + FileType = null!; + } + + private DisputeEvidence(Guid id, Guid disputeId, Guid uploaderId, string fileUrl, string fileType) + { + Id = id; + DisputeId = disputeId; + UploaderId = uploaderId; + FileUrl = fileUrl; + FileType = fileType; + _createdAt = DateTime.UtcNow; + } + + public static DisputeEvidence Create(Guid disputeId, Guid uploaderId, string fileUrl, string fileType = "image") + { + return new DisputeEvidence(Guid.NewGuid(), disputeId, uploaderId, fileUrl, fileType); + } +} diff --git a/src/Modules/Reputation/Domain/Entities/Review.cs b/src/Modules/Reputation/Domain/Entities/Review.cs new file mode 100644 index 0000000..62738d2 --- /dev/null +++ b/src/Modules/Reputation/Domain/Entities/Review.cs @@ -0,0 +1,118 @@ +using Nashel.BuildingBlocks.Domain; +using Nashel.Modules.Reputation.Domain.Events; + +namespace Nashel.Modules.Reputation.Domain.Entities; + +/// +/// Отзыв о пользователе (исполнителе или заказчике). +/// +public class Review : AggregateRoot +{ + private DateTime _createdAt; + + /// + /// ID заказа, к которому относится отзыв. + /// + public Guid OrderId { get; private set; } + + /// + /// ID автора отзыва. + /// + public Guid AuthorId { get; private set; } + + /// + /// ID получателя отзыва (на кого отзыв). + /// + public Guid TargetId { get; private set; } + + /// + /// Оценка (рейтинг) от 1 до 5. + /// + public int Rating { get; private set; } + + /// + /// Текст отзыва. + /// + public string Text { get; private set; } + + /// + /// Список ссылок на медиа-файлы (фото). + /// + public List MediaUrls { get; private set; } + + /// + /// Флаг авто-сгенерированного отзыва (если нет отзыва в течение 7 дней). + /// + public bool IsAutoGenerated { get; private set; } + + /// + /// Дата создания отзыва. + /// + public DateTime CreatedAt => _createdAt; + + // Для EF Core + private Review() + { + Text = null!; + MediaUrls = null!; + } + + private Review(Guid id, Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated) + { + Id = id; + OrderId = orderId; + AuthorId = authorId; + TargetId = targetId; + Rating = rating; + Text = text; + IsAutoGenerated = isAutoGenerated; + MediaUrls = new List(); + _createdAt = DateTime.UtcNow; + + if (rating < 1 || rating > 5) + { + throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5."); + } + } + public static Review Create(Guid orderId, Guid authorId, Guid targetId, int rating, string text, bool isAutoGenerated = false) + { + var review = new Review(Guid.NewGuid(), orderId, authorId, targetId, rating, text, isAutoGenerated); + review.AddDomainEvent(new ReviewCreatedEvent(review.Id, targetId, rating)); + return review; + } + + /// + /// Добавляет ссылки на медиа. + /// + public void AddMedia(List urls) + { + // Можно проверить время редактирования + CheckIfEditable(); + MediaUrls.AddRange(urls); + } + + /// + /// Обновляет текст и рейтинг. + /// + public void Update(string text, int rating) + { + CheckIfEditable(); + + if (rating < 1 || rating > 5) + throw new ArgumentOutOfRangeException(nameof(rating), "Рейтинг должен быть от 1 до 5."); + + Text = text; + Rating = rating; + } + + /// + /// Проверяет, можно ли редактировать отзыв (доступно только в течение 3 дней). + /// + private void CheckIfEditable() + { + if (DateTime.UtcNow > _createdAt.AddDays(3)) + { + throw new InvalidOperationException("Редактирование отзыва доступно только в течение 3 дней после создания."); + } + } +} diff --git a/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs b/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs new file mode 100644 index 0000000..182df31 --- /dev/null +++ b/src/Modules/Reputation/Domain/Enums/DisputeStatus.cs @@ -0,0 +1,27 @@ +namespace Nashel.Modules.Reputation.Domain.Enums; + +/// +/// Статус спора (арбитража). +/// +public enum DisputeStatus +{ + /// + /// Спор создан, ожидание принятия. + /// + Created, + + /// + /// Сбор улик (72 часа). + /// + EvidenceCollection, + + /// + /// Ожидание решения модератора. + /// + PendingDecision, + + /// + /// Спор разрешен. + /// + Resolved +} diff --git a/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs b/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs new file mode 100644 index 0000000..d85fb78 --- /dev/null +++ b/src/Modules/Reputation/Domain/Events/ReviewCreatedEvent.cs @@ -0,0 +1,20 @@ +using Nashel.BuildingBlocks.Domain; + +namespace Nashel.Modules.Reputation.Domain.Events; + +/// +/// Событие создания отзыва. +/// +public class ReviewCreatedEvent : BaseDomainEvent +{ + public Guid ReviewId { get; } + public Guid TargetId { get; } + public int Rating { get; } + + public ReviewCreatedEvent(Guid reviewId, Guid targetId, int rating) + { + ReviewId = reviewId; + TargetId = targetId; + Rating = rating; + } +} diff --git a/src/Modules/Reputation/Domain/Repositories/IRepositories.cs b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs new file mode 100644 index 0000000..e80697d --- /dev/null +++ b/src/Modules/Reputation/Domain/Repositories/IRepositories.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Nashel.Modules.Reputation.Domain.Entities; + +namespace Nashel.Modules.Reputation.Domain.Repositories +{ + public interface IReviewRepository + { + Task AddAsync(Review review, CancellationToken cancellationToken = default); + Task> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + // Для пересчета среднего рейтинга можно использовать SQL-запрос, но здесь пока просто GetByTargetId + } + + public interface IDisputeRepository + { + Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default); + } +} diff --git a/src/Modules/Reputation/Domain/Services/RatingCalculator.cs b/src/Modules/Reputation/Domain/Services/RatingCalculator.cs new file mode 100644 index 0000000..90fe068 --- /dev/null +++ b/src/Modules/Reputation/Domain/Services/RatingCalculator.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Domain.Services; + +/// +/// Сервис для расчета рейтинга пользователей. +/// +public class RatingCalculator +{ + private readonly IReviewRepository _reviewRepository; + + public RatingCalculator(IReviewRepository reviewRepository) + { + _reviewRepository = reviewRepository; + } + + /// + /// Вычисляет средний рейтинг пользователя на основе всех его отзывов. + /// + /// Средний рейтинг (double) или 0, если отзывов нет. + public async Task CalculateAverage(Guid targetId, CancellationToken cancellationToken = default) + { + var reviews = await _reviewRepository.GetByTargetIdAsync(targetId, cancellationToken); + + if (reviews == null || !reviews.Any()) + { + return 0.0; + } + + // Простое среднее арифметическое + double avg = reviews.Average(r => r.Rating); + + // Округлим до 1 знака (опционально) + return Math.Round(avg, 1); + } +} diff --git a/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs new file mode 100644 index 0000000..7728d9c --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/BackgroundJobs/AutoReviewJob.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Nashel.Modules.Reputation.Infrastructure.BackgroundJobs +{ + public class AutoReviewJob : BackgroundService + { + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public AutoReviewJob(ILogger logger, IServiceProvider serviceProvider) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Запуск AutoReviewJob..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + _logger.LogInformation("AutoReviewJob: Проверка заказов без отзывов..."); + + // TODO: Реализовать логику авто-отзыва + // 1. Получить все завершенные заказы (из модуля Order или через Integration Events/View) + // 2. Отфильтровать те, которые завершены > 7 дней назад + // 3. Проверить, есть ли уже отзыв для этого заказа (в таблице Reviews) + // 4. Если отзыва нет -> создать Review с Rating=5 и IsAutoGenerated=true + // 5. Сохранить в БД (ReviewRepository.AddAsync) + + using (var scope = _serviceProvider.CreateScope()) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + var reviewRepo = scope.ServiceProvider.GetRequiredService(); + + // Логика авто-отзыва: + // 1. Ищем заказы, которые были завершены ровно 7 дней назад. + // Это можно сделать через интеграционное событие или запрос к Order Module. + // 2. Для каждого такого заказа проверяем, оставил ли уже клиент отзыв. + // 3. Если отзыва нет: + // Review autoReview = Review.Create(order.Id, Guid.Empty (System), order.EmployeeId, 5, "Авто-отзыв: Заказ успешно завершен.", isAutoGenerated: true); + // await reviewRepo.AddAsync(autoReview); + + logger.LogInformation("AutoReviewJob: Проверка завершена."); + } + + // Ждем 24 часа перед следующей проверкой + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Ошибка в AutoReviewJob"); + // Ждем немного перед повторной попыткой при ошибке + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/DependencyInjection.cs b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..021afc3 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/DependencyInjection.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Nashel.Modules.Reputation.Domain.Repositories; +using Nashel.Modules.Reputation.Domain.Services; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories; + +namespace Nashel.Modules.Reputation.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddReputationModule(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("DefaultConnection"); + + services.AddDbContext(options => + options.UseNpgsql(connectionString)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(Assembly.Load("Nashel.Modules.Reputation.Application")); + }); + + // Фоновые задачи + services.AddHostedService(); + + return services; + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs new file mode 100644 index 0000000..105588a --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.Designer.cs @@ -0,0 +1,134 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + [Migration("20260212125841_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs new file mode 100644 index 0000000..4f03fee --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/20260212125841_InitialCreate.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "reputation"); + + migrationBuilder.CreateTable( + name: "Disputes", + schema: "reputation", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + InitiatorId = table.Column(type: "uuid", nullable: false), + Reason = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Disputes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Reviews", + schema: "reputation", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + TargetId = table.Column(type: "uuid", nullable: false), + Rating = table.Column(type: "integer", nullable: false), + Text = table.Column(type: "text", nullable: false), + MediaUrls = table.Column>(type: "text[]", nullable: false), + IsAutoGenerated = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Reviews", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DisputeEvidence", + schema: "reputation", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DisputeId = table.Column(type: "uuid", nullable: false), + UploaderId = table.Column(type: "uuid", nullable: false), + FileUrl = table.Column(type: "text", nullable: false), + FileType = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DisputeEvidence", x => x.Id); + table.ForeignKey( + name: "FK_DisputeEvidence_Disputes_DisputeId", + column: x => x.DisputeId, + principalSchema: "reputation", + principalTable: "Disputes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DisputeEvidence_DisputeId", + schema: "reputation", + table: "DisputeEvidence", + column: "DisputeId"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_TargetId", + schema: "reputation", + table: "Reviews", + column: "TargetId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DisputeEvidence", + schema: "reputation"); + + migrationBuilder.DropTable( + name: "Reviews", + schema: "reputation"); + + migrationBuilder.DropTable( + name: "Disputes", + schema: "reputation"); + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs new file mode 100644 index 0000000..f0c7dc2 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Migrations/ReputationDbContextModelSnapshot.cs @@ -0,0 +1,131 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Reputation.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Reputation.Infrastructure.Migrations +{ + [DbContext(typeof(ReputationDbContext))] + partial class ReputationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("reputation") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Disputes", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisputeId") + .HasColumnType("uuid"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("UploaderId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DisputeId"); + + b.ToTable("DisputeEvidence", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.PrimitiveCollection>("MediaUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TargetId"); + + b.ToTable("Reviews", "reputation"); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.DisputeEvidence", b => + { + b.HasOne("Nashel.Modules.Reputation.Domain.Entities.Dispute", null) + .WithMany("Evidences") + .HasForeignKey("DisputeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nashel.Modules.Reputation.Domain.Entities.Dispute", b => + { + b.Navigation("Evidences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj index 71270f2..e1e7c63 100644 --- a/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj +++ b/src/Modules/Reputation/Infrastructure/Nashel.Modules.Reputation.Infrastructure.csproj @@ -6,6 +6,7 @@ + Nashel.Modules.Reputation.Infrastructure diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs new file mode 100644 index 0000000..468045f --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Persistence/Configurations/Configurations.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Nashel.Modules.Reputation.Domain.Entities; + +namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Configurations; + +public class ReviewConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.Id); + + // Индекс для быстрого поиска по TargetId + builder.HasIndex(r => r.TargetId); + + // MediaUrls хранится как простой JSON или Primitive Collection + // В EF Core 8+ есть поддержка Primitive Collections. + // Для Postgres Npgsql это text[] array по умолчанию. + // В 10-й версии это должно работать из коробки. + } +} + +public class DisputeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(d => d.Id); + builder.Property(d => d.Status).HasConversion(); + + // Отношение One-to-Many с Evidence + builder.HasMany(d => d.Evidences) + .WithOne() + .HasForeignKey(e => e.DisputeId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs new file mode 100644 index 0000000..8eb5d1a --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/DisputeRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories; + +public class DisputeRepository : IDisputeRepository +{ + private readonly ReputationDbContext _context; + + public DisputeRepository(ReputationDbContext context) + { + _context = context; + } + + public async Task AddAsync(Dispute dispute, CancellationToken cancellationToken = default) + { + await _context.Disputes.AddAsync(dispute, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Disputes + .Include(d => d.Evidences) + .FirstOrDefaultAsync(d => d.Id == id, cancellationToken); + } + + public async Task UpdateAsync(Dispute dispute, CancellationToken cancellationToken = default) + { + _context.Disputes.Update(dispute); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs new file mode 100644 index 0000000..c210ac3 --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Persistence/Repositories/ReviewRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; + +namespace Nashel.Modules.Reputation.Infrastructure.Persistence.Repositories; + +public class ReviewRepository : IReviewRepository +{ + private readonly ReputationDbContext _context; + + public ReviewRepository(ReputationDbContext context) + { + _context = context; + } + + public async Task AddAsync(Review review, CancellationToken cancellationToken = default) + { + await _context.Reviews.AddAsync(review, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Reviews.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default) + { + return await _context.Reviews + .Where(r => r.TargetId == targetId) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs b/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs new file mode 100644 index 0000000..42c7c2f --- /dev/null +++ b/src/Modules/Reputation/Infrastructure/Persistence/ReputationDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Reputation.Domain.Entities; + +namespace Nashel.Modules.Reputation.Infrastructure.Persistence; + +public class ReputationDbContext : DbContext +{ + public DbSet Reviews { get; set; } + public DbSet Disputes { get; set; } + + public ReputationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("reputation"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReputationDbContext).Assembly); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj b/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj index 064a0ff..68331e4 100644 --- a/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj +++ b/src/Modules/Reputation/Presentation/Nashel.Modules.Reputation.Presentation.csproj @@ -1,11 +1,22 @@ - - - Nashel.Modules.Reputation.Presentation net10.0 enable enable + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Reputation/Presentation/ReputationEndpoints.cs b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs new file mode 100644 index 0000000..76fb0d4 --- /dev/null +++ b/src/Modules/Reputation/Presentation/ReputationEndpoints.cs @@ -0,0 +1,146 @@ +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Nashel.Modules.Reputation.Application.Commands; +using Nashel.Modules.Reputation.Application.Queries; + +namespace Nashel.Modules.Reputation.Presentation; + +public static class ReputationEndpoints +{ + public static void MapReputationEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/reputation").WithTags("Reputation"); + + // POST /api/reputation/reviews + group.MapPost("/reviews", async ([FromBody] CreateReviewRequest request, ISender sender) => + { + var command = new CreateReviewCommand( + request.OrderId, + request.AuthorId, + request.TargetId, + request.Rating, + request.Text + ); + var reviewId = await sender.Send(command); + return Results.Ok(reviewId); + }) + .WithName("CreateReview") + .WithOpenApi(operation => new(operation) { Summary = "Создать отзыв", Description = "Оставить отзыв о пользователе по завершенному заказу." }); + + // GET /api/reputation/reviews/{userId} + group.MapGet("/reviews/{userId:guid}", async (Guid userId, ISender sender) => + { + var query = new GetReviewsByTargetQuery(userId); + var result = await sender.Send(query); + return Results.Ok(result); + }) + .WithName("GetUserReviews") + .WithOpenApi(operation => new(operation) { Summary = "Получить отзывы пользователя", Description = "Возвращает список отзывов для конкретного пользователя." }); + + // POST /api/reputation/disputes + group.MapPost("/disputes", async ([FromBody] OpenDisputeRequest request, ISender sender) => + { + var command = new OpenDisputeCommand(request.OrderId, request.InitiatorId, request.Reason); + var disputeId = await sender.Send(command); + return Results.Ok(disputeId); + }) + .WithName("OpenDispute") + .WithOpenApi(operation => new(operation) { Summary = "Открыть спор (арбитраж)", Description = "Начинает процедуру спора по заказу. Блокирует выплаты." }); + + // POST /api/reputation/disputes/{id}/evidence + group.MapPost("/disputes/{id:guid}/evidence", async (Guid id, [FromBody] AddEvidenceRequest request, ISender sender) => + { + var command = new AddEvidenceCommand(id, request.UploaderId, request.FileUrl); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("AddDisputeEvidence") + .WithOpenApi(operation => new(operation) { Summary = "Добавить улики к спору", Description = "Добавляет фото/видео доказательства. Доступно только в статусе EvidenceCollection." }); + + // POST /api/reputation/disputes/{id}/resolve + group.MapPost("/disputes/{id:guid}/resolve", async (Guid id, [FromBody] ResolveDisputeRequest request, ISender sender) => + { + var command = new ResolveDisputeCommand(id, request.ResolutionDetails); + await sender.Send(command); + return Results.Ok(); + }) + .WithName("ResolveDispute") + .WithOpenApi(operation => new(operation) { Summary = "Разрешить спор (Admin)", Description = "Принимает решение по спору. Доступно только администратору." }); + } +} + +/// +/// Запрос на разрешение спора. +/// +public record ResolveDisputeRequest(string ResolutionDetails); + +/// +/// Запрос на создание отзыва. +/// +public record CreateReviewRequest +{ + /// + /// ID заказа. + /// + public Guid OrderId { get; init; } + + /// + /// ID автора отзыва. + /// + public Guid AuthorId { get; init; } + + /// + /// ID получателя отзыва. + /// + public Guid TargetId { get; init; } + + /// + /// Оценка (1-5). + /// + public int Rating { get; init; } + + /// + /// Текст отзыва. + /// + public string Text { get; init; } = default!; +} + +/// +/// Запрос на открытие спора. +/// +public record OpenDisputeRequest +{ + /// + /// ID заказа. + /// + public Guid OrderId { get; init; } + + /// + /// ID инициатора. + /// + public Guid InitiatorId { get; init; } + + /// + /// Причина спора. + /// + public string Reason { get; init; } = default!; +} + +/// +/// Запрос на добавление улики. +/// +public record AddEvidenceRequest +{ + /// + /// ID загрузившего. + /// + public Guid UploaderId { get; init; } + + /// + /// Ссылка на файл. + /// + public string FileUrl { get; init; } = default!; +} diff --git a/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs new file mode 100644 index 0000000..04fbbad --- /dev/null +++ b/src/Modules/Reputation/Tests/Application/CreateReviewTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using MediatR; +using Moq; +using Nashel.Modules.Reputation.Application.Commands; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; +using Xunit; + +namespace Nashel.Modules.Reputation.Tests.Application; + +public class ReviewApplicationTests +{ + private readonly Mock _repositoryMock; + private readonly Mock _mediatorMock; + private readonly CreateReviewHandler _handler; + + public ReviewApplicationTests() + { + _repositoryMock = new Mock(); + _mediatorMock = new Mock(); + _handler = new CreateReviewHandler(_repositoryMock.Object, _mediatorMock.Object); + } + + [Fact] + public async Task СозданиеОтзыва_Должно_Сохранить_Отзыв_И_Опубликовать_События() + { + // 1. Arrange + var command = new CreateReviewCommand( + OrderId: Guid.NewGuid(), + AuthorId: Guid.NewGuid(), + TargetId: Guid.NewGuid(), + Rating: 5, + Text: "Отличный опыт!" + ); + + // 2. Act + var result = await _handler.Handle(command, CancellationToken.None); + + // 3. Assert + result.Should().NotBeEmpty(); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _mediatorMock.Verify(m => m.Publish(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } +} diff --git a/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs new file mode 100644 index 0000000..abec75a --- /dev/null +++ b/src/Modules/Reputation/Tests/Application/OpenDisputeTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using MediatR; +using Moq; +using Nashel.Modules.Reputation.Application.Commands; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; +using Nashel.Modules.Reputation.Application.Events; +using Xunit; + +namespace Nashel.Modules.Reputation.Tests.Application; + +public class OpenDisputeTests +{ + private readonly Mock _repositoryMock; + private readonly Mock _publisherMock; + private readonly OpenDisputeHandler _handler; + + public OpenDisputeTests() + { + _repositoryMock = new Mock(); + _publisherMock = new Mock(); + _handler = new OpenDisputeHandler(_repositoryMock.Object, _publisherMock.Object); + } + + [Fact] + public async Task Обработка_Должна_Создать_Спор_И_Опубликовать_Событие() + { + // Arrange + var request = new OpenDisputeCommand(Guid.NewGuid(), Guid.NewGuid(), "Причина"); + + // Act + var result = await _handler.Handle(request, CancellationToken.None); + + // Assert + result.Should().NotBeEmpty(); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _publisherMock.Verify(p => p.Publish(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Reputation/Tests/Domain/DisputeTests.cs b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs new file mode 100644 index 0000000..45aa99a --- /dev/null +++ b/src/Modules/Reputation/Tests/Domain/DisputeTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Enums; +using Xunit; + +namespace Nashel.Modules.Reputation.Tests.Domain; + +public class DisputeTests +{ + [Fact] + public void Открытие_Должно_Установить_Статус_Сбор_Улик() + { + // Arrange + var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема"); + + // Assert + dispute.Status.Should().Be(DisputeStatus.EvidenceCollection); + dispute.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ДобавлениеУлик_Должно_Провалиться_Если_Статус_Не_СборУлик() + { + // Arrange + var dispute = Dispute.Open(Guid.NewGuid(), Guid.NewGuid(), "Проблема"); + + // Принудительно меняем статус через разрешение спора + dispute.Resolve("Решено"); + + // Act + var evidence = DisputeEvidence.Create(dispute.Id, Guid.NewGuid(), "url.com"); + var action = () => dispute.AddEvidence(evidence); + + // Assert + action.Should().Throw() + .WithMessage("Добавление улик возможно только на этапе сбора доказательств."); + } +} diff --git a/src/Modules/Reputation/Tests/Domain/ReviewTests.cs b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs new file mode 100644 index 0000000..dca9afe --- /dev/null +++ b/src/Modules/Reputation/Tests/Domain/ReviewTests.cs @@ -0,0 +1,57 @@ +using FluentAssertions; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Events; +using Xunit; + +namespace Nashel.Modules.Reputation.Tests.Domain; + +public class ReviewTests +{ + [Fact] + public void Создание_Должно_Установить_Правильные_Свойства() + { + // Arrange + var orderId = Guid.NewGuid(); + var authorId = Guid.NewGuid(); + var targetId = Guid.NewGuid(); + + // Act + var review = Review.Create(orderId, authorId, targetId, 5, "Отличная работа"); + + // Assert + review.Rating.Should().Be(5); + review.Text.Should().Be("Отличная работа"); + review.IsAutoGenerated.Should().BeFalse(); + review.DomainEvents.Should().ContainSingle(e => e is ReviewCreatedEvent); + } + + [Theory] + [InlineData(0)] + [InlineData(6)] + public void Создание_Должно_Выбросить_Исключение_Когда_Рейтинг_Недействителен(int invalidRating) + { + // Act + var action = () => Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), invalidRating, "текст"); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void Обновление_Должно_Выбросить_Исключение_Через_3_Дня() + { + // Arrange + var review = Review.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), 5, "Текст"); + + // Используем рефлексию для изменения даты создания + var createdAtField = typeof(Review).GetField("_createdAt", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + createdAtField?.SetValue(review, DateTime.UtcNow.AddDays(-4)); + + // Act + var action = () => review.Update("Новый текст", 4); + + // Assert + action.Should().Throw() + .WithMessage("Редактирование отзыва доступно только в течение 3 дней после создания."); + } +} diff --git a/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs new file mode 100644 index 0000000..44f9ea1 --- /dev/null +++ b/src/Modules/Reputation/Tests/Services/RatingCalculatorTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Reputation.Domain.Entities; +using Nashel.Modules.Reputation.Domain.Repositories; +using Nashel.Modules.Reputation.Domain.Services; +using Xunit; + +namespace Nashel.Modules.Reputation.Tests.Services; + +public class RatingCalculatorTests +{ + private readonly Mock _repositoryMock; + private readonly RatingCalculator _service; + + public RatingCalculatorTests() + { + _repositoryMock = new Mock(); + _service = new RatingCalculator(_repositoryMock.Object); + } + + [Fact] + public async Task РасчетСреднего_Должен_Вернуть_Верное_Значение() + { + // 1. Arrange + var targetId = Guid.NewGuid(); + + var reviews = new List + { + Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 5, "Хорошо"), + Review.Create(Guid.NewGuid(), Guid.NewGuid(), targetId, 3, "Средне") + }; + // 5 + 3 = 8 / 2 = 4.0 + + _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny())) + .ReturnsAsync(reviews); + + // 2. Act + var average = await _service.CalculateAverage(targetId); + + // 3. Assert + average.Should().Be(4.0, "Средний рейтинг должен рассчитываться правильно."); + } + + [Fact] + public async Task РасчетСреднего_Должен_Вернуть_Ноль_Если_Отзывов_Нет() + { + // 1. Arrange + var targetId = Guid.NewGuid(); + _repositoryMock.Setup(r => r.GetByTargetIdAsync(targetId, It.IsAny())) + .ReturnsAsync(new List()); + + // 2. Act + var average = await _service.CalculateAverage(targetId); + + // 3. Assert + average.Should().Be(0.0, "Рейтинг должен быть 0, если отзывов нет."); + } +} diff --git a/src/Modules/Reputation/Tests/Tests.csproj b/src/Modules/Reputation/Tests/Tests.csproj new file mode 100644 index 0000000..6327b3f --- /dev/null +++ b/src/Modules/Reputation/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + +