Реализация и тесты Geo модуля

This commit is contained in:
Халимов Рустам
2026-02-10 21:51:02 +03:00
parent 9d3999f690
commit 12c86f9937
24 changed files with 783 additions and 418 deletions

6
.gitignore vendored
View File

@@ -140,3 +140,9 @@ src/Modules/Identity/Tests/obj/
src/Modules/Catalog/Tests/bin/
src/Modules/Catalog/Tests/obj/
src/Modules/Geo/Tests/bin/
src/Modules/Geo/Tests/obj/
src/Modules/Geo/Presentation/bin/

View File

@@ -4,6 +4,8 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Nashel.Modules.Catalog.Infrastructure;
using Nashel.Modules.Catalog.Presentation.Endpoints;
using Nashel.Modules.Geo.Infrastructure;
using Nashel.Modules.Geo.Presentation;
using Nashel.Modules.Identity.Infrastructure;
using Nashel.Modules.Identity.Presentation.Endpoints;
@@ -53,6 +55,9 @@ builder.Services.AddIdentityModule(builder.Configuration);
// Регистрация модуля Catalog
builder.Services.AddCatalogModule(builder.Configuration);
// Регистрация модуля Geo
builder.Services.AddGeoModule(builder.Configuration);
// Настройка аутентификации
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
@@ -90,5 +95,6 @@ app.UseAuthorization();
// Маппинг эндпоинтов
app.MapIdentityEndpoints();
app.MapCatalogEndpoints();
app.MapGeoEndpoints();
app.Run();

View File

