Changed design language. Added editions, better support for authors. Base for file handling

This commit is contained in:
2026-03-28 15:17:20 +02:00
parent cbd7f52535
commit 5acde17a53
84 changed files with 5861 additions and 1983 deletions
@@ -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;
}
}