using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Moq; using NexusReader.Application.Features.Books.Commands; using NexusReader.Data.Persistence; using NexusReader.Domain.Entities; using NexusReader.Domain.Exceptions; using Xunit; namespace NexusReader.Application.Tests.Commands; public class PublishBookVersionTests : IDisposable { private readonly SqliteConnection _connection; private readonly DbContextOptions _contextOptions; private readonly Mock> _dbContextFactoryMock; public PublishBookVersionTests() { _connection = new SqliteConnection("DataSource=:memory:"); _connection.Open(); _contextOptions = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; // Seed initial database schema using var context = new AppDbContext(_contextOptions); context.Database.EnsureCreated(); _dbContextFactoryMock = new Mock>(); _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny())) .ReturnsAsync(() => new AppDbContext(_contextOptions)); } [Fact] public async Task Handle_WithValidBookAndChapters_CorrectlyPublishesAndClonesChaptersWithNewGuids() { // Arrange var bookId = Guid.NewGuid(); var userId = "test-user-123"; var tenantId = "test-tenant-456"; var user = new NexusUser { Id = userId, UserName = "testuser", Email = "test@example.com", TenantId = tenantId, SubscriptionPlanId = 1 }; var book = new Book { Id = bookId, Title = "My Epic Book", UserId = userId, TenantId = tenantId }; var originalDraftRevision = new BookRevision { Id = Guid.NewGuid(), BookId = bookId, VersionString = "Working Draft", IsPublished = false, CreatedAt = DateTime.UtcNow }; var oldChapterId1 = Guid.NewGuid(); var oldChapterId2 = Guid.NewGuid(); var chapter1 = new Chapter { Id = oldChapterId1, BookRevisionId = originalDraftRevision.Id, Title = "Chapter 1: The Beginning", MarkdownContent = "Once upon a time...", SortOrder = 1 }; var chapter2 = new Chapter { Id = oldChapterId2, BookRevisionId = originalDraftRevision.Id, Title = "Chapter 2: The Middle", MarkdownContent = "Interesting things happened.", SortOrder = 2 }; using (var context = new AppDbContext(_contextOptions)) { context.Users.Add(user); context.Books.Add(book); context.BookRevisions.Add(originalDraftRevision); context.Chapters.Add(chapter1); context.Chapters.Add(chapter2); await context.SaveChangesAsync(); // Link the book's draft revision var dbBook = await context.Books.FindAsync(bookId); dbBook!.CurrentDraftRevisionId = originalDraftRevision.Id; await context.SaveChangesAsync(); } var command = new PublishBookVersionCommand( BookId: bookId, CustomVersionString: "v1.0.0", UserId: userId, TenantId: tenantId ); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); using (var context = new AppDbContext(_contextOptions)) { var updatedBook = await context.Books .Include(b => b.Revisions) .ThenInclude(r => r.Chapters) .FirstOrDefaultAsync(b => b.Id == bookId); updatedBook.Should().NotBeNull(); updatedBook!.LivePublishedRevisionId.Should().Be(originalDraftRevision.Id); updatedBook.CurrentDraftRevisionId.Should().NotBeNull(); updatedBook.CurrentDraftRevisionId.Should().NotBe(originalDraftRevision.Id); // Fetch the old draft revision (now frozen / published) var oldDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == originalDraftRevision.Id); oldDraft.Should().NotBeNull(); oldDraft!.IsPublished.Should().BeTrue(); oldDraft.VersionString.Should().Be("v1.0.0"); oldDraft.PublishedAt.Should().NotBeNull(); // Fetch the new working draft revision var newDraft = updatedBook.Revisions.FirstOrDefault(r => r.Id == updatedBook.CurrentDraftRevisionId); newDraft.Should().NotBeNull(); newDraft!.IsPublished.Should().BeFalse(); newDraft.VersionString.Should().Be("Working Draft"); // Verify chapters were deep copied and received brand new GUIDs (Identity Reset) newDraft.Chapters.Should().HaveCount(2); var clonedChapter1 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 1); clonedChapter1.Should().NotBeNull(); clonedChapter1!.Title.Should().Be("Chapter 1: The Beginning"); clonedChapter1.MarkdownContent.Should().Be("Once upon a time..."); clonedChapter1.Id.Should().NotBe(oldChapterId1); // GUID must be regenerated clonedChapter1.BookRevisionId.Should().Be(newDraft.Id); var clonedChapter2 = newDraft.Chapters.FirstOrDefault(c => c.SortOrder == 2); clonedChapter2.Should().NotBeNull(); clonedChapter2!.Title.Should().Be("Chapter 2: The Middle"); clonedChapter2.MarkdownContent.Should().Be("Interesting things happened."); clonedChapter2.Id.Should().NotBe(oldChapterId2); // GUID must be regenerated clonedChapter2.BookRevisionId.Should().Be(newDraft.Id); } } [Fact] public async Task Handle_WithMismatchedTenantId_ReturnsFailure() { // Arrange var bookId = Guid.NewGuid(); var userId = "test-user-123"; var tenantId = "test-tenant-456"; var user = new NexusUser { Id = userId, UserName = "testuser", Email = "test@example.com", TenantId = tenantId, SubscriptionPlanId = 1 }; var book = new Book { Id = bookId, Title = "My Epic Book", UserId = userId, TenantId = tenantId }; using (var context = new AppDbContext(_contextOptions)) { context.Users.Add(user); context.Books.Add(book); await context.SaveChangesAsync(); } // Send command with a different TenantId to check multi-tenancy isolation var command = new PublishBookVersionCommand( BookId: bookId, CustomVersionString: "v1.0.0", UserId: userId, TenantId: "different-tenant-789" ); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); result.Errors.Should().Contain(e => e.Message.Contains("was not found")); } [Fact] public async Task Handle_WithMismatchedUserId_ReturnsFailure() { // Arrange var bookId = Guid.NewGuid(); var userId = "test-user-123"; var tenantId = "test-tenant-456"; var user = new NexusUser { Id = userId, UserName = "testuser", Email = "test@example.com", TenantId = tenantId, SubscriptionPlanId = 1 }; var book = new Book { Id = bookId, Title = "My Epic Book", UserId = userId, TenantId = tenantId }; using (var context = new AppDbContext(_contextOptions)) { context.Users.Add(user); context.Books.Add(book); await context.SaveChangesAsync(); } // Send command with a different UserId to check multi-tenancy isolation var command = new PublishBookVersionCommand( BookId: bookId, CustomVersionString: "v1.0.0", UserId: "different-user-789", TenantId: tenantId ); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); result.Errors.Should().Contain(e => e.Message.Contains("was not found")); } [Fact] public async Task Handle_WithNonExistentBook_ReturnsFailure() { // Arrange var command = new PublishBookVersionCommand( BookId: Guid.NewGuid(), CustomVersionString: "v1.0.0", UserId: "user-1", TenantId: "tenant-1" ); var handler = new PublishBookVersionCommandHandler(_dbContextFactoryMock.Object); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeFalse(); result.Errors.Should().Contain(e => e.Message.Contains("was not found")); } public void Dispose() { _connection.Close(); _connection.Dispose(); } }