Skip to content
Merged
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
5 changes: 5 additions & 0 deletions BookLoggerApp.Infrastructure/Services/GenreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public async Task<IReadOnlyList<Genre>> GetAllAsync(CancellationToken ct = defau
public async Task<Genre> AddAsync(Genre genre, CancellationToken ct = default)
{
var result = await _unitOfWork.Genres.AddAsync(genre, ct);
await _unitOfWork.SaveChangesAsync(ct);
// Invalidate cache when genres are modified
_cache.Remove(CacheKey);
return result;
Expand All @@ -52,6 +53,7 @@ public async Task<Genre> AddAsync(Genre genre, CancellationToken ct = default)
public async Task UpdateAsync(Genre genre, CancellationToken ct = default)
{
await _unitOfWork.Genres.UpdateAsync(genre, ct);
await _unitOfWork.SaveChangesAsync(ct);
// Invalidate cache when genres are modified
_cache.Remove(CacheKey);
}
Expand All @@ -62,6 +64,7 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
if (genre != null)
{
await _unitOfWork.Genres.DeleteAsync(genre, ct);
await _unitOfWork.SaveChangesAsync(ct);
// Invalidate cache when genres are modified
_cache.Remove(CacheKey);
}
Expand All @@ -81,6 +84,7 @@ public async Task AddGenreToBookAsync(Guid bookId, Guid genreId, CancellationTok
};

await _unitOfWork.BookGenres.AddAsync(bookGenre);
await _unitOfWork.SaveChangesAsync(ct);
}

public async Task RemoveGenreFromBookAsync(Guid bookId, Guid genreId, CancellationToken ct = default)
Expand All @@ -89,6 +93,7 @@ public async Task RemoveGenreFromBookAsync(Guid bookId, Guid genreId, Cancellati
if (bookGenre != null)
{
await _unitOfWork.BookGenres.DeleteAsync(bookGenre);
await _unitOfWork.SaveChangesAsync(ct);
}
}

Expand Down
1 change: 1 addition & 0 deletions BookLoggerApp.Tests/BookLoggerApp.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="FluentAssertions" Version="8.6.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
Expand Down
177 changes: 177 additions & 0 deletions BookLoggerApp.Tests/Unit/Services/GenreServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using BookLoggerApp.Core.Models;
using BookLoggerApp.Infrastructure.Data;
using BookLoggerApp.Infrastructure.Repositories;
using BookLoggerApp.Infrastructure.Services;
using BookLoggerApp.Tests.TestHelpers;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NSubstitute;
using Xunit;

namespace BookLoggerApp.Tests.Unit.Services;

public class GenreServiceTests : IDisposable
{
private readonly AppDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly IMemoryCache _cache;
private readonly GenreService _service;

public GenreServiceTests()
{
_context = TestDbContext.Create();
_unitOfWork = new UnitOfWork(_context);
_cache = Substitute.For<IMemoryCache>();

// Setup cache to return false for TryGetValue by default causing database hit
object? outValue = null;
_cache.TryGetValue(Arg.Any<object>(), out outValue).Returns(x =>
{
x[1] = null;
return false;
});

_service = new GenreService(_unitOfWork, _cache);

// Clear seeded genres to ensure tests start with empty state
_context.Genres.RemoveRange(_context.Genres);
_context.SaveChanges();
}

public void Dispose()
{
_context.Dispose();
}

[Fact]
public async Task GetAllAsync_Should_Return_Genres_From_Db_When_Not_Cached()
{
// Arrange
_context.Genres.Add(new Genre { Name = "Fantasy" });
_context.Genres.Add(new Genre { Name = "Sci-Fi" });
await _context.SaveChangesAsync();

// Act
var genres = await _service.GetAllAsync();

// Assert
genres.Should().HaveCount(2);
_cache.Received(1).CreateEntry(Arg.Any<object>()); // Should cache result
}

[Fact]
public async Task GetAllAsync_Should_Return_Genres_From_Cache_When_Cached()
{
// Arrange (Cache Hit)
var cachedGenres = new List<Genre> { new Genre { Name = "Cached Genre" } };
object? outValue;
_cache.TryGetValue(Arg.Any<object>(), out outValue).Returns(x =>
{
x[1] = cachedGenres;
return true;
});

// Act
var genres = await _service.GetAllAsync();

// Assert
genres.Should().HaveCount(1);
genres.First().Name.Should().Be("Cached Genre");
// Should NOT access DB (empty DB would return 0 if accessed)
}

[Fact]
public async Task AddAsync_Should_Invalidate_Cache()
{
// Arrange
var genre = new Genre { Name = "New Genre" };

// Act
await _service.AddAsync(genre);

// Assert
_cache.Received(1).Remove(Arg.Any<object>());
var dbGenre = await _context.Genres.FirstOrDefaultAsync();
dbGenre.Should().NotBeNull();
dbGenre!.Name.Should().Be("New Genre");
}

[Fact]
public async Task UpdateAsync_Should_Invalidate_Cache()
{
// Arrange
var genre = new Genre { Name = "Old Name" };
_context.Genres.Add(genre);
await _context.SaveChangesAsync();

genre.Name = "New Name";

// Act
await _service.UpdateAsync(genre);

// Assert
_cache.Received(1).Remove(Arg.Any<object>());
var dbGenre = await _context.Genres.FindAsync(genre.Id);
dbGenre!.Name.Should().Be("New Name");
}

[Fact]
public async Task DeleteAsync_Should_Invalidate_Cache()
{
// Arrange
var genre = new Genre { Name = "To Delete" };
_context.Genres.Add(genre);
await _context.SaveChangesAsync();

// Act
await _service.DeleteAsync(genre.Id);

// Assert
_cache.Received(1).Remove(Arg.Any<object>());
var count = await _context.Genres.CountAsync();
count.Should().Be(0);
}

[Fact]
public async Task AddGenreToBookAsync_Should_Add_Relation()
{
// Arrange
var book = new Book { Title = "Book" };
var genre = new Genre { Name = "Genre" };
_context.Books.Add(book);
_context.Genres.Add(genre);
await _context.SaveChangesAsync();

// Act
await _service.AddGenreToBookAsync(book.Id, genre.Id);

// Assert
var relation = await _context.BookGenres.FirstOrDefaultAsync();
relation.Should().NotBeNull();
relation!.BookId.Should().Be(book.Id);
relation.GenreId.Should().Be(genre.Id);
}

[Fact]
public async Task GetGenresForBookAsync_Should_Return_Associated_Genres()
{
// Arrange
var book = new Book { Title = "Book" };
var genre1 = new Genre { Name = "G1" };
var genre2 = new Genre { Name = "G2" };
_context.Books.Add(book);
_context.Genres.AddRange(genre1, genre2);
await _context.SaveChangesAsync();

_context.BookGenres.Add(new BookGenre { BookId = book.Id, GenreId = genre1.Id });
await _context.SaveChangesAsync();

// Act
var genres = await _service.GetGenresForBookAsync(book.Id);

// Assert
genres.Should().HaveCount(1);
genres.First().Name.Should().Be("G1");
}
}
144 changes: 144 additions & 0 deletions BookLoggerApp.Tests/Unit/Validators/BookValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using FluentValidation.TestHelper;
using BookLoggerApp.Core.Models;
using BookLoggerApp.Core.Validators;
using Xunit;

namespace BookLoggerApp.Tests.Unit.Validators;

public class BookValidatorTests
{
private readonly BookValidator _validator;

public BookValidatorTests()
{
_validator = new BookValidator();
}

[Fact]
public void Should_Have_Error_When_Title_Is_Empty()
{
var model = new Book { Title = "" };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.Title);
}

[Fact]
public void Should_Have_Error_When_Title_Exceeds_Max_Length()
{
var model = new Book { Title = new string('a', 501) };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.Title);
}

