Project scaffolding
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
global using Xunit;
|
||||
global using FluentAssertions;
|
||||
global using NSubstitute;
|
||||
@@ -0,0 +1,70 @@
|
||||
using PageManager.Api.Data.Models;
|
||||
|
||||
namespace PageManager.Api.Tests.Helpers;
|
||||
|
||||
public static class BookFactory
|
||||
{
|
||||
public static Book Create(
|
||||
int id = 1,
|
||||
string title = "Test Book",
|
||||
int? year = 2024,
|
||||
string? publisher = "Test Publisher",
|
||||
int? pages = 300,
|
||||
string? description = "A test book.",
|
||||
string[] formats = null!,
|
||||
string color = "#6366f1",
|
||||
string[] genres = null!,
|
||||
string? coverUrl = null,
|
||||
string? isbn = null,
|
||||
int? hardcoverId = null)
|
||||
{
|
||||
return new Book
|
||||
{
|
||||
Id = id,
|
||||
Title = title,
|
||||
Year = year,
|
||||
Publisher = publisher,
|
||||
Pages = pages,
|
||||
Description = description,
|
||||
Formats = formats ?? ["epub"],
|
||||
Color = color,
|
||||
Genres = genres ?? ["Fiction"],
|
||||
CoverUrl = coverUrl,
|
||||
Isbn = isbn,
|
||||
HardcoverId = hardcoverId,
|
||||
BookAuthors = [],
|
||||
SeriesEntries = [],
|
||||
};
|
||||
}
|
||||
|
||||
public static Book WithAuthors(this Book book, params (int Id, string Name)[] authors)
|
||||
{
|
||||
foreach (var (id, name) in authors)
|
||||
{
|
||||
var author = new Author { Id = id, Name = name };
|
||||
book.BookAuthors.Add(new BookAuthor
|
||||
{
|
||||
BookId = book.Id,
|
||||
AuthorId = id,
|
||||
Author = author,
|
||||
Book = book,
|
||||
});
|
||||
}
|
||||
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 };
|
||||
book.SeriesEntries.Add(new SeriesEntry
|
||||
{
|
||||
SeriesId = seriesId,
|
||||
BookId = book.Id,
|
||||
Series = series,
|
||||
Book = book,
|
||||
Position = position,
|
||||
Arc = arc,
|
||||
});
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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<AppDbContext>();
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE book_authors, series_entries, 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<BookDto[]>();
|
||||
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<BookDto[]>();
|
||||
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<BookDto>();
|
||||
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<BookDto>();
|
||||
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<AppDbContext>();
|
||||
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<IHardcoverService>();
|
||||
mockHardcover.GetBookDetailsAsync(999).Returns(new HardcoverBookDetails
|
||||
{
|
||||
Id = 999,
|
||||
Title = "Dune",
|
||||
Year = 1965,
|
||||
Publisher = "Ace Books",
|
||||
Pages = 412,
|
||||
Authors = ["Frank Herbert"],
|
||||
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<BookDto>();
|
||||
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<IHardcoverService>();
|
||||
mockHardcover.GetBookDetailsAsync(Arg.Any<int>()).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<IHardcoverService>();
|
||||
|
||||
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<BookDto>();
|
||||
book!.Title.Should().Be("Already Here");
|
||||
await mockHardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any<int>());
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<int> 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<AppDbContext>();
|
||||
|
||||
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<AppDbContext>();
|
||||
db.Books.Add(new Book { Title = title, HardcoverId = hardcoverId, Formats = [], Genres = [] });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
public class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.Build();
|
||||
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseNpgsql(ConnectionString)
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("Postgres")]
|
||||
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PageManager.Api.Data;
|
||||
|
||||
namespace PageManager.Api.Tests.Integration.Fixtures;
|
||||
|
||||
public class TestWebAppFactory(PostgresFixture postgres) : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
|
||||
if (descriptor is not null)
|
||||
services.Remove(descriptor);
|
||||
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(postgres.ConnectionString)
|
||||
.UseSnakeCaseNamingConvention());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
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 ImportControllerTests : IAsyncLifetime
|
||||
{
|
||||
private readonly TestWebAppFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public ImportControllerTests(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 Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions
|
||||
.ExecuteSqlRawAsync(db.Database,
|
||||
"TRUNCATE import_queue_items, import_sources RESTART IDENTITY CASCADE");
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── GET /api/sources ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSources_EmptyDb_Returns200WithEmptyArray()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/sources");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
|
||||
sources.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSources_SeededSource_ReturnsMappedDto()
|
||||
{
|
||||
await SeedSourceAsync(name: "My Library", type: ImportSourceType.Folder, path: "/books");
|
||||
|
||||
var response = await _client.GetAsync("/api/sources");
|
||||
var sources = await response.Content.ReadFromJsonAsync<ImportSourceDto[]>();
|
||||
|
||||
sources.Should().HaveCount(1);
|
||||
sources![0].Name.Should().Be("My Library");
|
||||
sources[0].Type.Should().Be("folder");
|
||||
sources[0].Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
// ── PATCH /api/sources/{id} ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSource_Found_Returns200WithUpdatedEnabled()
|
||||
{
|
||||
var id = await SeedSourceAsync();
|
||||
|
||||
var response = await _client.PatchAsJsonAsync($"/api/sources/{id}", new { enabled = false });
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var dto = await response.Content.ReadFromJsonAsync<ImportSourceDto>();
|
||||
dto!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSource_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.PatchAsJsonAsync("/api/sources/missing-id", new { enabled = false });
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── GET /api/queue ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueue_EmptyDb_Returns200WithEmptyArray()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/queue");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var items = await response.Content.ReadFromJsonAsync<QueueItemDto[]>();
|
||||
items.Should().BeEmpty();
|
||||
}
|
||||
|
||||
// ── DELETE /api/queue/{id} ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItem_Found_Returns204()
|
||||
{
|
||||
var id = await SeedQueueItemAsync();
|
||||
var response = await _client.DeleteAsync($"/api/queue/{id}");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItem_NotFound_Returns404()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/queue/missing-id");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── POST /api/queue/{id}/retry ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItem_FailedItem_Returns204()
|
||||
{
|
||||
var id = await SeedQueueItemAsync(status: QueueItemStatus.Failed);
|
||||
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItem_NonFailedItem_Returns404()
|
||||
{
|
||||
var id = await SeedQueueItemAsync(status: QueueItemStatus.Completed);
|
||||
var response = await _client.PostAsync($"/api/queue/{id}/retry", null);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> SeedSourceAsync(
|
||||
string name = "Test Source",
|
||||
ImportSourceType type = ImportSourceType.Folder,
|
||||
string path = "/test")
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var source = new ImportSource { Name = name, Type = type, Path = path };
|
||||
db.ImportSources.Add(source);
|
||||
await db.SaveChangesAsync();
|
||||
return source.Id;
|
||||
}
|
||||
|
||||
private async Task<string> SeedQueueItemAsync(
|
||||
string filename = "book.epub",
|
||||
QueueItemStatus status = QueueItemStatus.Queued)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var item = new ImportQueueItem { Filename = filename, Status = status, Source = "https://example.com" };
|
||||
db.ImportQueueItems.Add(item);
|
||||
await db.SaveChangesAsync();
|
||||
return item.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PageManager.Api\PageManager.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
|
||||
<!-- Pin EF Core versions to match the main project (avoids version conflict from Testcontainers) -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,248 @@
|
||||
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<IBooksRepository>();
|
||||
private readonly IHardcoverService _hardcover = Substitute.For<IHardcoverService>();
|
||||
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<int>());
|
||||
}
|
||||
|
||||
[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<PageManager.Api.Data.Models.Book>(),
|
||||
Arg.Any<IReadOnlyList<string>>(),
|
||||
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<PageManager.Api.Data.Models.Book>(), Arg.Any<IReadOnlyList<string>>(), 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<PageManager.Api.Data.Models.Book>(b => b.HardcoverId == 123 && b.Title == "Dune"),
|
||||
Arg.Is<IReadOnlyList<string>>(a => a.Contains("Frank Herbert")),
|
||||
Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PageManager.Api.Data;
|
||||
using PageManager.Api.Data.Models;
|
||||
using PageManager.Api.Services;
|
||||
|
||||
namespace PageManager.Api.Tests.Unit.Services;
|
||||
|
||||
public class ImportServiceTests : IDisposable
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ImportService _sut;
|
||||
|
||||
public ImportServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new AppDbContext(options);
|
||||
_sut = new ImportService(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// ── Sources ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSourcesAsync_ReturnsMappedDtos()
|
||||
{
|
||||
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "My Books", Type = ImportSourceType.Folder, Path = "/books", Enabled = true });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = (await _sut.GetSourcesAsync()).ToArray();
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be("s1");
|
||||
result[0].Name.Should().Be("My Books");
|
||||
result[0].Type.Should().Be("folder");
|
||||
result[0].Enabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSourceAsync_Found_TogglesEnabled()
|
||||
{
|
||||
_db.ImportSources.Add(new ImportSource { Id = "s1", Name = "Lib", Type = ImportSourceType.Folder, Path = "/", Enabled = true });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.UpdateSourceAsync("s1", false);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Enabled.Should().BeFalse();
|
||||
(await _db.ImportSources.FindAsync("s1"))!.Enabled.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSourceAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
var result = await _sut.UpdateSourceAsync("missing", true);
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
// ── Queue ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueueAsync_ReturnsMappedDtos()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem
|
||||
{
|
||||
Id = "q1", Filename = "book.epub", SizeBytes = 1024, DownloadedBytes = 512,
|
||||
Status = QueueItemStatus.Downloading, Source = "https://example.com",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = (await _sut.GetQueueAsync()).ToArray();
|
||||
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Id.Should().Be("q1");
|
||||
result[0].Status.Should().Be("downloading");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Found_RemovesAndReturnsTrue()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.RemoveQueueItemAsync("q1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
_db.ImportQueueItems.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_NotFound_ReturnsFalse()
|
||||
{
|
||||
(await _sut.RemoveQueueItemAsync("missing")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItemAsync_FailedItem_SetsStatusToQueued()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem
|
||||
{
|
||||
Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Failed, Error = "timeout",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _sut.RetryQueueItemAsync("q1");
|
||||
|
||||
result.Should().BeTrue();
|
||||
var item = await _db.ImportQueueItems.FindAsync("q1");
|
||||
item!.Status.Should().Be(QueueItemStatus.Queued);
|
||||
item.Error.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryQueueItemAsync_NonFailedItem_ReturnsFalse()
|
||||
{
|
||||
_db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed });
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
(await _sut.RetryQueueItemAsync("q1")).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user