Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@
public int StreetcodeId { get; set; }
public string UserId { get; set; }
public string UserFullName { get; set; }
public int? ParentCommentId { get; set; }
public IEnumerable<CommentDTO> Replies { get; set; }

Check warning on line 13 in Streetcode/Streetcode.BLL/DTO/Streetcode/Comments/CommentDTO.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Non-nullable property 'Replies' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

See more on https://sonarcloud.io/project/issues?id=project-studying-dotnet_Streetcode-Server-January-2026&issues=AZy1h4-L7AKibpF0iL7C&open=AZy1h4-L7AKibpF0iL7C&pullRequest=125
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ public class CreateCommentDTO
public string TextContent { get; set; }

public int StreetcodeId { get; set; }

public int? ParentCommentId { get; set; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ public CommentProfile()
{
CreateMap<Comment, CommentDTO>()
.ForMember(dest => dest.UserFullName, opt => opt.MapFrom(src =>
src.User != null ? $"{src.User.Name} {src.User.Surname}" : "Unknown User"));
src.User != null ? $"{src.User.Name} {src.User.Surname}" : "Unknown User"))
.ForMember(dest => dest.ParentCommentId, opt => opt.MapFrom(src => src.ParentCommentId))
.ForMember(dest => dest.Replies, opt => opt.MapFrom(src => src.Replies));

CreateMap<CreateCommentDTO, Comment>()
.ForMember(dest => dest.ParentCommentId, opt => opt.MapFrom(src => src.ParentCommentId));

CreateMap<CreateCommentDTO, Comment>();
CreateMap<UpdateCommentDTO, Comment>()
.ForMember(dest => dest.UserId, opt => opt.Ignore())
.ForMember(dest => dest.StreetcodeId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore());
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.ParentCommentId, opt => opt.Ignore());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public CreateCommentDTOValidator()

RuleFor(x => x.StreetcodeId)
.GreaterThan(0).WithMessage(Messages.Error_PropertyMustBeGreaterThanZero.Format(nameof(CreateCommentDTO.StreetcodeId)));