[Fact]
public void Should_Not_Have_Error_When_Title_Is_Valid()
{
var model = new Book { Title = "Valid Title" };
var result = _validator.TestValidate(model);
result.ShouldNotHaveValidationErrorFor(x => x.Title);
}

[Fact]
public void Should_Have_Error_When_Author_Is_Empty()
{
var model = new Book { Author = "" };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.Author);
}

[Fact]
public void Should_Have_Error_When_Author_Exceeds_Max_Length()
{
var model = new Book { Author = new string('a', 301) };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.Author);
}

[Fact]
public void Should_Not_Have_Error_When_Author_Is_Valid()
{
var model = new Book { Author = "Valid Author" };
var result = _validator.TestValidate(model);
result.ShouldNotHaveValidationErrorFor(x => x.Author);
}

[Fact]
public void Should_Have_Error_When_ISBN_Exceeds_Max_Length()
{
var model = new Book { ISBN = new string('1', 21) };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.ISBN);
}

[Fact]
public void Should_Not_Have_Error_When_ISBN_Is_Null_Or_Empty()
{
var model = new Book { ISBN = null };
var result = _validator.TestValidate(model);
result.ShouldNotHaveValidationErrorFor(x => x.ISBN);
}

[Fact]
public void Should_Have_Error_When_PageCount_Is_Zero_Or_Less()
{
var model = new Book { PageCount = 0 };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.PageCount);
}

[Fact]
public void Should_Have_Error_When_PageCount_Exceeds_Max()
{
var model = new Book { PageCount = 50001 };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.PageCount);
}

[Fact]
public void Should_Have_Error_When_CurrentPage_Is_Negative()
{
var model = new Book { CurrentPage = -1, PageCount = 100 };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.CurrentPage);
}

[Fact]
public void Should_Have_Error_When_CurrentPage_Exceeds_PageCount()
{
var model = new Book { PageCount = 100, CurrentPage = 101 };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.CurrentPage);
}

[Fact]
public void Should_Have_Error_When_DateStarted_Is_In_Future()
{
var model = new Book { DateStarted = DateTime.UtcNow.AddDays(1) };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.DateStarted);
}

[Fact]
public void Should_Have_Error_When_DateCompleted_Is_Before_DateStarted()
{
var model = new Book
{
DateStarted = DateTime.UtcNow,
DateCompleted = DateTime.UtcNow.AddSeconds(-1)
};
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.DateCompleted);
}

[Fact]
public void Should_Have_Error_When_DateCompleted_Is_In_Future()
{
var model = new Book
{
DateStarted = DateTime.UtcNow,
DateCompleted = DateTime.UtcNow.AddDays(1)
};
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.DateCompleted);
}
}
Loading
Loading