feat(#15): session audit log domain, store, and instrumentation

This commit is contained in:
Hermes Agent
2026-05-07 12:16:54 +00:00
parent 6394b1fe8c
commit 35894bf89e
6 changed files with 508 additions and 0 deletions
@@ -167,6 +167,81 @@ public sealed class AuthorizedSessionServiceTests
Assert.Equal(5, store.LastUpdatedMaxPlayers);
}
[Fact]
public async Task UpdateSessionForGmAsync_LogsAudit_WhenTitleChanges()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
Assert.Single(store.LogEntries);
Assert.Equal("Title", store.LogEntries[0].ChangeType);
Assert.Equal("Session A", store.LogEntries[0].OldValue);
Assert.Equal("Updated Title", store.LogEntries[0].NewValue);
}
[Fact]
public async Task UpdateSessionForGmAsync_LogsMultipleAudits_WhenMultipleFieldsChange()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var originalTime = DateTime.UtcNow;
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
var newTime = originalTime.AddDays(1);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);
Assert.Equal(3, store.LogEntries.Count);
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
Assert.Contains(store.LogEntries, e => e.ChangeType == "Time");
Assert.Contains(store.LogEntries, e => e.ChangeType == "Link");
}
[Fact]
public async Task UpdateSessionForGmAsync_DoesNotLogAudit_WhenNothingChanges()
{
var gmId = 1001L;
var groupId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var originalTime = DateTime.UtcNow;
var store = new FakeSessionStore(
groups:
[
new(groupId, 42, "Alpha", gmId)
],
sessions:
[
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]);
var service = new AuthorizedSessionService(store);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 10);
Assert.Empty(store.LogEntries);
}
[Fact]
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
{
@@ -659,6 +734,13 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
public Guid? LastRemovedPlayerParticipantId { get; private set; }
public List<SessionAuditLogEntry> LogEntries { get; private set; } = new();
public Guid? LastLogSessionId { get; private set; }
public long? LastLogActorTelegramId { get; private set; }
public string? LastLogActorName { get; private set; }
public string? LastLogChangeType { get; private set; }
public string? LastLogOldValue { get; private set; }
public string? LastLogNewValue { get; private set; }
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, gmId)).ToList());
@@ -889,6 +971,22 @@ public sealed class AuthorizedSessionServiceTests
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
Task.FromResult(new List<PlayerAttendanceStats>());
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = actorTelegramId;
LastLogActorName = actorName;
LastLogChangeType = changeType;
LastLogOldValue = oldValue;
LastLogNewValue = newValue;
return Task.CompletedTask;
}
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);