Project scaffolding

This commit is contained in:
2026-03-15 22:31:31 +02:00
parent 501528897d
commit cbd7f52535
106 changed files with 11222 additions and 0 deletions
@@ -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;
}
}