feat(creator): overhaul Creator flow, editor duplication, and staging setup (#83)
This pull request completely overhauls the Creator editor flow, resolves the editor duplication race condition, aligns layout/styling themes in light and dark mode, and adds Docker staging setups. ### Key Changes 1. **Creator Flow Polish**: Redesigned the editor canvas to prevent double scrolling by delegating overflow to the editor canvas layer, updated styles to a premium aesthetic. 2. **Race Condition Prevention**: Resolved Crepe editor duplication when loading or switching chapters by tracking state via shared window maps (`window.editorCache`, `window.editorStates`) and checking `_lastInitializedEditorId` synchronously in Blazor. 3. **Theme Synchronization**: Integrated explicit theme initialization (`ThemeService.InitializeAsync()`) and anchored CSS isolation selectors to correctly sync with Light (Soft Sepia) and Deep Dark theme preferences. 4. **Staging Automation**: Created staging docker configurations with `--nexus-only` flag to allow iterative development without resetting PG/Neo4j database containers. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #83 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #83.
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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_ReturnsFailure()
|
||||
{
|
||||
// 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 resultTenant = await handler.Handle(queryMismatchedTenant, CancellationToken.None);
|
||||
resultTenant.IsSuccess.Should().BeFalse();
|
||||
resultTenant.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
||||
|
||||
var queryMismatchedUser = new GetBookRevisionsQuery(bookId, "different-user", tenantId);
|
||||
var resultUser = await handler.Handle(queryMismatchedUser, CancellationToken.None);
|
||||
resultUser.IsSuccess.Should().BeFalse();
|
||||
resultUser.Errors.Should().Contain(e => e.Message.Contains("was not found"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Close();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user