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().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()); Assert.That(() => Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf()); }); } [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(); } }