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.
250 lines
10 KiB
C#
250 lines
10 KiB
C#
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();
|
|
}
|
|
}
|