RuleFor(x => x.ParentCommentId)
.GreaterThan(0)
.When(x => x.ParentCommentId.HasValue)
.WithMessage(Messages.Error_PropertyMustBeGreaterThanZero.Format(nameof(CreateCommentDTO.ParentCommentId)));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ public CreateCommentHandler(IRepositoryWrapper repositoryWrapper, IMapper mapper

public async Task<Result<CommentDTO>> Handle(CreateCommentCommand command, CancellationToken cancellationToken)
{
if (command.Comment.ParentCommentId.HasValue)
{
var parentComment = await _repositoryWrapper.CommentRepository
.GetFirstOrDefaultAsync(c => c.Id == command.Comment.ParentCommentId.Value);

if (parentComment == null)
{
string errorParentMsg = "Parent comment not found.";
_logger.LogError(command, errorParentMsg);
return Result.Fail(new Error(errorParentMsg));
}
}

var comment = _mapper.Map<Comment>(command.Comment);

comment.UserId = command.UserId;
Expand All @@ -48,4 +61,4 @@ public async Task<Result<CommentDTO>> Handle(CreateCommentCommand command, Cance
return Result.Fail(new Error(errorMsg));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Streetcode.BLL.Interfaces.Logging;
using Streetcode.DAL.Entities.Streetcode.Comments;
using Streetcode.DAL.Repositories.Interfaces.Base;
Expand All @@ -22,7 +23,9 @@ public DeleteCommentHandler(IRepositoryWrapper repositoryWrapper, ILoggerService
public async Task<Result<Unit>> Handle(DeleteCommentCommand command, CancellationToken cancellationToken)
{
var comment = await _repositoryWrapper.CommentRepository
.GetFirstOrDefaultAsync(t => t.Id == command.Id);
.GetFirstOrDefaultAsync(
predicate: t => t.Id == command.Id,
include: x => x.Include(c => c.Replies));

if (comment is null)
{
Expand Down Expand Up @@ -54,4 +57,4 @@ public async Task<Result<Unit>> Handle(DeleteCommentCommand command, Cancellatio
return Result.Fail(new Error(errorMsg));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@ public async Task<Result<IEnumerable<CommentDTO>>> Handle(GetCommentsByStreetcod
{
var comments = await _repositoryWrapper.CommentRepository.GetAllAsync(
predicate: c => c.StreetcodeId == request.StreetcodeId,
include: x => x.Include(c => c.User));
include: x => x.Include(c => c.User).Include(c => c.Replies));

var sortedComments = comments.OrderByDescending(c => c.CreatedAt);
var allMappedComments = _mapper.Map<IEnumerable<CommentDTO>>(comments);

return Result.Ok(_mapper.Map<IEnumerable<CommentDTO>>(sortedComments));
var rootComments = allMappedComments
.Where(c => c.ParentCommentId == null)
.OrderByDescending(c => c.CreatedAt)
.AsEnumerable();

return Result.Ok(rootComments);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task<Result<CommentDTO>> Handle(UpdateCommentCommand command, Cance
return Result.Fail(new Error(errorAuthMsg));
}

comment = _mapper.Map(command.Comment, comment);
_mapper.Map(command.Comment, comment);
comment.UpdatedAt = DateTime.UtcNow;

_repositoryWrapper.CommentRepository.Update(comment);
Expand All @@ -63,4 +63,4 @@ public async Task<Result<CommentDTO>> Handle(UpdateCommentCommand command, Cance
return Result.Fail(new Error(errorMsg));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ public class Comment
public string UserId { get; set; }

public User? User { get; set; }

public int? ParentCommentId { get; set; }

public Comment? ParentComment { get; set; }

public ICollection<Comment> Replies { get; set; } = new List<Comment>();
}
}
}
16 changes: 16 additions & 0 deletions Streetcode/Streetcode.DAL/Persistence/StreetcodeDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,5 +320,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.WithOne(t => t.User)
.HasForeignKey(t => t.UserId)
.OnDelete(DeleteBehavior.Cascade);

modelBuilder.Entity<Comment>(entity =>
{
entity.HasOne(c => c.ParentComment)
.WithMany(c => c.Replies)
.HasForeignKey(c => c.ParentCommentId)
.OnDelete(DeleteBehavior.Cascade);

entity.HasOne(c => c.Streetcode)
.WithMany(s => s.Comments)
.HasForeignKey(c => c.StreetcodeId);

entity.HasOne(c => c.User)
.WithMany(u => u.Comments)
.HasForeignKey(c => c.UserId);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ public async Task<IActionResult> Delete([FromRoute] int id)
{
return User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,72 +59,62 @@
var userId = "user-123";
var command = new CreateCommentCommand(createDto, userId);

var commentEntity = new Comment
{
Id = 1,
TextContent = createDto.TextContent,
StreetcodeId = createDto.StreetcodeId,
UserId = userId,
};

this.commentRepositoryMock.Setup(x => x.CreateAsync(It.IsAny<Comment>()))
.ReturnsAsync(commentEntity);

this.repositoryWrapperMock.Setup(x => x.SaveChangesAsync())
.ReturnsAsync(1);

this.userRepositoryMock
.Setup(x => x.GetFirstOrDefaultAsync(
It.IsAny<Expression<Func<User, bool>>>(),
null,
It.IsAny<bool>()))
.ReturnsAsync(new User());
this.SetupStandardSuccessMocks(userId, createDto.TextContent);

// Act
var result = await this.handler.Handle(command, CancellationToken.None);

// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.TextContent.Should().Be(createDto.TextContent);

this.commentRepositoryMock.Verify(x => x.CreateAsync(It.IsAny<Comment>()), Times.Once);
this.repositoryWrapperMock.Verify(x => x.SaveChangesAsync(), Times.Once);
}

[Fact]
public async Task Handle_SetsCorrectUser_WhenRequestIsValid()
public async Task Handle_ReturnsSuccess_WhenReplyIsValid()
{
// Arrange
int parentId = 1;
var createDto = GetCreateCommentDTO();
var userId = "test-user-id";
createDto.ParentCommentId = parentId;
var userId = "user-123";
var command = new CreateCommentCommand(createDto, userId);

var user = new User
{
Id = userId,
Name = "TestName",
Surname = "TestSurname",
};
this.commentRepositoryMock.Setup(x => x.GetFirstOrDefaultAsync(
It.IsAny<Expression<Func<Comment, bool>>>(), null, It.IsAny<bool>()))
.ReturnsAsync(new Comment { Id = parentId });

this.commentRepositoryMock.Setup(x => x.CreateAsync(It.IsAny<Comment>()))
.ReturnsAsync((Comment c) => c);
this.SetupStandardSuccessMocks(userId, createDto.TextContent);

this.repositoryWrapperMock.Setup(x => x.SaveChangesAsync()).ReturnsAsync(1);
// Act
var result = await this.handler.Handle(command, CancellationToken.None);

this.userRepositoryMock
.Setup(x => x.GetFirstOrDefaultAsync(
It.IsAny<Expression<Func<User, bool>>>(),
null,
It.IsAny<bool>()))
.ReturnsAsync(user);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.ParentCommentId.Should().Be(parentId);
this.commentRepositoryMock.Verify(x => x.GetFirstOrDefaultAsync(It.IsAny<Expression<Func<Comment, bool>>>(), null, It.IsAny<bool>()), Times.Once);
}

[Fact]
public async Task Handle_ReturnsFail_WhenParentCommentNotFound()
{
// Arrange
int parentId = 999;
var createDto = GetCreateCommentDTO();
createDto.ParentCommentId = parentId;
var userId = "user-123";
var command = new CreateCommentCommand(createDto, userId);

this.commentRepositoryMock.Setup(x => x.GetFirstOrDefaultAsync(
It.IsAny<Expression<Func<Comment, bool>>>(), null, It.IsAny<bool>()))
.ReturnsAsync((Comment)null);

Check warning on line 109 in Streetcode/Streetcode.XUnitTest/MediatR/Comments/Create/CreateCommentHandlerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Converting null literal or possible null value to non-nullable type.

See more on https://sonarcloud.io/project/issues?id=project-studying-dotnet_Streetcode-Server-January-2026&issues=AZy1h5Fq7AKibpF0iL7D&open=AZy1h5Fq7AKibpF0iL7D&pullRequest=125

// Act
var result = await this.handler.Handle(command, CancellationToken.None);

// Assert
result.Value.UserId.Should().Be(userId);
result.Value.UserFullName.Should().Be("TestName TestSurname");
result.IsFailed.Should().BeTrue();
result.Errors.First().Message.Should().Be("Parent comment not found.");

Check warning on line 116 in Streetcode/Streetcode.XUnitTest/MediatR/Comments/Create/CreateCommentHandlerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not use Enumerable methods on indexable collections. Instead use the collection directly.

See more on https://sonarcloud.io/project/issues?id=project-studying-dotnet_Streetcode-Server-January-2026&issues=AZy1h5Fq7AKibpF0iL7F&open=AZy1h5Fq7AKibpF0iL7F&pullRequest=125
this.loggerMock.Verify(x => x.LogError(command, "Parent comment not found."), Times.Once);
}

[Fact]
Expand All @@ -135,24 +125,36 @@
var userId = "user-123";
var command = new CreateCommentCommand(createDto, userId);

this.commentRepositoryMock.Setup(x => x.CreateAsync(It.IsAny<Comment>()));

this.repositoryWrapperMock.Setup(x => x.SaveChangesAsync())
.ReturnsAsync(0);

var expectedErrorMsg = Messages.Error_FailedToCreateEntity.Format(nameof(Comment));
this.repositoryWrapperMock.Setup(x => x.SaveChangesAsync()).ReturnsAsync(0);

// Act
var result = await this.handler.Handle(command, CancellationToken.None);

// Assert
result.IsFailed.Should().BeTrue();
result.Errors.First().Message.Should().Be(expectedErrorMsg);
this.loggerMock.Verify(x => x.LogError(command, It.IsAny<string>()), Times.Once);
}

private void SetupStandardSuccessMocks(string userId, string textContent)
{
var commentEntity = new Comment
{
Id = 1,
TextContent = textContent,
UserId = userId,
};

this.commentRepositoryMock.Setup(x => x.CreateAsync(It.IsAny<Comment>()))
.ReturnsAsync(commentEntity);

this.repositoryWrapperMock.Setup(x => x.SaveChangesAsync()).ReturnsAsync(1);

this.loggerMock.Verify(x => x.LogError(command, expectedErrorMsg), Times.Once);
this.userRepositoryMock.Setup(x => x.GetFirstOrDefaultAsync(
It.IsAny<Expression<Func<User, bool>>>(), null, It.IsAny<bool>()))
.ReturnsAsync(new User { Id = userId, Name = "Name", Surname = "Surname" });
}

private static CreateCommentDTO GetCreateCommentDTO()

Check warning on line 157 in Streetcode/Streetcode.XUnitTest/MediatR/Comments/Create/CreateCommentHandlerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Static members should appear before non-static members

See more on https://sonarcloud.io/project/issues?id=project-studying-dotnet_Streetcode-Server-January-2026&issues=AZy1h5Fq7AKibpF0iL7E&open=AZy1h5Fq7AKibpF0iL7E&pullRequest=125
{
return new CreateCommentDTO
{
Expand All @@ -161,4 +163,4 @@
};
}
}
}
}
Loading
Loading