@@ -0,0 +1,91 @@
using MediatR;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Domain.Enums;
using Nashel.Modules.Geo.Domain.Repositories;
namespace Nashel.Modules.Geo.Application.Commands;
/// <summary>
/// Команда обновления координат и статуса.
/// </summary>
public record UpdateGeoCommand : IRequest<Unit>
{
/// <summary>
/// Идентификатор исполнителя.
/// </summary>
public Guid PerformerId { get; init; }
/// <summary>
/// Широта.
/// </summary>
public double Latitude { get; init; }
/// <summary>
/// Долгота.
/// </summary>
public double Longitude { get; init; }
/// <summary>
/// Новый статус.
/// </summary>
public PerformerStatus Status { get; init; }
public UpdateGeoCommand(Guid performerId, double latitude, double longitude, PerformerStatus status)
{
PerformerId = performerId;
Latitude = latitude;
Longitude = longitude;
Status = status;
}
public UpdateGeoCommand() { }
}
public class UpdateGeoCommandHandler : IRequestHandler<UpdateGeoCommand, Unit>
{
private readonly IGeoRepository _repository;
public UpdateGeoCommandHandler(IGeoRepository repository)
{
_repository = repository;
}
public async Task<Unit> Handle(UpdateGeoCommand request, CancellationToken cancellationToken)
{
var liveStatus = await _repository.GetByPerformerIdAsync(request.PerformerId, cancellationToken);
bool isNew = false;
if (liveStatus == null)
{
liveStatus = new LiveStatus(request.PerformerId, request.Latitude, request.Longitude);
isNew = true;
}
else
{
liveStatus.UpdateLocation(request.Latitude, request.Longitude);
}
switch (request.Status)
{
case PerformerStatus.Available:
liveStatus.GoOnline();
break;
case PerformerStatus.Busy:
// Установим дефолтное время занятости 1 час, если не указано иное
liveStatus.SetBusy(DateTime.UtcNow.AddHours(1));
break;
case PerformerStatus.DayOff:
liveStatus.GoOffline();
break;
}
if (isNew)
{
await _repository.AddAsync(liveStatus, cancellationToken);
}
await _repository.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -11,5 +11,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,49 @@
using MediatR;
using Nashel.Modules.Geo.Domain.Repositories;
namespace Nashel.Modules.Geo.Application.Queries;
/// <summary>
/// Запрос на поиск исполнителей в радиусе.
/// </summary>
public record SearchNearbyQuery : IRequest<List<Guid>>
{
/// <summary>
/// Широта центра поиска.
/// </summary>
public double Latitude { get; init; }
/// <summary>
/// Долгота центра поиска.
/// </summary>
public double Longitude { get; init; }
/// <summary>
/// Радиус поиска (в метрах).
/// </summary>
public double RadiusInMeters { get; init; }
public SearchNearbyQuery(double latitude, double longitude, double radiusInMeters)
{
Latitude = latitude;
Longitude = longitude;
RadiusInMeters = radiusInMeters;
}
public SearchNearbyQuery() { }
}
public class SearchNearbyQueryHandler : IRequestHandler<SearchNearbyQuery, List<Guid>>
{
private readonly IGeoRepository _repository;
public SearchNearbyQueryHandler(IGeoRepository repository)
{
_repository = repository;
}
public async Task<List<Guid>> Handle(SearchNearbyQuery request, CancellationToken cancellationToken)
{
return await _repository.GetNearbyAsync(request.Latitude, request.Longitude, request.RadiusInMeters, cancellationToken);
}
}

View File

@@ -0,0 +1,88 @@
using Nashel.BuildingBlocks.Domain;
using Nashel.Modules.Geo.Domain.Enums;
using NetTopologySuite.Geometries;
namespace Nashel.Modules.Geo.Domain.Aggregates;
/// <summary>
/// Агрегат, хранящий текущее статус и локацию исполнителя.
/// </summary>
public class LiveStatus : AggregateRoot<Guid>
{
private LiveStatus() { } // EF Core
/// <summary>
/// Создает новый статус.
/// </summary>
/// <param name="performerId">ID исполнителя.</param>
/// <param name="latitude">Широта (Lat).</param>
/// <param name="longitude">Долгота (Lon).</param>
public LiveStatus(Guid performerId, double latitude, double longitude)
{
Id = performerId;
Location = new Point(longitude, latitude) { SRID = 4326 };
Status = PerformerStatus.Available;
LastUpdated = DateTime.UtcNow;
}
/// <summary>
/// Геопозиция исполнителя. SRID 4326 (WGS 84).
/// </summary>
public Point Location { get; private set; }
/// <summary>
/// Статус доступности.
/// </summary>
public PerformerStatus Status { get; private set; }
/// <summary>
/// Время, до которого исполнитель занят (UTC).
/// </summary>
public DateTime? BusyUntil { get; private set; }
/// <summary>
/// Время последнего обновления (UTC).
/// </summary>
public DateTime LastUpdated { get; private set; }
/// <summary>
/// Обновляет координаты.
/// </summary>
/// <param name="lat">Широта.</param>
/// <param name="lon">Долгота.</param>
public void UpdateLocation(double lat, double lon)
{
Location = new Point(lon, lat) { SRID = 4326 };
LastUpdated = DateTime.UtcNow;
}
/// <summary>
/// Устанавливает статус "Занят" до указанного времени.
/// </summary>
public void SetBusy(DateTime until)
{
Status = PerformerStatus.Busy;
BusyUntil = until;
LastUpdated = DateTime.UtcNow;
}
/// <summary>
/// Переводит в статус "Доступен".
/// </summary>
public void GoOnline()
{
Status = PerformerStatus.Available;
BusyUntil = null;
LastUpdated = DateTime.UtcNow;
}
/// <summary>
/// Переводит в статус "Выходной" (офлайн).
/// </summary>
public void GoOffline()
{
Status = PerformerStatus.DayOff;
BusyUntil = null;
LastUpdated = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,22 @@
namespace Nashel.Modules.Geo.Domain.Enums;
/// <summary>
/// Статус доступности исполнителя.
/// </summary>
public enum PerformerStatus
{
/// <summary>
/// Доступен для новых заказов.
/// </summary>
Available,
/// <summary>
/// Занят выполнением заказа.
/// </summary>
Busy,
/// <summary>
/// Выходной или не на смене.
/// </summary>
DayOff
}

View File

@@ -4,11 +4,18 @@
<ProjectReference Include="..\..\..\BuildingBlocks\Nashel.BuildingBlocks.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="NetTopologySuite.IO.PostGis" Version="2.1.0" />
</ItemGroup>
<PropertyGroup>
<RootNamespace>Nashel.Modules.Geo.Domain</RootNamespace>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using Nashel.Modules.Geo.Domain.Aggregates;
namespace Nashel.Modules.Geo.Domain.Repositories;
public interface IGeoRepository
{
Task<LiveStatus?> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default);
Task AddAsync(LiveStatus liveStatus, CancellationToken cancellationToken = default);
Task<List<Guid>> GetNearbyAsync(double lat, double lon, double radiusInMeters, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Nashel.Modules.Geo.Application.Commands;
using Nashel.Modules.Geo.Domain.Repositories;
using Nashel.Modules.Geo.Infrastructure.Persistence;
using Nashel.Modules.Geo.Infrastructure.Persistence.Repositories;
using Npgsql;
namespace Nashel.Modules.Geo.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddGeoModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<GeoDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString("DefaultConnection"),
x => x.UseNetTopologySuite()
));
services.AddScoped<IGeoRepository, GeoRepository>();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(UpdateGeoCommand).Assembly));
return services;
}
}

