Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped

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:
James Wampler
2026-08-20 16:39:09 -07:00
parent 7df1fffdca
commit aca26588f9
59 changed files with 2913 additions and 170 deletions
+6 -3
View File
@@ -152,7 +152,7 @@ public class CharacterArcTests : ServiceTestFixture
}
[Test]
public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it()
public async Task Trashing_a_chapter_hides_it_from_an_arc_stage_without_unpinning_it()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var stage = await Arcs.CreateAsync(
@@ -161,11 +161,14 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id);
var survivor = (await Arcs.GetAsync(stage.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("The map is wrong"));
Assert.That(survivor.ChapterId, Is.EqualTo(chapter.Id), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterNumber, Is.Null);
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(response.Title, Is.EqualTo("The map is wrong"));
});
}
@@ -258,7 +258,7 @@ public class CharacterServiceTests : ServiceTestFixture
}
[Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
public async Task Trashing_the_canonical_character_keeps_the_link_so_restoring_brings_it_back()
{
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
@@ -268,7 +268,13 @@ public class CharacterServiceTests : ServiceTestFixture
using var verification = Db.CreateContext();
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
Assert.That(survivor.SameCharacterAsId, Is.Null);
var response = (await Characters.GetAsync(stranger.Id))!.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.SameCharacterAsId, Is.EqualTo(kael.Id), "the pin survives so restoring Kael restores the identity link");
Assert.That(response.SameCharacterAsName, Is.Null, "a trashed canonical identity does not show up while it's in the trash");
});
}
[Test]
@@ -151,7 +151,7 @@ public class LocationServiceTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(survivor.Title, Is.EqualTo("Landfall"));
Assert.That(survivor.Locations, Is.Empty);
Assert.That(survivor.ToResponse().Locations, Is.Empty);
});
}
+4 -2
View File
@@ -182,7 +182,7 @@ public class OpenQuestionTests : ServiceTestFixture
}
[Test]
public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them()
public async Task Trashing_a_chapter_leaves_its_questions_open_and_pinned_but_hides_the_chapter()
{
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId));
@@ -190,10 +190,12 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId);
var survivor = (await Questions.GetAsync(question.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.ChapterId, Is.Null);
Assert.That(survivor.ChapterId, Is.EqualTo(_chapterId), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?"));
});
}
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using Novelly.Api.Activity;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
@@ -7,6 +8,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
@@ -28,6 +30,8 @@ public abstract class ServiceTestFixture
protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!;
protected GenreService Genres { get; private set; } = null!;
protected TrashService Trash { get; private set; } = null!;
protected TrashOptions TrashOptions { get; private set; } = null!;
protected CapturingLogger<NovelService> NovelLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
@@ -38,6 +42,7 @@ public abstract class ServiceTestFixture
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
protected CapturingLogger<TrashService> TrashLogs { get; private set; } = null!;
[SetUp]
public void SetUpFixture()
@@ -67,6 +72,7 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
GenreLogs = new CapturingLogger<GenreService>();
TrashLogs = new CapturingLogger<TrashService>();
Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
@@ -90,6 +96,8 @@ public abstract class ServiceTestFixture
Db.Context, Access, ActivityLog, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
Genres = new GenreService(Db.Context, GenreLogs);
TrashOptions = new TrashOptions();
Trash = new TrashService(Db.Context, Access, ActivityLog, Options.Create(TrashOptions), TrashLogs);
OnSetUp();
}
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
using Novelly.Api.Trash;
namespace Novelly.Api.Tests;
[TestFixture]
public class TrashPurgeScheduleTests
{
[Test]
public void Before_the_run_time_the_next_run_is_today() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 1, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void After_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 3, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void Exactly_at_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
}
[TestFixture]
public class TrashPurgeSweepTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task A_sweep_removes_only_items_older_than_the_retention_window()
{
var old = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Old News"));
var recent = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Fresh Off The Press"));
await Chapters.DeleteAsync(old!.Id);
await Chapters.DeleteAsync(recent!.Id);
using (var context = Db.CreateContext())
{
var oldRow = await context.Chapters.IgnoreQueryFilters().SingleAsync(c => c.Id == old.Id);
oldRow.DeletedAt = DateTimeOffset.UtcNow.AddDays(-31);
await context.SaveChangesAsync();
}
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var (characters, chapters, locations) = await TrashPurgeRunner.SweepAsync(Db.Context, cutoff, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That((characters, chapters, locations), Is.EqualTo((0, 1, 0)));
});
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == old.Id), Is.False);
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == recent.Id), Is.True);
});
}
}
@@ -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();
}
}