Add initial implementation of API, database, and user management components.

This commit is contained in:
2026-03-15 23:17:51 +01:00
commit 0543120f3b
53 changed files with 2241 additions and 0 deletions

View File

@@ -0,0 +1,401 @@
using MikrocopApi.Dtos;
using MikrocopApi.Exceptions;
using MikrocopApi.Services;
using MikrocopDb.Entities;
using MikrocopDb.Repositories;
using Moq;
namespace MikrocopTests;
[TestFixture]
public sealed class UserServiceTests
{
[Test]
public async Task CreateAsync_ReturnsCreatedUser_WhenRequestIsValid()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var request = CreateRequest();
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
hashingServiceMock.Setup(x => x.HashPassword(request.Password))
.Returns(("hash-value", "salt-value"));
repositoryMock.Setup(x => x.AddAsync(It.IsAny<UserEntity>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
var result = await sut.CreateAsync(request);
Assert.That(result.UserName, Is.EqualTo("new-user"));
Assert.That(result.Email, Is.EqualTo("new.user@example.com"));
repositoryMock.Verify(x => x.AddAsync(It.Is<UserEntity>(u =>
u.UserName == "new-user" &&
u.FullName == "New User" &&
u.Email == "new.user@example.com" &&
u.PasswordHash == "hash-value" &&
u.PasswordSalt == "salt-value"),
It.IsAny<CancellationToken>()), Times.Once);
repositoryMock.VerifyAll();
hashingServiceMock.VerifyAll();
}
[Test]
public void CreateAsync_ThrowsConflict_WhenUserNameExists()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var request = CreateRequest();
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity());
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void CreateAsync_ThrowsConflict_WhenEmailExists()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var request = CreateRequest();
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity());
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<ConflictException>(() => sut.CreateAsync(request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void CreateAsync_ThrowsBadRequest_WhenPasswordIsNotComplexEnough()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var request = CreateRequest();
request.Password = "weak";
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<BadRequestException>(() => sut.CreateAsync(request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public async Task UpdateAsync_UpdatesUserWithoutPassword_WhenPasswordIsNotProvided()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var existing = CreateEntity(id);
var request = CreateUpdateRequest(password: null);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(existing);
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
await sut.UpdateAsync(id, request);
Assert.That(existing.UserName, Is.EqualTo(request.UserName));
Assert.That(existing.PasswordHash, Is.EqualTo("hash"));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public async Task UpdateAsync_HashesPassword_WhenPasswordIsProvided()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var existing = CreateEntity(id);
var request = CreateUpdateRequest(password: "UpdatedPassword!123");
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(existing);
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
hashingServiceMock.Setup(x => x.HashPassword(request.Password!))
.Returns(("new-hash", "new-salt"));
repositoryMock.Setup(x => x.UpdateAsync(existing, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
await sut.UpdateAsync(id, request);
Assert.That(existing.PasswordHash, Is.EqualTo("new-hash"));
Assert.That(existing.PasswordSalt, Is.EqualTo("new-salt"));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyAll();
}
[Test]
public void UpdateAsync_ThrowsNotFound_WhenUserDoesNotExist()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var request = CreateUpdateRequest(password: null);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<NotFoundException>(() => sut.UpdateAsync(id, request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void UpdateAsync_ThrowsConflict_WhenUserNameBelongsToAnotherUser()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var request = CreateUpdateRequest(password: null);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity(id));
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void UpdateAsync_ThrowsConflict_WhenEmailBelongsToAnotherUser()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var request = CreateUpdateRequest(password: null);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity(id));
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity(Guid.NewGuid()));
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<ConflictException>(() => sut.UpdateAsync(id, request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void UpdateAsync_ThrowsBadRequest_WhenProvidedPasswordIsWeak()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var request = CreateUpdateRequest(password: "weak");
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateEntity(id));
repositoryMock.Setup(x => x.GetByUserNameAsync(request.UserName, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
repositoryMock.Setup(x => x.GetByEmailAsync(request.Email, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<BadRequestException>(() => sut.UpdateAsync(id, request));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public async Task GetByIdAsync_ReturnsUser_WhenUserExists()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var user = CreateEntity(id);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
var result = await sut.GetByIdAsync(id);
Assert.That(result.Id, Is.EqualTo(id));
Assert.That(result.UserName, Is.EqualTo(user.UserName));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void GetByIdAsync_ThrowsNotFound_WhenUserDoesNotExist()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<NotFoundException>(() => sut.GetByIdAsync(id));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public async Task DeleteAsync_DeletesUser_WhenUserExists()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var user = CreateEntity(id);
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);
repositoryMock.Setup(x => x.DeleteAsync(user, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
await sut.DeleteAsync(id);
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public void DeleteAsync_ThrowsNotFound_WhenUserDoesNotExist()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<NotFoundException>(() => sut.DeleteAsync(id));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
[Test]
public async Task ValidatePasswordAsync_ReturnsResult_WhenUserExists()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
var user = CreateEntity(id);
const string password = "AnyPassword!123";
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync(user);
hashingServiceMock.Setup(x => x.VerifyPassword(password, user.PasswordHash, user.PasswordSalt))
.Returns(true);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
var result = await sut.ValidatePasswordAsync(id, password);
Assert.That(result, Is.True);
repositoryMock.VerifyAll();
hashingServiceMock.VerifyAll();
}
[Test]
public void ValidatePasswordAsync_ThrowsNotFound_WhenUserDoesNotExist()
{
var repositoryMock = new Mock<IUserRepository>(MockBehavior.Strict);
var hashingServiceMock = new Mock<IPasswordHashingService>(MockBehavior.Strict);
var id = Guid.NewGuid();
repositoryMock.Setup(x => x.GetByIdAsync(id, It.IsAny<CancellationToken>()))
.ReturnsAsync((UserEntity?)null);
var sut = new UserService(repositoryMock.Object, hashingServiceMock.Object);
Assert.ThrowsAsync<NotFoundException>(() => sut.ValidatePasswordAsync(id, "AnyPassword!123"));
repositoryMock.VerifyAll();
hashingServiceMock.VerifyNoOtherCalls();
}
private static CreateUserDto CreateRequest()
{
return new CreateUserDto
{
UserName = " new-user ",
FullName = " New User ",
Email = " new.user@example.com ",
MobileNumber = " +38640123456 ",
Language = " en ",
Culture = " en-US ",
Password = "ValidPassword!123"
};
}
private static UpdateUserDto CreateUpdateRequest(string? password)
{
return new UpdateUserDto
{
UserName = "updated-user",
FullName = "Updated User",
Email = "updated.user@example.com",
MobileNumber = "+38640999888",
Language = "en",
Culture = "en-US",
Password = password
};
}
private static UserEntity CreateEntity(Guid? id = null)
{
return new UserEntity
{
Id = id ?? Guid.NewGuid(),
UserName = "existing-user",
FullName = "Existing User",
Email = "existing@example.com",
MobileNumber = "+38640111222",
Language = "en",
Culture = "en-US",
PasswordHash = "hash",
PasswordSalt = "salt"
};
}
}