View File

@@ -0,0 +1,61 @@
// <auto-generated />
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("20260210181606_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("BusyUntil")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("LastUpdated")
.HasColumnType("timestamp with time zone");
b.Property<Point>("Location")
.IsRequired()
.HasColumnType("geography (point)");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace Nashel.Modules.Geo.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "geo");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
migrationBuilder.CreateTable(
name: "LiveStatuses",
schema: "geo",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Location = table.Column<Point>(type: "geography (point)", nullable: false),
Status = table.Column<string>(type: "text", nullable: false),
BusyUntil = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LiveStatuses", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_LiveStatuses_Location",
schema: "geo",
table: "LiveStatuses",
column: "Location")
.Annotation("Npgsql:IndexMethod", "gist");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LiveStatuses",
schema: "geo");
}
}
}

View File

@@ -0,0 +1,58 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
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))]
partial class GeoDbContextModelSnapshot : 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.Geo.Domain.Aggregates.LiveStatus", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("BusyUntil")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("LastUpdated")
.HasColumnType("timestamp with time zone");
b.Property<Point>("Location")
.IsRequired()
.HasColumnType("geography (point)");
b.Property<string>("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
}
}
}

View File

@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="NetTopologySuite.IO.PostGis" Version="2.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />
</ItemGroup>
<PropertyGroup>
<RootNamespace>Nashel.Modules.Geo.Infrastructure</RootNamespace>

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Nashel.Modules.Geo.Domain.Aggregates;
namespace Nashel.Modules.Geo.Infrastructure.Persistence.Configurations;
public class LiveStatusConfiguration : IEntityTypeConfiguration<LiveStatus>
{
public void Configure(EntityTypeBuilder<LiveStatus> builder)
{
builder.ToTable("LiveStatuses", "geo");
builder.HasKey(x => x.Id);
// Маппинг Location в geography(point)
builder.Property(x => x.Location)
.HasColumnType("geography (point)")
.IsRequired();
// Пространственный индекс (GiST)
builder.HasIndex(x => x.Location)
.HasMethod("gist");
builder.Property(x => x.Status)
.HasConversion<string>();
builder.Property(x => x.BusyUntil)
.IsRequired(false);
builder.Property(x => x.LastUpdated)
.IsRequired();
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Infrastructure.Persistence.Configurations;
namespace Nashel.Modules.Geo.Infrastructure.Persistence;
public class GeoDbContext : DbContext
{
public GeoDbContext(DbContextOptions<GeoDbContext> options) : base(options) { }
public DbSet<LiveStatus> LiveStatuses { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("postgis");
modelBuilder.ApplyConfiguration(new LiveStatusConfiguration());
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Domain.Enums;
using Nashel.Modules.Geo.Domain.Repositories;
using Nashel.Modules.Geo.Infrastructure.Persistence;
using NetTopologySuite.Geometries;
namespace Nashel.Modules.Geo.Infrastructure.Persistence.Repositories;
public class GeoRepository : IGeoRepository
{
private readonly GeoDbContext _context;
public GeoRepository(GeoDbContext context)
{
_context = context;
}
public async Task<LiveStatus?> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default)
{
return await _context.LiveStatuses
.FirstOrDefaultAsync(x => x.Id == performerId, cancellationToken);
}
public async Task AddAsync(LiveStatus liveStatus, CancellationToken cancellationToken = default)
{
await _context.LiveStatuses.AddAsync(liveStatus, cancellationToken);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<List<Guid>> GetNearbyAsync(double lat, double lon, double radiusInMeters, CancellationToken cancellationToken = default)
{
var location = new Point(lon, lat) { SRID = 4326 };
return await _context.LiveStatuses
.Where(x => x.Status == PerformerStatus.Available && x.Location.IsWithinDistance(location, radiusInMeters))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Nashel.Modules.Geo.Application.Commands;
using Nashel.Modules.Geo.Application.Queries;
using Nashel.Modules.Geo.Domain.Enums;
namespace Nashel.Modules.Geo.Presentation;
public static class GeoEndpoints
{
public static void MapGeoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("api/geo").WithTags("Geo");
// PATCH /api/geo/status
group.MapPatch("status", async (
[FromBody] UpdateGeoRequest request,
ISender sender,
CancellationToken ct) =>
{
var command = new UpdateGeoCommand(request.PerformerId, request.Latitude, request.Longitude, request.Status);
await sender.Send(command, ct);
return Results.Ok();
});
// GET /api/geo/nearby
group.MapGet("nearby", async (
[FromQuery] double lat,
[FromQuery] double lon,
[FromQuery] double radius,
ISender sender,
CancellationToken ct) =>
{
var result = await sender.Send(new SearchNearbyQuery(lat, lon, radius), ct);
return Results.Ok(result);
});
}
/// <summary>
/// Запрос на обновление геопозиции и статуса исполнителя.
/// </summary>
/// <param name="PerformerId">Идентификатор исполнителя.</param>
/// <param name="Latitude">Широта.</param>
/// <param name="Longitude">Долгота.</param>
/// <param name="Status">Новый статус (0 = Available, 1 = Busy, 2 = DayOff).</param>
public record UpdateGeoRequest(Guid PerformerId, double Latitude, double Longitude, PerformerStatus Status);
}

View File

@@ -7,5 +7,11 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -1,418 +0,0 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v10.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v10.0": {
"Nashel.Modules.Geo.Presentation/1.0.0": {
"dependencies": {
"Nashel.Modules.Geo.Application": "1.0.0"
},
"runtime": {
"Nashel.Modules.Geo.Presentation.dll": {}
}
},
"FluentValidation/12.1.1": {
"runtime": {
"lib/net8.0/FluentValidation.dll": {
"assemblyVersion": "12.0.0.0",
"fileVersion": "12.1.1.0"
}
}
},
"MediatR/14.0.0": {
"dependencies": {
"MediatR.Contracts": "2.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.IdentityModel.JsonWebTokens": "8.14.0"
},
"runtime": {
"lib/net10.0/MediatR.dll": {
"assemblyVersion": "14.0.0.0",
"fileVersion": "14.0.0.0"
}
}
},
"MediatR.Contracts/2.0.1": {
"runtime": {
"lib/netstandard2.0/MediatR.Contracts.dll": {
"assemblyVersion": "2.0.1.0",
"fileVersion": "2.0.1.0"
}
}
},
"Microsoft.EntityFrameworkCore/10.0.2": {
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.2",
"Microsoft.Extensions.Caching.Memory": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.dll": {
"assemblyVersion": "10.0.2.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.2": {
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Abstractions.dll": {
"assemblyVersion": "10.0.2.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.EntityFrameworkCore.Relational/10.0.2": {
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.2",
"Microsoft.Extensions.Caching.Memory": "10.0.2",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.2",
"Microsoft.Extensions.Logging": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.EntityFrameworkCore.Relational.dll": {
"assemblyVersion": "10.0.2.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Caching.Abstractions/10.0.2": {
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Caching.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Caching.Memory/10.0.2": {
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.2",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Caching.Memory.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Configuration.Abstractions/10.0.2": {
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Configuration.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.DependencyInjection/10.0.2": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.DependencyInjection.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.2": {
"runtime": {
"lib/net10.0/Microsoft.Extensions.DependencyInjection.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Logging/10.0.2": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.Extensions.Options": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Logging.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Logging.Abstractions/10.0.2": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Logging.Abstractions.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Options/10.0.2": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.2",
"Microsoft.Extensions.Primitives": "10.0.2"
},
"runtime": {
"lib/net10.0/Microsoft.Extensions.Options.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.Extensions.Primitives/10.0.2": {
"runtime": {
"lib/net10.0/Microsoft.Extensions.Primitives.dll": {
"assemblyVersion": "10.0.0.0",
"fileVersion": "10.0.225.61305"
}
}
},
"Microsoft.IdentityModel.Abstractions/8.14.0": {
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Abstractions.dll": {
"assemblyVersion": "8.14.0.0",
"fileVersion": "8.14.0.60815"
}
}
},
"Microsoft.IdentityModel.JsonWebTokens/8.14.0": {
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.14.0"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.JsonWebTokens.dll": {
"assemblyVersion": "8.14.0.0",
"fileVersion": "8.14.0.60815"
}
}
},
"Microsoft.IdentityModel.Logging/8.14.0": {
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.14.0"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Logging.dll": {
"assemblyVersion": "8.14.0.0",
"fileVersion": "8.14.0.60815"
}
}
},
"Microsoft.IdentityModel.Tokens/8.14.0": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.2",
"Microsoft.IdentityModel.Logging": "8.14.0"
},
"runtime": {
"lib/net9.0/Microsoft.IdentityModel.Tokens.dll": {
"assemblyVersion": "8.14.0.0",
"fileVersion": "8.14.0.60815"
}
}
},
"Nashel.BuildingBlocks/1.0.0": {
"dependencies": {
"FluentValidation": "12.1.1",
"MediatR": "14.0.0",
"Microsoft.EntityFrameworkCore.Relational": "10.0.2"
},
"runtime": {
"Nashel.BuildingBlocks.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"Nashel.Modules.Geo.Application/1.0.0": {
"dependencies": {
"MediatR": "14.0.0",
"Nashel.BuildingBlocks": "1.0.0",
"Nashel.Modules.Geo.Domain": "1.0.0"
},
"runtime": {
"Nashel.Modules.Geo.Application.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
},
"Nashel.Modules.Geo.Domain/1.0.0": {
"dependencies": {
"Nashel.BuildingBlocks": "1.0.0"
},
"runtime": {
"Nashel.Modules.Geo.Domain.dll": {
"assemblyVersion": "1.0.0.0",
"fileVersion": "1.0.0.0"
}
}
}
}
},
"libraries": {
"Nashel.Modules.Geo.Presentation/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"FluentValidation/12.1.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==",
"path": "fluentvalidation/12.1.1",
"hashPath": "fluentvalidation.12.1.1.nupkg.sha512"
},
"MediatR/14.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-r5fwUO6NBvOFKaiMRx/gRrD1MEHHOio5yEdzSLs2OMeD2e9ZKnZaBQM6A6vVBEWJF4VY41vplXmDMOY1YvpqNA==",
"path": "mediatr/14.0.0",
"hashPath": "mediatr.14.0.0.nupkg.sha512"
},
"MediatR.Contracts/2.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-FYv95bNT4UwcNA+G/J1oX5OpRiSUxteXaUt2BJbRSdRNiIUNbggJF69wy6mnk2wYToaanpdXZdCwVylt96MpwQ==",
"path": "mediatr.contracts/2.0.1",
"hashPath": "mediatr.contracts.2.0.1.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-d3+XKbLSHPCu3vwpXECoXcFbvGKmAhEeUmc1xy2czmuPnEF7rZN2HP5ZGMwCMbAKk4B01+nS4HixSMo2Vf/Y9g==",
"path": "microsoft.entityframeworkcore/10.0.2",
"hashPath": "microsoft.entityframeworkcore.10.0.2.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Abstractions/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-BzAwIU5mYeOmnKbEXrkwx7feW2V+zUTrK/kRonSib94tjvc0/iRj2a4N6YGXRhTNjaFP3tvCMIDaX1vIFF6dkg==",
"path": "microsoft.entityframeworkcore.abstractions/10.0.2",
"hashPath": "microsoft.entityframeworkcore.abstractions.10.0.2.nupkg.sha512"
},
"Microsoft.EntityFrameworkCore.Relational/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-1fUeyNmqDNfMogJ2ut7OKO57/WGjjkHMYeX51SpA3PwP7ftbx8g/Z3fbErD+1q14DILrqJfsszYsYhGssBRfDg==",
"path": "microsoft.entityframeworkcore.relational/10.0.2",
"hashPath": "microsoft.entityframeworkcore.relational.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Caching.Abstractions/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-WIRPDa/qoKHmJhTAPCO/zLu9kRLQ2Fd6HD5tzgdXJ3xGEVXDHP6FvakKJjynwKrVDld8H4G4tcbW53wuC/wxMQ==",
"path": "microsoft.extensions.caching.abstractions/10.0.2",
"hashPath": "microsoft.extensions.caching.abstractions.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Caching.Memory/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-MkdPYdtsu0Ta4m9Di4XnWVdO9u+wi1LtvisoR1EteIxsXWO/+3iyAPH6RZbw2lBlWZu9lastbl2YsHVIaL9j+g==",
"path": "microsoft.extensions.caching.memory/10.0.2",
"hashPath": "microsoft.extensions.caching.memory.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Configuration.Abstractions/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-KC5PslaTDnTuTvyke0KYAVBYdZ7IVTsU3JhHe69BpEbHLcj1YThP3bIGtZNOkZfast2AuLnul5lk4rZKxAdUGQ==",
"path": "microsoft.extensions.configuration.abstractions/10.0.2",
"hashPath": "microsoft.extensions.configuration.abstractions.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.DependencyInjection/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-J/Zmp6fY93JbaiZ11ckWvcyxMPjD6XVwIHQXBjryTBgn7O6O20HYg9uVLFcZlNfgH78MnreE/7EH+hjfzn7VyA==",
"path": "microsoft.extensions.dependencyinjection/10.0.2",
"hashPath": "microsoft.extensions.dependencyinjection.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.DependencyInjection.Abstractions/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-zOIurr59+kUf9vNcsUkCvKWZv+fPosUZXURZesYkJCvl0EzTc9F7maAO4Cd2WEV7ZJJ0AZrFQvuH6Npph9wdBw==",
"path": "microsoft.extensions.dependencyinjection.abstractions/10.0.2",
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Logging/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-a0EWuBs6D3d7XMGroDXm+WsAi5CVVfjOJvyxurzWnuhBN9CO+1qHKcrKV1JK7H/T4ZtHIoVCOX/YyWM8K87qtw==",
"path": "microsoft.extensions.logging/10.0.2",
"hashPath": "microsoft.extensions.logging.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Logging.Abstractions/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-RZkez/JjpnO+MZ6efKkSynN6ZztLpw3WbxNzjLCPBd97wWj1S9ZYPWi0nmT4kWBRa6atHsdM1ydGkUr8GudyDQ==",
"path": "microsoft.extensions.logging.abstractions/10.0.2",
"hashPath": "microsoft.extensions.logging.abstractions.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Options/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-1De2LJjmxdqopI5AYC5dIhoZQ79AR5ayywxNF1rXrXFtKQfbQOV9+n/IsZBa7qWlr0MqoGpW8+OY2v/57udZOA==",
"path": "microsoft.extensions.options/10.0.2",
"hashPath": "microsoft.extensions.options.10.0.2.nupkg.sha512"
},
"Microsoft.Extensions.Primitives/10.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-QmSiO+oLBEooGgB3i0GRXyeYRDHjllqt3k365jwfZlYWhvSHA3UL2NEVV5m8aZa041eIlblo6KMI5txvTMpTwA==",
"path": "microsoft.extensions.primitives/10.0.2",
"hashPath": "microsoft.extensions.primitives.10.0.2.nupkg.sha512"
},
"Microsoft.IdentityModel.Abstractions/8.14.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ==",
"path": "microsoft.identitymodel.abstractions/8.14.0",
"hashPath": "microsoft.identitymodel.abstractions.8.14.0.nupkg.sha512"
},
"Microsoft.IdentityModel.JsonWebTokens/8.14.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-4jOpiA4THdtpLyMdAb24dtj7+6GmvhOhxf5XHLYWmPKF8ApEnApal1UnJsKO4HxUWRXDA6C4WQVfYyqsRhpNpQ==",
"path": "microsoft.identitymodel.jsonwebtokens/8.14.0",
"hashPath": "microsoft.identitymodel.jsonwebtokens.8.14.0.nupkg.sha512"
},
"Microsoft.IdentityModel.Logging/8.14.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-eqqnemdW38CKZEHS6diA50BV94QICozDZEvSrsvN3SJXUFwVB9gy+/oz76gldP7nZliA16IglXjXTCTdmU/Ejg==",
"path": "microsoft.identitymodel.logging/8.14.0",
"hashPath": "microsoft.identitymodel.logging.8.14.0.nupkg.sha512"
},
"Microsoft.IdentityModel.Tokens/8.14.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-lKIZiBiGd36k02TCdMHp1KlNWisyIvQxcYJvIkz7P4gSQ9zi8dgh6S5Grj8NNG7HWYIPfQymGyoZ6JB5d1Lo1g==",
"path": "microsoft.identitymodel.tokens/8.14.0",
"hashPath": "microsoft.identitymodel.tokens.8.14.0.nupkg.sha512"
},
"Nashel.BuildingBlocks/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Nashel.Modules.Geo.Application/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Nashel.Modules.Geo.Domain/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}

