Files
novelly/tests/Novelly.Api.Tests/TrashServiceTests.cs
T
James Wampler aca26588f9
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped
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.
2026-08-20 16:39:09 -07:00

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();
}
}