Add GitHub-style activity contribution graph
Record create/update/delete events across novel content (chapters, beats, characters, arc stages, tags, locations, questions) into an append-only ActivityEvent log, aggregate by UTC day, and surface as a heatmap on the novels list and each novel's dashboard. Backfills history from existing CreatedAt timestamps on first boot after the migration.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Activity;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ActivityTests : ServiceTestFixture
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Writing_prose_records_the_word_delta_for_that_day()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||
|
||||
await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Prose: "one two three four five"));
|
||||
|
||||
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||
|
||||
Assert.That(calendar.Days.Sum(d => d.Words), Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Editing_a_beat_records_one_edit_not_two()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||
var beat = await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("She finds the map"));
|
||||
|
||||
await Beats.UpdateAsync(beat!.Id, new UpdateBeatRequest(WhatHappened: "She finds the map under the floorboards."));
|
||||
|
||||
var beatEvents = await Db.Context.ActivityEvents.CountAsync(e => e.EntityKind == ActivityEntityKind.Beat);
|
||||
var chapterEvents = await Db.Context.ActivityEvents.CountAsync(e => e.EntityKind == ActivityEntityKind.Chapter);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(beatEvents, Is.EqualTo(2));
|
||||
Assert.That(chapterEvents, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_chapter_records_a_negative_word_delta()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall", Prose: "one two three"));
|
||||
|
||||
await Chapters.DeleteAsync(chapter!.Id);
|
||||
|
||||
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||
|
||||
Assert.That(calendar.Days.Sum(d => d.Words), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_calendar_groups_multiple_edits_into_one_day()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
var chapter = await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||
|
||||
await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Summary: "First pass"));
|
||||
await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Summary: "Second pass"));
|
||||
|
||||
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(calendar.Days, Has.Count.EqualTo(1));
|
||||
Assert.That(calendar.Days[0].Edits, Is.EqualTo(4));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_calendar_omits_days_with_no_activity()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
|
||||
var calendar = await Activity.GetForNovelAsync(novel.Id, days: 7);
|
||||
|
||||
Assert.That(calendar.Days, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task The_calendar_excludes_novels_the_user_cannot_read()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("Someone Else's Novel"));
|
||||
|
||||
AsNewUser(GlobalRole.Writer);
|
||||
await Novels.CreateAsync(new CreateNovelRequest("My Own Novel"));
|
||||
|
||||
var calendar = await Activity.GetForCurrentUserAsync(days: 7);
|
||||
|
||||
Assert.That(calendar.TotalEdits, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_novel_removes_its_activity_events()
|
||||
{
|
||||
var novel = await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"));
|
||||
await Chapters.CreateAsync(novel.Id, new CreateChapterRequest("Landfall"));
|
||||
|
||||
await Novels.DeleteAsync(novel.Id);
|
||||
|
||||
Assert.That(await Db.Context.ActivityEvents.CountAsync(e => e.NovelId == novel.Id), Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Novelly.Api.Activity;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
@@ -15,6 +16,8 @@ public abstract class ServiceTestFixture
|
||||
protected TestDatabase Db { get; private set; } = null!;
|
||||
protected TestUserContext UserContext { get; private set; } = null!;
|
||||
protected NovelAccessService Access { get; private set; } = null!;
|
||||
protected ActivityLog ActivityLog { get; private set; } = null!;
|
||||
protected ActivityService Activity { get; private set; } = null!;
|
||||
protected TagService Tags { get; private set; } = null!;
|
||||
protected LocationService Locations { get; private set; } = null!;
|
||||
protected NovelService Novels { get; private set; } = null!;
|
||||
@@ -41,6 +44,8 @@ public abstract class ServiceTestFixture
|
||||
Db = new TestDatabase();
|
||||
UserContext = new TestUserContext();
|
||||
Access = new NovelAccessService(Db.Context, UserContext, new CapturingLogger<NovelAccessService>());
|
||||
ActivityLog = new ActivityLog(Db.Context, UserContext, new CapturingLogger<ActivityLog>());
|
||||
Activity = new ActivityService(Db.Context, Access, new CapturingLogger<ActivityService>());
|
||||
|
||||
Db.Context.Users.Add(new NovellyUser
|
||||
{
|
||||
@@ -62,25 +67,25 @@ public abstract class ServiceTestFixture
|
||||
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
||||
GenreLogs = new CapturingLogger<GenreService>();
|
||||
|
||||
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
|
||||
Locations = new LocationService(Db.Context, Access, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
|
||||
Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
|
||||
Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
|
||||
Novels = new NovelService(
|
||||
Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
|
||||
Db.Context, Access, UserContext, ActivityLog, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
|
||||
Characters = new CharacterService(
|
||||
Db.Context, Access, Tags, CharacterLogs,
|
||||
Db.Context, Access, Tags, ActivityLog, CharacterLogs,
|
||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
|
||||
new LinkCharacterIdentityRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ActivityLog, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||
Beats = new BeatService(
|
||||
Db.Context, Access, Tags, BeatLogs,
|
||||
Db.Context, Access, Tags, ActivityLog, BeatLogs,
|
||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
|
||||
new AssignCharacterToBeatsRequestValidator(), new MoveBeatsRequestValidator());
|
||||
Arcs = new CharacterArcService(
|
||||
Db.Context, Access, ArcLogs,
|
||||
Db.Context, Access, ActivityLog, ArcLogs,
|
||||
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator(),
|
||||
new SetArcStageBeatsRequestValidator());
|
||||
Questions = new OpenQuestionService(
|
||||
Db.Context, Access, QuestionLogs,
|
||||
Db.Context, Access, ActivityLog, QuestionLogs,
|
||||
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
|
||||
Genres = new GenreService(Db.Context, GenreLogs);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user