8856fb1614
- 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
279 lines
9.1 KiB
C#
279 lines
9.1 KiB
C#
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();
|
|
}
|
|
}
|