using FluentAssertions; using NSubstitute; using PageManager.Api.Api.Dtos; using PageManager.Api.Data.Repositories; using PageManager.Api.Services; using PageManager.Api.Tests.Helpers; namespace PageManager.Api.Tests.Unit.Services; public class BooksServiceTests { private readonly IBooksRepository _repo = Substitute.For(); private readonly IHardcoverService _hardcover = Substitute.For(); private readonly BooksService _sut; public BooksServiceTests() { _sut = new BooksService(_repo, _hardcover); } // ── GetAllAsync ─────────────────────────────────────────────────────────── [Fact] public async Task GetAllAsync_MultipleBooks_ReturnsMappedDtos() { var books = new[] { BookFactory.Create(id: 1, title: "Book One").WithAuthors((1, "Author A")), BookFactory.Create(id: 2, title: "Book Two").WithAuthors((2, "Author B")), }; _repo.GetAllAsync().Returns(books); var result = (await _sut.GetAllAsync()).ToArray(); result.Should().HaveCount(2); result[0].Title.Should().Be("Book One"); result[1].Title.Should().Be("Book Two"); } [Fact] public async Task GetAllAsync_EmptyRepo_ReturnsEmptyCollection() { _repo.GetAllAsync().Returns([]); var result = await _sut.GetAllAsync(); result.Should().BeEmpty(); } // ── GetByIdAsync ────────────────────────────────────────────────────────── [Fact] public async Task GetByIdAsync_BookExists_ReturnsDtoWithCorrectFields() { var book = BookFactory.Create(id: 42, title: "Found Book", year: 2020, publisher: "Pub", pages: 100) .WithAuthors((7, "Jane Doe")) .WithSeries(seriesName: "Great Series", position: 2.0, arc: "Part One"); _repo.GetByIdAsync(42).Returns(book); var result = await _sut.GetByIdAsync(42); result.Should().NotBeNull(); result!.Id.Should().Be(42); result.Title.Should().Be("Found Book"); result.Year.Should().Be(2020); result.Publisher.Should().Be("Pub"); result.Pages.Should().Be(100); result.Authors.Should().ContainSingle(a => a.Id == 7 && a.Name == "Jane Doe"); result.Series.Should().NotBeNull(); result.Series!.Name.Should().Be("Great Series"); result.Series.Position.Should().Be(2.0); result.Series.Arc.Should().Be("Part One"); } [Fact] public async Task GetByIdAsync_BookNotFound_ReturnsNull() { _repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); var result = await _sut.GetByIdAsync(99); result.Should().BeNull(); } // ── UpdateAsync ─────────────────────────────────────────────────────────── [Fact] public async Task UpdateAsync_BookExists_AppliesAllFieldsAndSaves() { var book = BookFactory.Create(id: 5).WithAuthors((1, "Author")); _repo.GetByIdAsync(5).Returns(book); var req = new UpdateBookRequest { Title = "Updated Title", Year = 2025, Publisher = "New Publisher", Pages = 999, Description = "New description", Formats = ["epub", "mobi"], Color = "#ff0000", Genres = ["Fantasy", "Adventure"], CoverUrl = "https://example.com/cover.jpg", Isbn = "978-0-00-000000-0", HardcoverId = 123, }; var result = await _sut.UpdateAsync(5, req); result.Should().NotBeNull(); result!.Title.Should().Be("Updated Title"); result.Year.Should().Be(2025); result.Publisher.Should().Be("New Publisher"); result.Pages.Should().Be(999); result.Description.Should().Be("New description"); result.Formats.Should().BeEquivalentTo(["epub", "mobi"]); result.Color.Should().Be("#ff0000"); result.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]); result.CoverUrl.Should().Be("https://example.com/cover.jpg"); result.Isbn.Should().Be("978-0-00-000000-0"); result.HardcoverId.Should().Be(123); await _repo.Received(1).SaveAsync(); } [Fact] public async Task UpdateAsync_BookNotFound_ReturnsNullWithoutSaving() { _repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); var result = await _sut.UpdateAsync(99, new UpdateBookRequest()); result.Should().BeNull(); await _repo.DidNotReceive().SaveAsync(); } // ── DTO mapping ─────────────────────────────────────────────────────────── [Fact] public async Task GetByIdAsync_BookWithSeries_MapsSeriesNamePositionArc() { var book = BookFactory.Create(id: 1) .WithAuthors((1, "Author")) .WithSeries(seriesId: 10, seriesName: "The Stormlight Archive", position: 1.0, arc: "Book One"); _repo.GetByIdAsync(1).Returns(book); var result = await _sut.GetByIdAsync(1); result!.Series.Should().NotBeNull(); result.Series!.Name.Should().Be("The Stormlight Archive"); result.Series.Position.Should().Be(1.0); result.Series.Arc.Should().Be("Book One"); } [Fact] public async Task GetByIdAsync_BookWithoutSeries_SeriesIsNull() { var book = BookFactory.Create(id: 2).WithAuthors((1, "Author")); _repo.GetByIdAsync(2).Returns(book); var result = await _sut.GetByIdAsync(2); result!.Series.Should().BeNull(); } [Fact] public async Task GetByIdAsync_BookWithMultipleAuthors_MapsAllAuthors() { var book = BookFactory.Create(id: 3) .WithAuthors((1, "Alice"), (2, "Bob"), (3, "Carol")); _repo.GetByIdAsync(3).Returns(book); var result = await _sut.GetByIdAsync(3); result!.Authors.Should().HaveCount(3); result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]); } // ── CreateFromHardcoverAsync ────────────────────────────────────────────── [Fact] public async Task CreateFromHardcoverAsync_AlreadyInDb_ReturnsExistingWithoutCallingHardcover() { var existing = BookFactory.Create(id: 7, title: "Existing").WithAuthors((1, "Author")); _repo.FindByHardcoverIdAsync(42).Returns(existing); var result = await _sut.CreateFromHardcoverAsync(42); result.Should().NotBeNull(); result!.Id.Should().Be(7); result.Title.Should().Be("Existing"); await _hardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any()); } [Fact] public async Task CreateFromHardcoverAsync_HardcoverReturnsNull_ReturnsNull() { _repo.FindByHardcoverIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); _hardcover.GetBookDetailsAsync(99).Returns((HardcoverBookDetails?)null); var result = await _sut.CreateFromHardcoverAsync(99); result.Should().BeNull(); await _repo.DidNotReceive().CreateBookAsync( Arg.Any(), Arg.Any>(), Arg.Any<(string, double, string?)?> ()); } [Fact] public async Task CreateFromHardcoverAsync_Success_CreatesBookAndReturnsDto() { _repo.FindByHardcoverIdAsync(123).Returns((PageManager.Api.Data.Models.Book?)null); var details = new HardcoverBookDetails { Id = 123, Title = "Dune", Year = 1965, Publisher = "Ace Books", Pages = 412, Description = "A sci-fi classic.", Authors = ["Frank Herbert"], Genres = ["Science Fiction"], Isbn = "9780441013593", CoverUrl = "https://example.com/cover.jpg", CoverColor = "#c4a35a", Series = new HardcoverSeriesInfo { Name = "Dune Chronicles", Position = 1.0 }, }; _hardcover.GetBookDetailsAsync(123).Returns(details); var createdBook = BookFactory.Create(id: 55, title: "Dune") .WithAuthors((1, "Frank Herbert")) .WithSeries(seriesName: "Dune Chronicles", position: 1.0); _repo.CreateBookAsync(Arg.Any(), Arg.Any>(), Arg.Any<(string, double, string?)?>()) .Returns(createdBook); var result = await _sut.CreateFromHardcoverAsync(123); result.Should().NotBeNull(); result!.Title.Should().Be("Dune"); result.Series.Should().NotBeNull(); result.Series!.Name.Should().Be("Dune Chronicles"); await _repo.Received(1).CreateBookAsync( Arg.Is(b => b.HardcoverId == 123 && b.Title == "Dune"), Arg.Is>(a => a.Contains("Frank Herbert")), Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles")); } }