View File

@@ -0,0 +1,67 @@
using FluentAssertions;
using Moq;
using Nashel.Modules.Geo.Application.Commands;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Domain.Enums;
using Nashel.Modules.Geo.Domain.Repositories;
using Xunit;
namespace Nashel.Modules.Geo.Tests.Application;
public class UpdateGeoCommandHandlerTests
{
private readonly Mock<IGeoRepository> _repositoryMock;
private readonly UpdateGeoCommandHandler _handler;
public UpdateGeoCommandHandlerTests()
{
_repositoryMock = new Mock<IGeoRepository>();
_handler = new UpdateGeoCommandHandler(_repositoryMock.Object);
}
[Fact]
public async Task Handle_Should_CreateNew_When_RecordDoesNotExist()
{
// Arrange
var command = new UpdateGeoCommand(Guid.NewGuid(), 10.0, 20.0, PerformerStatus.Available);
_repositoryMock.Setup(x => x.GetByPerformerIdAsync(command.PerformerId, It.IsAny<CancellationToken>()))
.ReturnsAsync((LiveStatus?)null);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
_repositoryMock.Verify(x => x.AddAsync(
It.Is<LiveStatus>(s =>
s.Id == command.PerformerId &&
s.Location.Y == command.Latitude &&
s.Location.X == command.Longitude),
It.IsAny<CancellationToken>()), Times.Once);
_repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_Should_UpdateExisting_When_RecordExists()
{
// Arrange
var performerId = Guid.NewGuid();
var existingStatus = new LiveStatus(performerId, 10.0, 20.0);
var command = new UpdateGeoCommand(performerId, 15.0, 25.0, PerformerStatus.Busy);
_repositoryMock.Setup(x => x.GetByPerformerIdAsync(performerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existingStatus);
// Act
await _handler.Handle(command, CancellationToken.None);
// Assert
existingStatus.Location.Y.Should().Be(15.0);
existingStatus.Location.X.Should().Be(25.0);
existingStatus.Status.Should().Be(PerformerStatus.Busy);
_repositoryMock.Verify(x => x.AddAsync(It.IsAny<LiveStatus>(), It.IsAny<CancellationToken>()), Times.Never);
_repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using Nashel.Modules.Geo.Domain.Aggregates;
using Nashel.Modules.Geo.Domain.Enums;
using Xunit;
namespace Nashel.Modules.Geo.Tests.Domain;
public class LiveStatusTests
{
[Fact]
public void UpdateLocation_Should_ChangeCoordinates_And_UpdateTimestamp()
{
// Arrange
var performerId = Guid.NewGuid();
var liveStatus = new LiveStatus(performerId, 10.0, 20.0);
var originalTimestamp = liveStatus.LastUpdated;
Thread.Sleep(50); // Small delay
// Act
liveStatus.UpdateLocation(15.0, 25.0);
// Assert
liveStatus.Location.Y.Should().Be(15.0); // Latitude
liveStatus.Location.X.Should().Be(25.0); // Longitude
liveStatus.LastUpdated.Should().BeAfter(originalTimestamp);
}
[Fact]
public void SetBusy_Should_ChangeStatus_To_Busy()
{
// Arrange
var liveStatus = new LiveStatus(Guid.NewGuid(), 10.0, 20.0);
var busyUntil = DateTime.UtcNow.AddHours(2);
// Act
liveStatus.SetBusy(busyUntil);
// Assert
liveStatus.Status.Should().Be(PerformerStatus.Busy);
liveStatus.BusyUntil.Should().Be(busyUntil);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\Nashel.Modules.Geo.Domain.csproj" />
<ProjectReference Include="..\Application\Nashel.Modules.Geo.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace Nashel.Modules.Geo.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}