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:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user