diff --git a/.gitignore b/.gitignore index 2ea93b2..f1de997 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,7 @@ src/Modules/Collaboration/Domain/bin/ src/Modules/Collaboration/Presentation/bin/ src/Modules/Identity/Tests/obj/ + +src/Modules/Catalog/Tests/bin/ + +src/Modules/Catalog/Tests/obj/ diff --git a/src/Host/Nashel.Host.csproj b/src/Host/Nashel.Host.csproj index 85f5232..a4bac45 100644 --- a/src/Host/Nashel.Host.csproj +++ b/src/Host/Nashel.Host.csproj @@ -11,6 +11,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/Host/Program.cs b/src/Host/Program.cs index ec6cccb..0eb2cfc 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -2,6 +2,8 @@ using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; +using Nashel.Modules.Catalog.Infrastructure; +using Nashel.Modules.Catalog.Presentation.Endpoints; using Nashel.Modules.Identity.Infrastructure; using Nashel.Modules.Identity.Presentation.Endpoints; @@ -41,7 +43,6 @@ builder.Services.AddSwaggerGen(options => var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "*.xml"); foreach (var xmlFile in xmlFiles) { - // Исключаем системные сборки, берем только наши, если нужно, или просто все options.IncludeXmlComments(xmlFile); } }); @@ -49,6 +50,9 @@ builder.Services.AddSwaggerGen(options => // Регистрация модуля Identity builder.Services.AddIdentityModule(builder.Configuration); +// Регистрация модуля Catalog +builder.Services.AddCatalogModule(builder.Configuration); + // Настройка аутентификации builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -83,7 +87,8 @@ if (app.Environment.IsDevelopment()) app.UseAuthentication(); app.UseAuthorization(); -// Маппинг эндпоинтов модуля Identity +// Маппинг эндпоинтов app.MapIdentityEndpoints(); +app.MapCatalogEndpoints(); app.Run(); diff --git a/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs new file mode 100644 index 0000000..2e6c756 --- /dev/null +++ b/src/Modules/Catalog/Application/Commands/CreateCategoryCommand.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using MediatR; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; + +namespace Nashel.Modules.Catalog.Application.Commands; + +/// +/// Команда создания категории. +/// +public record CreateCategoryCommand : IRequest +{ + /// + /// Название категории. + /// + public string Title { get; init; } + + /// + /// URL-friendly идентификатор (слаг). + /// + public string Slug { get; init; } + + /// + /// ID родительской категории (null для корневых). + /// + public Guid? ParentId { get; init; } + + /// + /// Шаблон характеристик (JSON). + /// + public JsonDocument? AttributeSchema { get; init; } + + public CreateCategoryCommand(string title, string slug, Guid? parentId, JsonDocument? attributeSchema) + { + Title = title; + Slug = slug; + ParentId = parentId; + AttributeSchema = attributeSchema; + } + + public CreateCategoryCommand() { } // For deserialization +} + +public class CreateCategoryCommandHandler : IRequestHandler +{ + private readonly ICategoryRepository _repository; + + public CreateCategoryCommandHandler(ICategoryRepository repository) + { + _repository = repository; + } + + public async Task Handle(CreateCategoryCommand request, CancellationToken cancellationToken) + { + if (request.ParentId.HasValue) + { + var parentCategory = await _repository.GetByIdAsync(request.ParentId.Value, cancellationToken); + if (parentCategory == null) + { + throw new ApplicationException($"Родительская категория с ID {request.ParentId} не найдена."); + } + } + + var category = new Category( + request.Title, + request.Slug, + request.ParentId, + request.AttributeSchema + ); + + await _repository.AddAsync(category, cancellationToken); + + return category.Id; + } +} diff --git a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs new file mode 100644 index 0000000..099b18d --- /dev/null +++ b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using MediatR; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Domain.ValueObjects; + +namespace Nashel.Modules.Catalog.Application.Commands; + +/// +/// Команда создания услуги (оффера). +/// +public record CreateOfferCommand : IRequest +{ + /// + /// ID владельца (пользователя/исполнителя). + /// + public Guid OwnerId { get; init; } + + /// + /// ID категории услуги. + /// + public Guid CategoryId { get; init; } + + /// + /// Заголовок объявления. + /// + public string Title { get; init; } = default!; + + /// + /// Полное описание услуги. + /// + public string Description { get; init; } = default!; + + /// + /// Цена услуги. + /// + public Price Price { get; init; } = default!; + + /// + /// Характеристики услуги (JSON). + /// + public JsonDocument? Attributes { get; init; } + + public CreateOfferCommand(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes) + { + OwnerId = ownerId; + CategoryId = categoryId; + Title = title; + Description = description; + Price = price; + Attributes = attributes; + } + + public CreateOfferCommand() { } +} + +public class CreateOfferCommandHandler : IRequestHandler +{ + private readonly IOfferRepository _repository; + + public CreateOfferCommandHandler(IOfferRepository repository) + { + _repository = repository; + } + + public async Task Handle(CreateOfferCommand request, CancellationToken cancellationToken) + { + var offer = new Offer( + request.OwnerId, + request.CategoryId, + request.Title, + request.Description, + request.Price, + request.Attributes + ); + + await _repository.AddAsync(offer, cancellationToken); + + return offer.Id; + } +} diff --git a/src/Modules/Catalog/Application/Common/Dtos.cs b/src/Modules/Catalog/Application/Common/Dtos.cs new file mode 100644 index 0000000..3e51daf --- /dev/null +++ b/src/Modules/Catalog/Application/Common/Dtos.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using Nashel.Modules.Catalog.Domain.ValueObjects; + +namespace Nashel.Modules.Catalog.Application.Common; + +/// +/// DTO категории. +/// +public record CategoryDto( + Guid Id, + string Title, + string Slug, + Guid? ParentId, + JsonDocument? AttributeSchema +); + +/// +/// DTO услуги/оффера. +/// +public record OfferDto( + Guid Id, + Guid OwnerId, + Guid CategoryId, + string Title, + string Description, + Price Price, + JsonDocument? Attributes, + bool IsActive +); diff --git a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj index a23d790..e9112a5 100644 --- a/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj +++ b/src/Modules/Catalog/Application/Nashel.Modules.Catalog.Application.csproj @@ -11,5 +11,7 @@ net10.0 enable enable + true + $(NoWarn);1591 \ No newline at end of file diff --git a/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs new file mode 100644 index 0000000..a71aaab --- /dev/null +++ b/src/Modules/Catalog/Application/Queries/GetCategoriesQuery.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using MediatR; +using Nashel.Modules.Catalog.Application.Common; +using Nashel.Modules.Catalog.Domain.Repositories; + +namespace Nashel.Modules.Catalog.Application.Queries; + +/// +/// Запрос дерева категорий. +/// +public record GetCategoriesQuery() : IRequest>; + +public class GetCategoriesQueryHandler : IRequestHandler> +{ + private readonly ICategoryRepository _repository; + + public GetCategoriesQueryHandler(ICategoryRepository repository) + { + _repository = repository; + } + + public async Task> Handle(GetCategoriesQuery request, CancellationToken cancellationToken) + { + var rawCategories = await _repository.GetAllAsync(cancellationToken); + + // Преобразование в DTO (для дерева логика нужна сложнее, но пока плоский список для старта) + // Если нужно дерево: нужно иметь DTO с List Children + // Для MVP возвращаем плоский список, фронтенд сам строит дерево по ParentId + + return rawCategories.Select(c => new CategoryDto( + c.Id, + c.Title, + c.Slug, + c.ParentId, + c.AttributeSchema + )).ToList(); + } +} diff --git a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs new file mode 100644 index 0000000..14dabcb --- /dev/null +++ b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using Nashel.Modules.Catalog.Application.Common; +using Nashel.Modules.Catalog.Domain.Repositories; + +namespace Nashel.Modules.Catalog.Application.Queries; + +/// +/// Запрос деталей услуги по ID. +/// +public record GetOfferByIdQuery(Guid Id) : IRequest; + +public class GetOfferByIdQueryHandler : IRequestHandler +{ + private readonly IOfferRepository _repository; + + public GetOfferByIdQueryHandler(IOfferRepository repository) + { + _repository = repository; + } + + public async Task Handle(GetOfferByIdQuery request, CancellationToken cancellationToken) + { + var offer = await _repository.GetByIdAsync(request.Id, cancellationToken); + if (offer == null) return null; + + return new OfferDto( + offer.Id, + offer.OwnerId, + offer.CategoryId, + offer.Title, + offer.Description, + offer.Price, + offer.Attributes, + offer.IsActive + ); + } +} diff --git a/src/Modules/Catalog/Domain/Aggregates/Category.cs b/src/Modules/Catalog/Domain/Aggregates/Category.cs new file mode 100644 index 0000000..9dc1e76 --- /dev/null +++ b/src/Modules/Catalog/Domain/Aggregates/Category.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Nashel.Modules.Catalog.Domain.Aggregates; + +/// +/// Категория услуг. +/// +public class Category +{ + /// + /// Уникальный идентификатор. + /// + public Guid Id { get; private set; } + + /// + /// Название категории. + /// + public string Title { get; private set; } + + /// + /// URL-friendly идентификатор (слаг). + /// + public string Slug { get; private set; } + + /// + /// ID родительской категории (null для корневых). + /// + public Guid? ParentId { get; private set; } + + /// + /// Шаблон характеристик (JSON). + /// + public JsonDocument? AttributeSchema { get; private set; } + + // Конструктор по умолчанию для EF Core + private Category() { } + + /// + /// Создает новую категорию. + /// + public Category(string title, string slug, Guid? parentId, JsonDocument? attributeSchema = null) + { + Id = Guid.NewGuid(); + Title = title; + Slug = slug; + ParentId = parentId; + AttributeSchema = attributeSchema; + } +} diff --git a/src/Modules/Catalog/Domain/Aggregates/Offer.cs b/src/Modules/Catalog/Domain/Aggregates/Offer.cs new file mode 100644 index 0000000..d61c32d --- /dev/null +++ b/src/Modules/Catalog/Domain/Aggregates/Offer.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using Nashel.Modules.Catalog.Domain.ValueObjects; + +namespace Nashel.Modules.Catalog.Domain.Aggregates; + +/// +/// Услуга или оффер исполнителя. +/// +public class Offer +{ + /// + /// Уникальный идентификатор оффера. + /// + public Guid Id { get; private set; } + + /// + /// ID владельца (пользователя/исполнителя). + /// + public Guid OwnerId { get; private set; } + + /// + /// ID услуги/категории. + /// + public Guid CategoryId { get; private set; } + + /// + /// Заголовок объявления. + /// + public string Title { get; private set; } + + /// + /// Полное описание. + /// + public string? Description { get; private set; } + + /// + /// Цена (сумма, валюта, тип оплаты). + /// + public Price Price { get; private set; } + + /// + /// Характеристики (JSONB). + /// + public JsonDocument? Attributes { get; private set; } + + /// + /// Активно ли объявление. + /// + public bool IsActive { get; private set; } + + // Конструктор по умолчанию для EF Core + private Offer() { } + + /// + /// Создает новый оффер. + /// + public Offer(Guid ownerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null) + { + Id = Guid.NewGuid(); + OwnerId = ownerId; + CategoryId = categoryId; + Title = title; + Description = description; + Price = price; + Attributes = attributes; + IsActive = true; + } +} diff --git a/src/Modules/Catalog/Domain/Enums/OfferType.cs b/src/Modules/Catalog/Domain/Enums/OfferType.cs new file mode 100644 index 0000000..bc4ce83 --- /dev/null +++ b/src/Modules/Catalog/Domain/Enums/OfferType.cs @@ -0,0 +1,22 @@ +namespace Nashel.Modules.Catalog.Domain.Enums; + +/// +/// Тип оплаты услуги. +/// +public enum OfferType +{ + /// + /// Фиксированная цена. + /// + Fixed, + + /// + /// Почасовая оплата. + /// + Hourly, + + /// + /// Договорная цена. + /// + Negotiable +} diff --git a/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj b/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj index 298795d..e040df2 100644 --- a/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj +++ b/src/Modules/Catalog/Domain/Nashel.Modules.Catalog.Domain.csproj @@ -9,6 +9,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs b/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs new file mode 100644 index 0000000..66f88c2 --- /dev/null +++ b/src/Modules/Catalog/Domain/Repositories/ICategoryRepository.cs @@ -0,0 +1,10 @@ +using Nashel.Modules.Catalog.Domain.Aggregates; + +namespace Nashel.Modules.Catalog.Domain.Repositories; + +public interface ICategoryRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task AddAsync(Category category, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs new file mode 100644 index 0000000..91eb766 --- /dev/null +++ b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs @@ -0,0 +1,9 @@ +using Nashel.Modules.Catalog.Domain.Aggregates; + +namespace Nashel.Modules.Catalog.Domain.Repositories; + +public interface IOfferRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task AddAsync(Offer offer, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Catalog/Domain/ValueObjects/Price.cs b/src/Modules/Catalog/Domain/ValueObjects/Price.cs new file mode 100644 index 0000000..44c8b86 --- /dev/null +++ b/src/Modules/Catalog/Domain/ValueObjects/Price.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Nashel.Modules.Catalog.Domain.Enums; + +namespace Nashel.Modules.Catalog.Domain.ValueObjects; + +/// +/// Значение цены услуги. +/// +public record Price +{ + /// + /// Сумма. + /// + public decimal Amount { get; init; } + + /// + /// Валюта (по умолчанию RUB). + /// + public string Currency { get; init; } = "RUB"; + + /// + /// Тип оплаты (фиксированная, почасовая, договорная). + /// + public OfferType Type { get; init; } + + // Конструктор по умолчанию для EF Core и сериализации + public Price() { } + + public Price(decimal amount, OfferType type, string currency = "RUB") + { + Amount = amount; + Type = type; + Currency = currency; + } +} diff --git a/src/Modules/Catalog/Infrastructure/DependencyInjection.cs b/src/Modules/Catalog/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..65b04de --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/DependencyInjection.cs @@ -0,0 +1,34 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Nashel.Modules.Catalog.Application.Commands; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Nashel.Modules.Catalog.Infrastructure.Repositories; + +namespace Nashel.Modules.Catalog.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddCatalogModule(this IServiceCollection services, IConfiguration configuration) + { + // 1. Регистрация DbContext + services.AddDbContext(options => + { + options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")); + }); + + // 2. Репозитории + services.AddScoped(); + services.AddScoped(); + + // 3. MediatR (сканируем сборку Application) + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(typeof(CreateCategoryCommand).Assembly); + }); + + return services; + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs b/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs new file mode 100644 index 0000000..efca6ba --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/CatalogDbContext.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Catalog.Domain.Aggregates; + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence; + +public class CatalogDbContext : DbContext +{ + public DbSet Categories { get; set; } = null!; + public DbSet Offers { get; set; } = null!; + + public CatalogDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasPostgresExtension("postgis"); // Если нужно для чего-то еще, но вообще JSONB встроен. + // Но лучше просто не трогать расширения если не уверены. + // Для JSONB ничего особенного не нужно, кроме HasColumnType("jsonb"). + + modelBuilder.Entity(entity => + { + entity.ToTable("Categories", "catalog"); + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Slug).IsUnique(); + + // Self-referencing + entity.HasOne() + .WithMany() // Навигационное свойство Children не добавлено в доменную модель явно, но связь есть + .HasForeignKey(e => e.ParentId) + .OnDelete(DeleteBehavior.Restrict); + + entity.Property(e => e.AttributeSchema) + .HasColumnType("jsonb"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Offers", "catalog"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.Attributes) + .HasColumnType("jsonb"); + + // GIN Index + entity.HasIndex(e => e.Attributes) + .HasMethod("gin"); + + entity.OwnsOne(e => e.Price, price => + { + price.Property(p => p.Amount).HasColumnName("PriceAmount"); + price.Property(p => p.Currency).HasColumnName("PriceCurrency").HasMaxLength(3); + price.Property(p => p.Type).HasColumnName("PriceType"); // Enum as int by default + }); + }); + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs new file mode 100644 index 0000000..d697d61 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.Designer.cs @@ -0,0 +1,137 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260210114511_InitialCreate")] + partial class InitialCreate + { + /// + 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.Catalog.Domain.Aggregates.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeSchema") + .HasColumnType("jsonb"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .HasColumnType("jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Attributes"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin"); + + b.ToTable("Offers", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 => + { + b1.Property("OfferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("PriceType"); + + b1.HasKey("OfferId"); + + b1.ToTable("Offers", "catalog"); + + b1.WithOwner() + .HasForeignKey("OfferId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.cs new file mode 100644 index 0000000..42f6093 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260210114511_InitialCreate.cs @@ -0,0 +1,98 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "catalog"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:postgis", ",,"); + + migrationBuilder.CreateTable( + name: "Categories", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "text", nullable: false), + Slug = table.Column(type: "text", nullable: false), + ParentId = table.Column(type: "uuid", nullable: true), + AttributeSchema = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Categories", x => x.Id); + table.ForeignKey( + name: "FK_Categories_Categories_ParentId", + column: x => x.ParentId, + principalSchema: "catalog", + principalTable: "Categories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Offers", + schema: "catalog", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OwnerId = table.Column(type: "uuid", nullable: false), + CategoryId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + PriceAmount = table.Column(type: "numeric", nullable: false), + PriceCurrency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + PriceType = table.Column(type: "integer", nullable: false), + Attributes = table.Column(type: "jsonb", nullable: true), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Offers", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Categories_ParentId", + schema: "catalog", + table: "Categories", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_Categories_Slug", + schema: "catalog", + table: "Categories", + column: "Slug", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Offers_Attributes", + schema: "catalog", + table: "Offers", + column: "Attributes") + .Annotation("Npgsql:IndexMethod", "gin"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Categories", + schema: "catalog"); + + migrationBuilder.DropTable( + name: "Offers", + schema: "catalog"); + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs new file mode 100644 index 0000000..4233490 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs @@ -0,0 +1,134 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + partial class CatalogDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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.Catalog.Domain.Aggregates.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeSchema") + .HasColumnType("jsonb"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .HasColumnType("jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Attributes"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin"); + + b.ToTable("Offers", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 => + { + b1.Property("OfferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("PriceType"); + + b1.HasKey("OfferId"); + + b1.ToTable("Offers", "catalog"); + + b1.WithOwner() + .HasForeignKey("OfferId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Repositories/CategoryRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/CategoryRepository.cs new file mode 100644 index 0000000..8abf34d --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Repositories/CategoryRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Infrastructure.Persistence; + +namespace Nashel.Modules.Catalog.Infrastructure.Repositories; + +public class CategoryRepository : ICategoryRepository +{ + private readonly CatalogDbContext _context; + + public CategoryRepository(CatalogDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Categories + .Include(c => c.AttributeSchema) // Not needed as it's a property now, but sometimes needed if navigation property (JsonDocument is property) + .FirstOrDefaultAsync(c => c.Id == id, cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await _context.Categories.ToListAsync(cancellationToken); + } + + public async Task AddAsync(Category category, CancellationToken cancellationToken = default) + { + await _context.Categories.AddAsync(category, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs new file mode 100644 index 0000000..fb13abe --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Infrastructure.Persistence; + +namespace Nashel.Modules.Catalog.Infrastructure.Repositories; + +public class OfferRepository : IOfferRepository +{ + private readonly CatalogDbContext _context; + + public OfferRepository(CatalogDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Offers + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + public async Task AddAsync(Offer offer, CancellationToken cancellationToken = default) + { + await _context.Offers.AddAsync(offer, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs new file mode 100644 index 0000000..a5e76da --- /dev/null +++ b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs @@ -0,0 +1,56 @@ +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Nashel.Modules.Catalog.Application.Commands; +using Nashel.Modules.Catalog.Application.Queries; + +namespace Nashel.Modules.Catalog.Presentation.Endpoints; + +/// +/// API Каталога (Категории и Услуги). +/// +public static class CatalogEndpoints +{ + public static void MapCatalogEndpoints(this IEndpointRouteBuilder app) + { + var catalogGroup = app.MapGroup("/api/catalog").WithTags("Catalog"); + + // --- Категории --- + + catalogGroup.MapPost("/categories", async ([FromBody] CreateCategoryCommand command, ISender sender) => + { + var id = await sender.Send(command); + return Results.Ok(id); + }) + .WithName("CreateCategory") + .WithSummary("Создать категорию (Admin)"); + + catalogGroup.MapGet("/categories", async (ISender sender) => + { + var result = await sender.Send(new GetCategoriesQuery()); + return Results.Ok(result); + }) + .WithName("GetCategories") + .WithSummary("Получить все категории (плоский список с ParentId)"); + + // --- Услуги (Offers) --- + + catalogGroup.MapPost("/offers", async ([FromBody] CreateOfferCommand command, ISender sender) => + { + var id = await sender.Send(command); + return Results.Ok(id); + }) + .WithName("CreateOffer") + .WithSummary("Создать оффер/услугу"); + + catalogGroup.MapGet("/offers/{id:guid}", async (Guid id, ISender sender) => + { + var result = await sender.Send(new GetOfferByIdQuery(id)); + return result is not null ? Results.Ok(result) : Results.NotFound(); + }) + .WithName("GetOfferById") + .WithSummary("Получить детали услуги по ID"); + } +} diff --git a/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj b/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj index 2050ec5..7839126 100644 --- a/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj +++ b/src/Modules/Catalog/Presentation/Nashel.Modules.Catalog.Presentation.csproj @@ -7,5 +7,10 @@ net10.0 enable enable + true + $(NoWarn);1591 + + + \ No newline at end of file diff --git a/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs new file mode 100644 index 0000000..39ce86d --- /dev/null +++ b/src/Modules/Catalog/Tests/Application/CreateCategoryCommandHandlerTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Catalog.Application.Commands; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Application; + +public class CreateCategoryCommandHandlerTests +{ + private readonly Mock _categoryRepositoryMock; + private readonly CreateCategoryCommandHandler _handler; + + public CreateCategoryCommandHandlerTests() + { + _categoryRepositoryMock = new Mock(); + _handler = new CreateCategoryCommandHandler(_categoryRepositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldCreateCategory_WhenCommandIsValid() + { + // Arrange + var command = new CreateCategoryCommand( + "Test Category", + "test-category", + null, + null + ); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().Be(result); // Guid should be returned + _categoryRepositoryMock.Verify(r => r.AddAsync(It.Is(c => + c.Title == command.Title && + c.Slug == command.Slug && + c.ParentId == command.ParentId && + c.AttributeSchema == command.AttributeSchema + ), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs new file mode 100644 index 0000000..0ca2a6f --- /dev/null +++ b/src/Modules/Catalog/Tests/Application/CreateOfferCommandHandlerTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Catalog.Application.Commands; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Enums; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Domain.ValueObjects; +using System.Text.Json; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Application; + +public class CreateOfferCommandHandlerTests +{ + private readonly Mock _offerRepositoryMock; + private readonly CreateOfferCommandHandler _handler; + + public CreateOfferCommandHandlerTests() + { + _offerRepositoryMock = new Mock(); + _handler = new CreateOfferCommandHandler(_offerRepositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldCreateOffer_WhenCommandIsValid() + { + // Arrange + var command = new CreateOfferCommand( + Guid.NewGuid(), + Guid.NewGuid(), + "Test Offer", + "Test Description", + new Price(100, OfferType.Fixed, "RUB"), + JsonDocument.Parse("{}") + ); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().Be(result); // Guid should be returned + _offerRepositoryMock.Verify(r => r.AddAsync(It.Is(o => + o.Title == command.Title && + o.OwnerId == command.OwnerId && + o.CategoryId == command.CategoryId && + o.Description == command.Description && + o.Price.Amount == command.Price.Amount && + o.Price.Type == command.Price.Type && + o.Price.Currency == command.Price.Currency && + o.Attributes == command.Attributes + ), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs new file mode 100644 index 0000000..7a54ed0 --- /dev/null +++ b/src/Modules/Catalog/Tests/Application/GetCategoriesQueryHandlerTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Catalog.Application.Common; +using Nashel.Modules.Catalog.Application.Queries; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using System.Text.Json; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Application; + +public class GetCategoriesQueryHandlerTests +{ + private readonly Mock _categoryRepositoryMock; + private readonly GetCategoriesQueryHandler _handler; + + public GetCategoriesQueryHandlerTests() + { + _categoryRepositoryMock = new Mock(); + _handler = new GetCategoriesQueryHandler(_categoryRepositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnCategories_WhenCategoriesExist() + { + // Arrange + var categories = new List + { + new Category("Category 1", "category-1", null), + new Category("Category 2", "category-2", null) + }; + _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(categories); + + var query = new GetCategoriesQuery(); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + + var expectedDto1 = new CategoryDto(categories[0].Id, categories[0].Title, categories[0].Slug, categories[0].ParentId, categories[0].AttributeSchema); + var expectedDto2 = new CategoryDto(categories[1].Id, categories[1].Title, categories[1].Slug, categories[1].ParentId, categories[1].AttributeSchema); + + result.Should().ContainEquivalentOf(expectedDto1); + result.Should().ContainEquivalentOf(expectedDto2); + } + + [Fact] + public async Task Handle_ShouldReturnEmptyList_WhenNoCategoriesExist() + { + // Arrange + _categoryRepositoryMock.Setup(r => r.GetAllAsync(It.IsAny())) + .ReturnsAsync(new List()); + + var query = new GetCategoriesQuery(); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } +} diff --git a/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs new file mode 100644 index 0000000..d4409e2 --- /dev/null +++ b/src/Modules/Catalog/Tests/Application/GetOfferByIdQueryHandlerTests.cs @@ -0,0 +1,79 @@ +using FluentAssertions; +using Moq; +using Nashel.Modules.Catalog.Application.Common; +using Nashel.Modules.Catalog.Application.Queries; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Domain.ValueObjects; +using Nashel.Modules.Catalog.Domain.Enums; +using System.Text.Json; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Application; + +public class GetOfferByIdQueryHandlerTests +{ + private readonly Mock _offerRepositoryMock; + private readonly GetOfferByIdQueryHandler _handler; + + public GetOfferByIdQueryHandlerTests() + { + _offerRepositoryMock = new Mock(); + _handler = new GetOfferByIdQueryHandler(_offerRepositoryMock.Object); + } + + [Fact] + public async Task Handle_ShouldReturnOffer_WhenOfferExists() + { + // Arrange + var offerId = Guid.NewGuid(); + var offer = new Offer( + Guid.NewGuid(), + Guid.NewGuid(), + "Test Offer", + "Test Description", + new Price(100, OfferType.Fixed, "RUB"), + JsonDocument.Parse("{}") + ); + + // Reflection hack to set the ID as it's private set in constructor (Guid.NewGuid()) + // But wait, the constructor sets a new GUID. + // We need to mock the repository to return the offer when GetByIdAsync is called with the offer's generated ID. + // Or cleaner: modify the repository setup to ignore the specific ID argument or capture it? + // No, let's just use the ID generated by the offer. + + _offerRepositoryMock.Setup(r => r.GetByIdAsync(offer.Id, It.IsAny())) + .ReturnsAsync(offer); + + var query = new GetOfferByIdQuery(offer.Id); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(offer.Id); + result.Title.Should().Be("Test Offer"); + result.Description.Should().Be("Test Description"); + result.Price.Should().BeEquivalentTo(offer.Price); + result.OwnerId.Should().Be(offer.OwnerId); + result.CategoryId.Should().Be(offer.CategoryId); + result.Attributes.Should().Be(offer.Attributes); + } + + [Fact] + public async Task Handle_ShouldReturnNull_WhenOfferDoesNotExist() + { + // Arrange + _offerRepositoryMock.Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Offer?)null); + + var query = new GetOfferByIdQuery(Guid.NewGuid()); + + // Act + var result = await _handler.Handle(query, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } +} diff --git a/src/Modules/Catalog/Tests/Domain/CategoryTests.cs b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs new file mode 100644 index 0000000..2f44dd4 --- /dev/null +++ b/src/Modules/Catalog/Tests/Domain/CategoryTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Nashel.Modules.Catalog.Domain.Aggregates; +using System.Text.Json; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Domain; + +public class CategoryTests +{ + [Fact] + public void Constructor_ShouldSetPropertiesCorrectly() + { + // Assemble + var title = "Test Category"; + var slug = "test-category"; + var parentId = Guid.NewGuid(); + var jsonDoc = JsonDocument.Parse("{}"); + + // Act + var category = new Category(title, slug, parentId, jsonDoc); + + // Assert + category.Id.Should().NotBeEmpty(); + category.Title.Should().Be(title); + category.Slug.Should().Be(slug); + category.ParentId.Should().Be(parentId); + category.AttributeSchema.Should().Be(jsonDoc); + } + + [Fact] + public void Constructor_ShouldSetDefaultValues_WhenOptionalParametersAreNull() + { + // Act + var category = new Category("Test", "test", null, null); + + // Assert + category.ParentId.Should().BeNull(); + category.AttributeSchema.Should().BeNull(); + } +} diff --git a/src/Modules/Catalog/Tests/Domain/OfferTests.cs b/src/Modules/Catalog/Tests/Domain/OfferTests.cs new file mode 100644 index 0000000..4c0f450 --- /dev/null +++ b/src/Modules/Catalog/Tests/Domain/OfferTests.cs @@ -0,0 +1,36 @@ +using FluentAssertions; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.ValueObjects; +using Nashel.Modules.Catalog.Domain.Enums; +using System.Text.Json; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Domain; + +public class OfferTests +{ + [Fact] + public void Constructor_ShouldSetPropertiesCorrectly() + { + // Assemble + var ownerId = Guid.NewGuid(); + var categoryId = Guid.NewGuid(); + var title = "Test Offer"; + var description = "Test Description"; + var price = new Price(100, OfferType.Fixed, "RUB"); + var attributes = JsonDocument.Parse("{}"); + + // Act + var offer = new Offer(ownerId, categoryId, title, description, price, attributes); + + // Assert + offer.Id.Should().NotBeEmpty(); + offer.OwnerId.Should().Be(ownerId); + offer.CategoryId.Should().Be(categoryId); + offer.Title.Should().Be(title); + offer.Description.Should().Be(description); + offer.Price.Should().Be(price); + offer.Attributes.Should().Be(attributes); + offer.IsActive.Should().BeTrue(); + } +} diff --git a/src/Modules/Catalog/Tests/Domain/PriceTests.cs b/src/Modules/Catalog/Tests/Domain/PriceTests.cs new file mode 100644 index 0000000..a4e98e9 --- /dev/null +++ b/src/Modules/Catalog/Tests/Domain/PriceTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using Nashel.Modules.Catalog.Domain.Enums; +using Nashel.Modules.Catalog.Domain.ValueObjects; +using Xunit; + +namespace Nashel.Modules.Catalog.Tests.Domain; + +public class PriceTests +{ + [Fact] + public void Constructor_ShouldSetPropertiesCorrectly() + { + // Assemble + var amount = 100m; + var type = OfferType.Fixed; + var currency = "USD"; + + // Act + var price = new Price(amount, type, currency); + + // Assert + price.Amount.Should().Be(amount); + price.Type.Should().Be(type); + price.Currency.Should().Be(currency); + } + + [Fact] + public void Constructor_ShouldSetDefaultCurrency_WhenNotProvided() + { + // Act + var price = new Price(100m, OfferType.Fixed); + + // Assert + price.Currency.Should().Be("RUB"); + } + + [Fact] + public void Equality_ShouldBeTrue_WhenValuesAreSame() + { + // Arrange + var price1 = new Price(100m, OfferType.Fixed, "USD"); + var price2 = new Price(100m, OfferType.Fixed, "USD"); + + // Act & Assert + price1.Should().Be(price2); + } + + [Fact] + public void Equality_ShouldBeFalse_WhenValuesAreDifferent() + { + // Arrange + var price1 = new Price(100m, OfferType.Fixed, "USD"); + var price2 = new Price(200m, OfferType.Fixed, "USD"); + + // Act & Assert + price1.Should().NotBe(price2); + } +} diff --git a/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj new file mode 100644 index 0000000..a5d8f9e --- /dev/null +++ b/src/Modules/Catalog/Tests/Nashel.Modules.Catalog.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Identity/Application/Commands/LoginCommand.cs b/src/Modules/Identity/Application/Commands/LoginCommand.cs index 1cbc273..e81b6eb 100644 --- a/src/Modules/Identity/Application/Commands/LoginCommand.cs +++ b/src/Modules/Identity/Application/Commands/LoginCommand.cs @@ -6,7 +6,29 @@ using Nashel.Modules.Identity.Domain.Services; namespace Nashel.Modules.Identity.Application.Commands; -public record LoginCommand(string Phone, string Password) : IRequest; // Возвращает JWT +/// +/// Команда входа пользователя. +/// +public record LoginCommand : IRequest +{ + /// + /// Номер телефона. + /// + public string Phone { get; init; } = default!; + + /// + /// Пароль. + /// + public string Password { get; init; } = default!; + + public LoginCommand(string phone, string password) + { + Phone = phone; + Password = password; + } + + public LoginCommand() { } +} // Возвращает JWT public class LoginCommandHandler : IRequestHandler { diff --git a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs index 0704514..b1dfa02 100644 --- a/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs +++ b/src/Modules/Identity/Application/Commands/RegisterUserCommand.cs @@ -5,7 +5,29 @@ using Nashel.Modules.Identity.Domain.Repositories; namespace Nashel.Modules.Identity.Application.Commands; -public record RegisterUserCommand(string Phone, string Password) : IRequest; +/// +/// Команда регистрации нового пользователя. +/// +public record RegisterUserCommand : IRequest +{ + /// + /// Номер телефона пользователя. + /// + public string Phone { get; init; } = default!; + + /// + /// Пароль пользователя. + /// + public string Password { get; init; } = default!; + + public RegisterUserCommand(string phone, string password) + { + Phone = phone; + Password = password; + } + + public RegisterUserCommand() { } +} public class RegisterUserCommandHandler : IRequestHandler { diff --git a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj index 866ffdf..955cea5 100644 --- a/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj +++ b/src/Modules/Identity/Application/Nashel.Modules.Identity.Application.csproj @@ -11,5 +11,7 @@ net10.0 enable enable + true + $(NoWarn);1591 \ No newline at end of file diff --git a/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj b/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj index 1628e66..7717082 100644 --- a/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj +++ b/src/Modules/Identity/Domain/Nashel.Modules.Identity.Domain.csproj @@ -9,6 +9,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs new file mode 100644 index 0000000..e05c9b0 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.Designer.cs @@ -0,0 +1,57 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Identity.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260210114447_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Accounts", "identity"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs new file mode 100644 index 0000000..8259b94 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260210114447_InitialCreate.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "Accounts", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + Roles = table.Column(type: "jsonb", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Accounts_Phone", + schema: "identity", + table: "Accounts", + column: "Phone", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Accounts", + schema: "identity"); + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..d0a31cb --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,54 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Identity.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Identity.Domain.Aggregates.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Accounts", "identity"); + }); +#pragma warning restore 612, 618 + } + } +}