Rename Project concept to Novel across the stack
Renames the domain concept from Project to Novel throughout the backend (entities, DTOs, services, endpoints, ProjectAccessService/Permission, ProjectId foreign keys), MCP server (tool names and routes), and the React/Vite frontend (types, hooks, routes, components). Adds a new EF Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to preserve existing data instead of dropping/recreating tables. Updates CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class NovelAccessTests : ServiceTestFixture
|
||||
{
|
||||
private Guid AsNewUser(GlobalRole globalRole)
|
||||
{
|
||||
var user = new NovellyUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = $"{Guid.NewGuid()}@novelly.test",
|
||||
Email = $"{Guid.NewGuid()}@novelly.test",
|
||||
DisplayName = "Test User",
|
||||
GlobalRole = globalRole
|
||||
};
|
||||
Db.Context.Users.Add(user);
|
||||
Db.Context.SaveChanges();
|
||||
|
||||
UserContext.UserId = user.Id;
|
||||
UserContext.GlobalRole = globalRole;
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
private void GrantNovelRole(Guid novelId, Guid userId, NovelRole role)
|
||||
{
|
||||
Db.Context.NovelMembers.Add(new NovelMember { NovelId = novelId, UserId = userId, NovelRole = role, GrantedByUserId = userId });
|
||||
Db.Context.SaveChanges();
|
||||
}
|
||||
|
||||
private void AsAdmin()
|
||||
{
|
||||
UserContext.UserId = Db.Context.Users.Single(u => u.GlobalRole == GlobalRole.Admin).Id;
|
||||
UserContext.GlobalRole = GlobalRole.Admin;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_writer_sees_only_novels_they_own_or_have_been_granted()
|
||||
{
|
||||
var writerId = AsNewUser(GlobalRole.Writer);
|
||||
var ownedNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer"));
|
||||
|
||||
AsAdmin();
|
||||
var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else"));
|
||||
|
||||
UserContext.UserId = writerId;
|
||||
UserContext.GlobalRole = GlobalRole.Writer;
|
||||
|
||||
var visibleBeforeGrant = await Novels.ListAsync();
|
||||
Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id }));
|
||||
|
||||
GrantNovelRole(otherNovel.Id, writerId, NovelRole.Reviewer);
|
||||
|
||||
var visibleAfterGrant = await Novels.ListAsync();
|
||||
Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedNovel.Id, otherNovel.Id }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task An_editor_can_rewrite_a_chapter_but_cannot_delete_it()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Editable Novel"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One"));
|
||||
|
||||
var editorId = AsNewUser(GlobalRole.Reviewer);
|
||||
GrantNovelRole(novel.Id, editorId, NovelRole.Editor);
|
||||
|
||||
var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Title: "Renamed"));
|
||||
Assert.That(updated!.Title, Is.EqualTo("Renamed"));
|
||||
|
||||
Assert.That(() => Chapters.DeleteAsync(chapter.Id), Throws.TypeOf<NotAuthorizedException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_editor_cannot_create_a_new_novel()
|
||||
{
|
||||
AsNewUser(GlobalRole.Editor);
|
||||
|
||||
Assert.That(() => Novels.CreateAsync(new CreateNovelRequest("Should not exist")), Throws.TypeOf<NotAuthorizedException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_reviewer_can_read_a_chapter_but_not_change_it()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Reviewed Novel"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Chapter One"));
|
||||
|
||||
var reviewerId = AsNewUser(GlobalRole.Reviewer);
|
||||
GrantNovelRole(novel.Id, reviewerId, NovelRole.Reviewer);
|
||||
|
||||
var read = await Chapters.GetAsync(chapter!.Id);
|
||||
Assert.That(read, Is.Not.Null);
|
||||
|
||||
Assert.That(
|
||||
() => Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "Nope")),
|
||||
Throws.TypeOf<NotAuthorizedException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_writer_granted_access_to_someone_elses_novel_still_cannot_grant_access_to_others()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
|
||||
|
||||
var grantedWriterId = AsNewUser(GlobalRole.Writer);
|
||||
GrantNovelRole(novel.Id, grantedWriterId, NovelRole.Writer);
|
||||
|
||||
Assert.That(
|
||||
() => Access.RequireAsync(novel.Id, NovelPermission.ManageAccess),
|
||||
Throws.TypeOf<NotAuthorizedException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task An_admin_reaches_every_novel()
|
||||
{
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
await Novels.CreateAsync(new CreateNovelRequest("Writer's Novel"));
|
||||
|
||||
AsAdmin();
|
||||
await Novels.CreateAsync(new CreateNovelRequest("Admin's Novel"));
|
||||
|
||||
var visible = await Novels.ListAsync();
|
||||
Assert.That(visible, Has.Count.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deleting_a_user_does_not_cascade_to_their_novels()
|
||||
{
|
||||
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Novel))!.FindNavigation(nameof(Novel.Owner))!;
|
||||
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_creator_of_a_novel_sees_their_role_as_owner()
|
||||
{
|
||||
var writerId = AsNewUser(GlobalRole.Writer);
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by writer"));
|
||||
|
||||
UserContext.UserId = writerId;
|
||||
var role = await Access.GetMyRoleAsync(novel);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Owner"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own()
|
||||
{
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Owned by someone else"));
|
||||
|
||||
AsAdmin();
|
||||
var role = await Access.GetMyRoleAsync(novel);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Admin"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_user_granted_editor_sees_their_role_as_editor()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Granted Novel"));
|
||||
var editorId = AsNewUser(GlobalRole.Reviewer);
|
||||
GrantNovelRole(novel.Id, editorId, NovelRole.Editor);
|
||||
|
||||
var role = await Access.GetMyRoleAsync(novel);
|
||||
|
||||
Assert.That(role, Is.EqualTo("Editor"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_user_with_no_access_sees_a_null_role()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
|
||||
var role = await Access.GetMyRoleAsync(novel);
|
||||
|
||||
Assert.That(role, Is.Null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user