Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
Adds SoftDelete/Trash across characters, chapters, locations, beats with a purge schedule and Trash page. Reworks the web client for keyboard-driven navigation (focus helpers, help overlay, keyboard.md doc). Moves the ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Trash;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class TrashServiceTests : ServiceTestFixture
|
||||
{
|
||||
private Guid _novelId;
|
||||
|
||||
protected override void OnSetUp() =>
|
||||
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
|
||||
|
||||
[Test]
|
||||
public async Task Trashing_a_chapter_hides_it_from_the_chapter_list_but_keeps_its_beats()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
|
||||
var listed = await Chapters.ListAsync(_novelId);
|
||||
Assert.That(listed, Is.Empty);
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
var survivingBeats = await verification.Beats.IgnoreQueryFilters().Where(b => b.ChapterId == chapter.Id).ToListAsync();
|
||||
Assert.That(survivingBeats, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Restoring_a_chapter_brings_its_beats_back()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
|
||||
|
||||
var restored = await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
|
||||
Assert.That(restored, Is.True);
|
||||
|
||||
var survivor = (await Chapters.GetAsync(chapter.Id))!;
|
||||
Assert.That(survivor.Beats.Select(b => b.Title), Is.EquivalentTo(new[] { "They spot the wreck" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Restoring_a_chapter_restores_its_word_count_to_the_activity_feed()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Prose: "The tide came in slow and cold."));
|
||||
|
||||
await Chapters.DeleteAsync(chapter!.Id);
|
||||
var afterDelete = await Activity.GetForNovelAsync(_novelId, days: 1);
|
||||
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
|
||||
await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
|
||||
var afterRestore = await Activity.GetForNovelAsync(_novelId, days: 1);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(afterDelete.TotalWords, Is.EqualTo(0), "the create and the trash deltas cancel out");
|
||||
Assert.That(afterRestore.TotalWords, Is.EqualTo(chapter.WordCount), "restoring adds the word count back");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task A_trashed_location_name_can_be_used_by_a_new_location()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(
|
||||
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
|
||||
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
|
||||
|
||||
await Locations.DeleteAsync(locationId);
|
||||
|
||||
var recreated = await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
|
||||
|
||||
Assert.That(recreated, Is.Not.Null);
|
||||
Assert.That(chapter, Is.Not.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Restoring_a_location_whose_name_was_taken_reports_a_clash()
|
||||
{
|
||||
var locationId = (await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour")))!.Id;
|
||||
await Locations.DeleteAsync(locationId);
|
||||
await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
|
||||
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Location).Id;
|
||||
|
||||
Assert.That(
|
||||
() => Trash.RestoreAsync(TrashEntityKind.Location, trashedId),
|
||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Trashed_characters_disappear_from_beat_listings()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
||||
var beat = await Beats.CreateAsync(
|
||||
chapter!.Id, new CreateBeatRequest("Kael spots the wreck", CharacterIds: [character!.Id]));
|
||||
|
||||
await Characters.DeleteAsync(character.Id);
|
||||
|
||||
var survivor = (await Beats.GetAsync(beat!.Id))!;
|
||||
Assert.That(survivor.ToResponse().Characters, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Restoring_a_trashed_character_brings_it_back()
|
||||
{
|
||||
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
||||
await Characters.DeleteAsync(character!.Id);
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
|
||||
|
||||
var restored = await Trash.RestoreAsync(TrashEntityKind.Character, trashedId);
|
||||
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
Assert.That(restored, Is.True);
|
||||
Assert.That(await Characters.GetAsync(character.Id), Is.Not.Null);
|
||||
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Emptying_trash_purges_everything_in_the_novel_but_leaves_other_novels_alone()
|
||||
{
|
||||
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
|
||||
var elsewhereChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
|
||||
await Chapters.DeleteAsync(elsewhereChapter!.Id);
|
||||
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
||||
await Chapters.DeleteAsync(chapter!.Id);
|
||||
await Characters.DeleteAsync(character!.Id);
|
||||
|
||||
var purged = await Trash.EmptyAsync(_novelId);
|
||||
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
Assert.That(purged, Is.EqualTo(2));
|
||||
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
|
||||
Assert.That((await Trash.ListAsync(otherNovelId)).Select(i => i.Id), Is.EquivalentTo(new[] { elsewhereChapter.Id }));
|
||||
});
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
Assert.Multiple(async () =>
|
||||
{
|
||||
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == chapter.Id), Is.False);
|
||||
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == character.Id), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Purging_a_character_that_others_relate_to_succeeds()
|
||||
{
|
||||
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
|
||||
var mira = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mira"));
|
||||
await Characters.AddRelationshipAsync(mira!.Id, new CreateRelationshipRequest(kael!.Id, "sibling"));
|
||||
|
||||
await Characters.DeleteAsync(kael.Id);
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
|
||||
|
||||
var purged = await Trash.PurgeAsync(TrashEntityKind.Character, trashedId);
|
||||
Assert.That(purged, Is.True);
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == kael.Id), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Purging_a_chapter_deletes_its_beats_for_good()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
|
||||
|
||||
await Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId);
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
Assert.That(await verification.Beats.IgnoreQueryFilters().AnyAsync(b => b.ChapterId == chapter.Id), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task An_editor_cannot_restore_or_purge()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
await Chapters.DeleteAsync(chapter!.Id);
|
||||
var trashedId = (await Trash.ListAsync(_novelId)).Single().Id;
|
||||
|
||||
var editorId = AsNewUser(GlobalRole.Reviewer);
|
||||
GrantNovelRole(_novelId, editorId, NovelRole.Editor);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(() => Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
|
||||
Assert.That(() => Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Trash_lists_only_the_novels_own_items()
|
||||
{
|
||||
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
|
||||
var otherChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
|
||||
await Chapters.DeleteAsync(otherChapter!.Id);
|
||||
|
||||
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||
await Chapters.DeleteAsync(chapter!.Id);
|
||||
|
||||
var listed = await Trash.ListAsync(_novelId);
|
||||
|
||||
Assert.That(listed.Select(i => i.Id), Is.EquivalentTo(new[] { chapter.Id }));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user