using System.Net; using System.Net.Http.Json; using FluentAssertions; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using PageManager.Api.Api.Dtos; using PageManager.Api.Data; using PageManager.Api.Data.Models; using PageManager.Api.Services; using PageManager.Api.Tests.Integration.Fixtures; namespace PageManager.Api.Tests.Integration; [Collection("Postgres")] public class BooksControllerTests : IAsyncLifetime { private readonly TestWebAppFactory _factory; private readonly HttpClient _client; public BooksControllerTests(PostgresFixture postgres) { _factory = new TestWebAppFactory(postgres); _client = _factory.CreateClient(); } public async Task InitializeAsync() { using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.ExecuteSqlRawAsync( "TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series RESTART IDENTITY CASCADE"); } public Task DisposeAsync() { _client.Dispose(); _factory.Dispose(); return Task.CompletedTask; } // ── GET /api/books ──────────────────────────────────────────────────────── [Fact] public async Task GetBooks_EmptyDb_Returns200WithEmptyArray() { var response = await _client.GetAsync("/api/books"); response.StatusCode.Should().Be(HttpStatusCode.OK); var books = await response.Content.ReadFromJsonAsync(); books.Should().BeEmpty(); } [Fact] public async Task GetBooks_SeededBooks_Returns200WithCorrectCountAndArrayFields() { await SeedBookAsync(title: "Dune", formats: ["epub", "mobi"], genres: ["Science Fiction"]); await SeedBookAsync(title: "Foundation", formats: ["pdf"], genres: ["Science Fiction", "Classic"]); var response = await _client.GetAsync("/api/books"); response.StatusCode.Should().Be(HttpStatusCode.OK); var books = await response.Content.ReadFromJsonAsync(); books.Should().HaveCount(2); var dune = books!.Single(b => b.Title == "Dune"); dune.Formats.Should().BeEquivalentTo(new[] { "epub", "mobi" }); dune.Genres.Should().BeEquivalentTo(new[] { "Science Fiction" }); var foundation = books.Single(b => b.Title == "Foundation"); foundation.Formats.Should().BeEquivalentTo(new[] { "pdf" }); } // ── GET /api/books/{id} ─────────────────────────────────────────────────── [Fact] public async Task GetBook_Exists_Returns200WithDtoAndAuthors() { var id = await SeedBookAsync(title: "The Name of the Wind", authorName: "Patrick Rothfuss"); var response = await _client.GetAsync($"/api/books/{id}"); response.StatusCode.Should().Be(HttpStatusCode.OK); var book = await response.Content.ReadFromJsonAsync(); book.Should().NotBeNull(); book!.Title.Should().Be("The Name of the Wind"); book.Authors.Should().ContainSingle(a => a.Name == "Patrick Rothfuss"); } [Fact] public async Task GetBook_NotFound_Returns404() { var response = await _client.GetAsync("/api/books/99999"); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } // ── PUT /api/books/{id} ─────────────────────────────────────────────────── [Fact] public async Task UpdateBook_Exists_Returns200WithUpdatedFields() { var id = await SeedBookAsync(title: "Original Title"); var req = new UpdateBookRequest { Title = "Updated Title", Year = 2025, Publisher = "New Publisher", Pages = 500, Formats = ["epub", "mobi"], Genres = ["Fantasy"], Color = "#ff0000", }; var response = await _client.PutAsJsonAsync($"/api/books/{id}", req); response.StatusCode.Should().Be(HttpStatusCode.OK); var book = await response.Content.ReadFromJsonAsync(); book!.Title.Should().Be("Updated Title"); book.Year.Should().Be(2025); book.Publisher.Should().Be("New Publisher"); } [Fact] public async Task UpdateBook_NotFound_Returns404() { var req = new UpdateBookRequest { Title = "Whatever" }; var response = await _client.PutAsJsonAsync("/api/books/99999", req); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task UpdateBook_PersistsArrayFieldsToDb() { var id = await SeedBookAsync(title: "Array Test"); var req = new UpdateBookRequest { Title = "Array Test", Formats = ["epub", "pdf", "mobi"], Genres = ["Fantasy", "Adventure"], Color = "#6366f1", }; await _client.PutAsJsonAsync($"/api/books/{id}", req); using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var book = await db.Books.FindAsync(id); book!.Formats.Should().BeEquivalentTo(["epub", "pdf", "mobi"]); book.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]); } // ── POST /api/books ─────────────────────────────────────────────────────── [Fact] public async Task CreateBook_HardcoverReturnsDetails_Returns200WithCreatedBook() { var mockHardcover = Substitute.For(); mockHardcover.GetBookDetailsAsync(999).Returns(new HardcoverBookDetails { Id = 999, Title = "Dune", Year = 1965, Publisher = "Ace Books", Pages = 412, Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }], Genres = ["Science Fiction"], Isbn = "9780441013593", CoverColor = "#c4a35a", }); using var client = _factory .WithWebHostBuilder(b => b.ConfigureServices(services => { services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); services.AddScoped(_ => mockHardcover); })) .CreateClient(); var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 999 }); response.StatusCode.Should().Be(HttpStatusCode.OK); var book = await response.Content.ReadFromJsonAsync(); book.Should().NotBeNull(); book!.Title.Should().Be("Dune"); book.HardcoverId.Should().Be(999); book.Authors.Should().ContainSingle(a => a.Name == "Frank Herbert"); } [Fact] public async Task CreateBook_HardcoverReturnsNull_Returns404() { var mockHardcover = Substitute.For(); mockHardcover.GetBookDetailsAsync(Arg.Any()).Returns((HardcoverBookDetails?)null); using var client = _factory .WithWebHostBuilder(b => b.ConfigureServices(services => { services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); services.AddScoped(_ => mockHardcover); })) .CreateClient(); var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 0 }); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public async Task CreateBook_HardcoverIdAlreadyInDb_Returns200WithExistingBook() { await SeedBookWithHardcoverIdAsync(hardcoverId: 777, title: "Already Here"); var mockHardcover = Substitute.For(); using var client = _factory .WithWebHostBuilder(b => b.ConfigureServices(services => { services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); services.AddScoped(_ => mockHardcover); })) .CreateClient(); var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 777 }); response.StatusCode.Should().Be(HttpStatusCode.OK); var book = await response.Content.ReadFromJsonAsync(); book!.Title.Should().Be("Already Here"); await mockHardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any()); } // ── Helpers ─────────────────────────────────────────────────────────────── private async Task SeedBookAsync( string title = "Test Book", string[] formats = null!, string[] genres = null!, string? authorName = null) { using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var book = new Book { Title = title, Formats = formats ?? [], Genres = genres ?? [], }; db.Books.Add(book); if (authorName is not null) { var author = new Author { Name = authorName }; db.Authors.Add(author); await db.SaveChangesAsync(); db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id, }); } await db.SaveChangesAsync(); return book.Id; } private async Task SeedBookWithHardcoverIdAsync(int hardcoverId, string title) { using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Books.Add(new Book { Title = title, HardcoverId = hardcoverId, Formats = [], Genres = [] }); await db.SaveChangesAsync(); } }