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(); 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(); 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(); 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(); 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(); 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(); 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(); 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 SeedBookAsync(string title = "Test Book") { using var scope = _factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var book = new Book { Title = title, Formats = [], Genres = [] }; db.Books.Add(book); await db.SaveChangesAsync(); return book.Id; } private async Task 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(); 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; } }