Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -34,6 +34,7 @@ public static class BookFactory
|
||||
HardcoverId = hardcoverId,
|
||||
BookAuthors = [],
|
||||
SeriesEntries = [],
|
||||
Editions = [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +54,13 @@ public static class BookFactory
|
||||
return book;
|
||||
}
|
||||
|
||||
public static Book WithEditions(this Book book, params (string? Isbn, string? Publisher, string? CoverUrl)[] editions)
|
||||
{
|
||||
foreach (var (isbn, publisher, coverUrl) in editions)
|
||||
book.Editions.Add(new Edition { BookId = book.Id, Isbn = isbn, Publisher = publisher, CoverUrl = coverUrl });
|
||||
return book;
|
||||
}
|
||||
|
||||
public static Book WithSeries(this Book book, int seriesId = 1, string seriesName = "Test Series", double position = 1.0, string? arc = null)
|
||||
{
|
||||
var series = new Series { Id = seriesId, Name = seriesName };
|
||||
|
||||
@@ -30,7 +30,7 @@ public class BooksControllerTests : IAsyncLifetime
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE book_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE");
|
||||
"TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
@@ -166,7 +166,7 @@ public class BooksControllerTests : IAsyncLifetime
|
||||
Year = 1965,
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Authors = ["Frank Herbert"],
|
||||
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverColor = "#c4a35a",
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PageManager.Api.Api.Dtos;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration;
|
||||
|
||||
[Collection("Postgres")]
|
||||
public class FilesControllerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestWebAppFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public FilesControllerTests(PostgresFixture postgres)
|
||||
{
|
||||
_factory = new TestWebAppFactory(postgres);
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE book_files, book_authors, series_entries, editions, books, authors, series, import_sources RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── GET /api/books/{id}/files ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetBookFiles_NoFiles_Returns200WithEmptyArray()
|
||||
{
|
||||
var bookId = await SeedBookAsync();
|
||||
|
||||
var response = await _client.GetAsync($"/api/books/{bookId}/files");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
|
||||
files.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBookFiles_WithFiles_Returns200WithCorrectData()
|
||||
{
|
||||
var bookId = await SeedBookAsync("Dune");
|
||||
await SeedFileAsync(filename: "dune.epub", format: FileFormat.Epub, bookId: bookId);
|
||||
await SeedFileAsync(filename: "dune.mobi", format: FileFormat.Mobi, bookId: bookId);
|
||||
|
||||
var response = await _client.GetAsync($"/api/books/{bookId}/files");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
|
||||
files.Should().HaveCount(2);
|
||||
files!.Select(f => f.Filename).Should().BeEquivalentTo(["dune.epub", "dune.mobi"]);
|
||||
files.First(f => f.Filename == "dune.epub").Format.Should().Be("epub");
|
||||
}
|
||||
|
||||
// ── GET /api/files?unmatched=true ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnmatchedFiles_MixedFiles_ReturnsOnlyUnmatched()
|
||||
{
|
||||
var bookId = await SeedBookAsync();
|
||||
await SeedFileAsync(filename: "unmatched.epub", bookId: null);
|
||||
await SeedFileAsync(filename: "matched.epub", bookId: bookId);
|
||||
|
||||
var response = await _client.GetAsync("/api/files?unmatched=true");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var files = await response.Content.ReadFromJsonAsync<BookFileDto[]>();
|
||||
files.Should().ContainSingle(f => f.Filename == "unmatched.epub");
|
||||
files.Should().NotContain(f => f.Filename == "matched.epub");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFiles_NoUnmatchedParam_Returns400()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/files");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
// ── PATCH /api/files/{id} ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AssignFile_Exists_Returns200WithUpdatedBookId()
|
||||
{
|
||||
var bookId = await SeedBookAsync();
|
||||
var fileId = await SeedFileAsync(filename: "orphan.epub", bookId: null);
|
||||
|
||||
var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var file = await response.Content.ReadFromJsonAsync<BookFileDto>();
|
||||
file!.BookId.Should().Be(bookId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignFile_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.PatchAsJsonAsync("/api/files/99999", new { bookId = 1 });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssignFile_ClearsBookId_WhenSetToNull()
|
||||
{
|
||||
var bookId = await SeedBookAsync();
|
||||
var fileId = await SeedFileAsync(filename: "book.epub", bookId: bookId);
|
||||
|
||||
var response = await _client.PatchAsJsonAsync($"/api/files/{fileId}", new { bookId = (int?)null });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var file = await response.Content.ReadFromJsonAsync<BookFileDto>();
|
||||
file!.BookId.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── DELETE /api/files/{id} ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_Exists_Returns204AndRemovesRecord()
|
||||
{
|
||||
var fileId = await SeedFileAsync();
|
||||
|
||||
var response = await _client.DeleteAsync($"/api/files/{fileId}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var exists = await db.BookFiles.AnyAsync(f => f.Id == fileId);
|
||||
exists.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteFile_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/files/99999");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> SeedBookAsync(string title = "Test Book")
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var book = new Book { Title = title, Formats = [], Genres = [] };
|
||||
db.Books.Add(book);
|
||||
await db.SaveChangesAsync();
|
||||
return book.Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedFileAsync(
|
||||
string filename = "test.epub",
|
||||
FileFormat format = FileFormat.Epub,
|
||||
int? bookId = null,
|
||||
long sizeBytes = 1024)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var file = new BookFile
|
||||
{
|
||||
Filename = filename,
|
||||
Path = filename,
|
||||
Format = format,
|
||||
SizeBytes = sizeBytes,
|
||||
BookId = bookId,
|
||||
AddedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.BookFiles.Add(file);
|
||||
await db.SaveChangesAsync();
|
||||
return file.Id;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,39 @@ public class BooksServiceTests
|
||||
result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]);
|
||||
}
|
||||
|
||||
// ── Editions DTO mapping ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithEditions_MapsEditionsToDto()
|
||||
{
|
||||
var book = BookFactory.Create(id: 10)
|
||||
.WithAuthors((1, "Author"))
|
||||
.WithEditions(
|
||||
("9780441013593", "Ace Books", "https://example.com/cover1.jpg"),
|
||||
(null, "Bantam", null));
|
||||
_repo.GetByIdAsync(10).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(10);
|
||||
|
||||
result!.Editions.Should().HaveCount(2);
|
||||
result.Editions[0].Isbn.Should().Be("9780441013593");
|
||||
result.Editions[0].Publisher.Should().Be("Ace Books");
|
||||
result.Editions[0].CoverUrl.Should().Be("https://example.com/cover1.jpg");
|
||||
result.Editions[1].Isbn.Should().BeNull();
|
||||
result.Editions[1].Publisher.Should().Be("Bantam");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_BookWithNoEditions_ReturnsEmptyEditionsArray()
|
||||
{
|
||||
var book = BookFactory.Create(id: 11).WithAuthors((1, "Author"));
|
||||
_repo.GetByIdAsync(11).Returns(book);
|
||||
|
||||
var result = await _sut.GetByIdAsync(11);
|
||||
|
||||
result!.Editions.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── CreateFromHardcoverAsync ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -202,7 +235,7 @@ public class BooksServiceTests
|
||||
result.Should().BeNull();
|
||||
await _repo.DidNotReceive().CreateBookAsync(
|
||||
Arg.Any<PageManager.Api.Data.Models.Book>(),
|
||||
Arg.Any<IReadOnlyList<string>>(),
|
||||
Arg.Any<IReadOnlyList<HardcoverAuthor>>(),
|
||||
Arg.Any<(string, double, string?)?> ());
|
||||
}
|
||||
|
||||
@@ -219,7 +252,7 @@ public class BooksServiceTests
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Description = "A sci-fi classic.",
|
||||
Authors = ["Frank Herbert"],
|
||||
Authors = [new HardcoverAuthor { Name = "Frank Herbert", Role = "Author" }],
|
||||
Genres = ["Science Fiction"],
|
||||
Isbn = "9780441013593",
|
||||
CoverUrl = "https://example.com/cover.jpg",
|
||||
@@ -231,7 +264,7 @@ public class BooksServiceTests
|
||||
var createdBook = BookFactory.Create(id: 55, title: "Dune")
|
||||
.WithAuthors((1, "Frank Herbert"))
|
||||
.WithSeries(seriesName: "Dune Chronicles", position: 1.0);
|
||||
_repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<(string, double, string?)?>())
|
||||
_repo.CreateBookAsync(Arg.Any<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<HardcoverAuthor>>(), Arg.Any<(string, double, string?)?>())
|
||||
.Returns(createdBook);
|
||||
|
||||
var result = await _sut.CreateFromHardcoverAsync(123);
|
||||
@@ -242,7 +275,7 @@ public class BooksServiceTests
|
||||
result.Series!.Name.Should().Be("Dune Chronicles");
|
||||
await _repo.Received(1).CreateBookAsync(
|
||||
Arg.Is<PageManager.Api.Data.Models.Book>(b => b.HardcoverId == 123 && b.Title == "Dune"),
|
||||
Arg.Is<IReadOnlyList<string>>(a => a.Contains("Frank Herbert")),
|
||||
Arg.Is<IReadOnlyList<HardcoverAuthor>>(a => a.Any(ha => ha.Name == "Frank Herbert")),
|
||||
Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using FluentAssertions;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Services;
|
||||
using PageManager.Api.Tests.Helpers;
|
||||
|
||||
namespace PageManager.Api.Tests.Unit.Services;
|
||||
|
||||
public class FileScannerServiceTests
|
||||
{
|
||||
// ── GetFormatFromExtension ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(".epub", FileFormat.Epub)]
|
||||
[InlineData(".mobi", FileFormat.Mobi)]
|
||||
[InlineData(".pdf", FileFormat.Pdf)]
|
||||
[InlineData(".m4b", FileFormat.M4b)]
|
||||
[InlineData(".mp3", FileFormat.Mp3)]
|
||||
[InlineData(".aac", FileFormat.Aac)]
|
||||
[InlineData(".flac", FileFormat.Flac)]
|
||||
[InlineData(".EPUB", FileFormat.Epub)] // case-insensitive
|
||||
[InlineData(".PDF", FileFormat.Pdf)]
|
||||
public void GetFormatFromExtension_KnownExtension_ReturnsFormat(string ext, FileFormat expected)
|
||||
{
|
||||
FileScannerService.GetFormatFromExtension(ext).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".txt")]
|
||||
[InlineData(".docx")]
|
||||
[InlineData(".zip")]
|
||||
[InlineData("")]
|
||||
public void GetFormatFromExtension_UnknownExtension_ReturnsNull(string ext)
|
||||
{
|
||||
FileScannerService.GetFormatFromExtension(ext).Should().BeNull();
|
||||
}
|
||||
|
||||
// ── FindMatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_FilenameContainsBookTitle_ReturnsBook()
|
||||
{
|
||||
var books = new[] { BookFactory.Create(id: 1, title: "Dune") };
|
||||
var file = new BookFile { Filename = "Dune - Frank Herbert.epub" };
|
||||
|
||||
FileScannerService.FindMatch(file, books)!.Id.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_CaseInsensitive_ReturnsBook()
|
||||
{
|
||||
var books = new[] { BookFactory.Create(id: 2, title: "The Way of Kings") };
|
||||
var file = new BookFile { Filename = "the way of kings.epub" };
|
||||
|
||||
FileScannerService.FindMatch(file, books)!.Id.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_FilenameContainsIsbn_ReturnsBook()
|
||||
{
|
||||
var books = new[] { BookFactory.Create(id: 3, title: "Foundation", isbn: "9780553293357") };
|
||||
var file = new BookFile { Filename = "9780553293357.epub" };
|
||||
|
||||
FileScannerService.FindMatch(file, books)!.Id.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_NoMatch_ReturnsNull()
|
||||
{
|
||||
var books = new[] { BookFactory.Create(id: 1, title: "Dune") };
|
||||
var file = new BookFile { Filename = "Foundation - Isaac Asimov.epub" };
|
||||
|
||||
FileScannerService.FindMatch(file, books).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_EmptyBookList_ReturnsNull()
|
||||
{
|
||||
FileScannerService.FindMatch(new BookFile { Filename = "anything.epub" }, []).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindMatch_BookWithNullIsbn_DoesNotThrow()
|
||||
{
|
||||
var books = new[] { BookFactory.Create(id: 1, title: "Dune", isbn: null) };
|
||||
var file = new BookFile { Filename = "some-file.epub" };
|
||||
|
||||
var act = () => FileScannerService.FindMatch(file, books);
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user