feat(creator): Refactor Creator flow, implement book creation pipeline & versioning, and setup Docker staging

- Relocate dashboard routing to /creator and editor workspace to /creator/edit/{BookId}
- Implement CreateBookCommand and handler with transactional default chapter seeding
- Implement PublishBookVersionCommand and GetCreatorDashboardDataQuery
- Build CreatorDashboard modal and UI components with customized dark input styles
- Add run-stage.sh script to automate staging environment setup, database migrations, and health checks
- Update developer workflow rules in GEMINI.md
This commit is contained in:
2026-06-14 10:58:37 +02:00
parent 978485e8ff
commit 8856fb1614
29 changed files with 4656 additions and 388 deletions
@@ -0,0 +1,278 @@
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.Queries.Creator;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Domain.Exceptions;
using Xunit;
namespace NexusReader.Application.Tests.Queries;
public class CreatorDashboardTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
public CreatorDashboardTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
// Seed initial database schema
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
}
private NexusUser CreateTestUser(string userId, string tenantId)
{
return new NexusUser
{
Id = userId,
UserName = $"user_{userId}",
Email = $"{userId}@example.com",
TenantId = tenantId,
SubscriptionPlanId = 1
};
}
[Fact]
public async Task GetCreatorDashboardData_WithValidUser_ProjectsCorrectlyAndNeverLoadsMarkdownToTracker()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
var draft = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
// Standard markdown content (length 58 characters -> estimated word count: 9 words)
var chapter = new Chapter
{
Id = Guid.NewGuid(),
BookRevisionId = draft.Id,
Title = "Chapter One",
MarkdownContent = "This is a content snippet that contains exactly ten words.", // 58 chars
SortOrder = 1
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
context.BookRevisions.Add(draft);
context.Chapters.Add(chapter);
await context.SaveChangesAsync();
// Link draft revision
var dbBook = await context.Books.FindAsync(bookId);
dbBook!.CurrentDraftRevisionId = draft.Id;
await context.SaveChangesAsync();
}
var query = new GetCreatorDashboardDataQuery(userId, tenantId);
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Books.Should().HaveCount(1);
var bookDto = result.Value.Books.First();
bookDto.Title.Should().Be("Authored Masterpiece");
bookDto.WordCount.Should().Be(58 / 6); // projected word count calculation check
bookDto.AggregatedReads.Should().Be(Math.Abs(bookId.GetHashCode() % 1000) + 120);
// Verify metrics are calculated
result.Value.Metrics.TotalReads.Should().Be(bookDto.AggregatedReads);
result.Value.Metrics.ActiveReaders.Should().BeGreaterThan(0);
result.Value.Metrics.GrossRevenue.Should().Be(bookDto.AggregatedReads * 1.49m);
result.Value.Metrics.AvgReadTimeMinutes.Should().Be(Math.Round((58 / 6) / 250.0, 1));
}
[Fact]
public async Task GetCreatorDashboardData_EnforcesTenantAndUserBoundaries()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
// Query with mismatched tenant ID
var queryMismatchedTenant = new GetCreatorDashboardDataQuery(userId, "different-tenant");
var handler = new GetCreatorDashboardDataQueryHandler(_dbContextFactoryMock.Object);
// Act
var resultMismatchedTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
// Assert
resultMismatchedTenant.IsSuccess.Should().BeTrue();
resultMismatchedTenant.Value.Books.Should().BeEmpty();
resultMismatchedTenant.Value.Metrics.TotalReads.Should().Be(0);
// Query with mismatched user ID
var queryMismatchedUser = new GetCreatorDashboardDataQuery("different-user", tenantId);
// Act
var resultMismatchedUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
// Assert
resultMismatchedUser.IsSuccess.Should().BeTrue();
resultMismatchedUser.Value.Books.Should().BeEmpty();
}
[Fact]
public async Task GetBookRevisions_WithValidBook_ReturnsRevisionsOrderedByDate()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
var revision1 = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "v1.0.0",
IsPublished = true,
CreatedAt = DateTime.UtcNow.AddMinutes(-5),
PublishedAt = DateTime.UtcNow.AddMinutes(-5)
};
var revision2 = new BookRevision
{
Id = Guid.NewGuid(),
BookId = bookId,
VersionString = "Working Draft",
IsPublished = false,
CreatedAt = DateTime.UtcNow
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
context.BookRevisions.Add(revision1);
context.BookRevisions.Add(revision2);
await context.SaveChangesAsync();
}
var query = new GetBookRevisionsQuery(bookId, userId, tenantId);
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
// Ordered by CreatedAt descending
result.Value[0].VersionString.Should().Be("Working Draft");
result.Value[1].VersionString.Should().Be("v1.0.0");
}
[Fact]
public async Task GetBookRevisions_WithMismatchedUserOrTenant_ThrowsBookNotFoundException()
{
// Arrange
var userId = "creator-123";
var tenantId = "tenant-abc";
var bookId = Guid.NewGuid();
var user = CreateTestUser(userId, tenantId);
var book = new Book
{
Id = bookId,
Title = "Authored Masterpiece",
UserId = userId,
TenantId = tenantId
};
using (var context = new AppDbContext(_contextOptions))
{
context.Users.Add(user);
context.Books.Add(book);
await context.SaveChangesAsync();
}
var handler = new GetBookRevisionsQueryHandler(_dbContextFactoryMock.Object);
// Act & Assert
var queryMismatchedTenant = new GetBookRevisionsQuery(bookId, userId, "different-tenant");
var actionTenant = () => handler.Handle(queryMismatchedTenant, CancellationToken.None);
await actionTenant.Should().ThrowAsync<BookNotFoundException>();
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
var actionUser = () => handler.Handle(queryMismatchedUser, CancellationToken.None);
await actionUser.Should().ThrowAsync<BookNotFoundException>();
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}