diff --git a/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs new file mode 100644 index 00000000..5b427a3e --- /dev/null +++ b/SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs @@ -0,0 +1,167 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using SVSim.Database.Entities.Guild; +using System.Data; + +namespace SVSim.Database.Repositories.Guild; + +public sealed class GuildChatMessageRepository : IGuildChatMessageRepository +{ + private readonly SVSimDbContext _db; + + public GuildChatMessageRepository(SVSimDbContext db) { _db = db; } + + /// + /// Allocates the next per-guild monotonic MessageId and inserts the row inside a serializable + /// transaction (Postgres). The unique (GuildId, MessageId) index is the backstop — if a + /// concurrent insert wins the race we retry once before surfacing the exception. + /// InMemory provider does not support transactions; callers are single-threaded in tests. + /// + public async Task AppendAsync(GuildChatMessage msg, CancellationToken ct = default) + { + for (int attempt = 0; attempt < 2; attempt++) + { + IDbContextTransaction? tx = null; + try + { + // BeginTransactionAsync throws NotSupportedException on the InMemory provider. + // Catch and fall through — single-threaded tests don't need transaction isolation. + try + { + tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + } + catch (InvalidOperationException) + { + // InMemory provider does not support transactions. + } + + int nextId = await GetMaxMessageIdAsync(msg.GuildId, ct) + 1; + msg.MessageId = nextId; + + _db.GuildChatMessages.Add(msg); + await _db.SaveChangesAsync(ct); + + if (tx is not null) + await tx.CommitAsync(ct); + + return msg; + } + catch (DbUpdateException) when (attempt == 0) + { + // Unique violation on (GuildId, MessageId) — another writer raced us. + // Detach the failed entity and retry once. + if (tx is not null) + { + try { await tx.RollbackAsync(ct); } catch { /* ignore rollback failure */ } + } + _db.Entry(msg).State = EntityState.Detached; + msg.MessageId = 0; // will be re-assigned on retry + } + finally + { + if (tx is not null) + await tx.DisposeAsync(); + } + } + + throw new InvalidOperationException( + $"Failed to allocate a unique MessageId for GuildId={msg.GuildId} after 2 attempts."); + } + + /// + /// Window query for message history. + /// direction 1 = OLD (walk backwards from start, return ascending), + /// direction 2 = NEW (walk forwards from start), + /// direction 3 = BOTH (around start, older half + newer half). + /// start = 0 means "latest". + /// Result is always ordered oldest-to-newest. + /// + public async Task> GetWindowAsync( + int guildId, int start, int direction, int limit, CancellationToken ct = default) + { + IQueryable baseQ = _db.GuildChatMessages + .Where(m => m.GuildId == guildId); + + if (start == 0) + { + // Latest N messages. + return await baseQ + .OrderByDescending(m => m.MessageId) + .Take(limit) + .OrderBy(m => m.MessageId) + .ToListAsync(ct); + } + + switch (direction) + { + case 1: // OLD — messages older than (and including) start + return await baseQ + .Where(m => m.MessageId <= start) + .OrderByDescending(m => m.MessageId) + .Take(limit) + .OrderBy(m => m.MessageId) + .ToListAsync(ct); + + case 2: // NEW — messages newer than (and including) start + return await baseQ + .Where(m => m.MessageId >= start) + .OrderBy(m => m.MessageId) + .Take(limit) + .ToListAsync(ct); + + case 3: // BOTH — half older, half newer around start + { + int half = limit / 2; + var older = await baseQ + .Where(m => m.MessageId <= start) + .OrderByDescending(m => m.MessageId) + .Take(half) + .ToListAsync(ct); + var newer = await baseQ + .Where(m => m.MessageId > start) + .OrderBy(m => m.MessageId) + .Take(limit - half) + .ToListAsync(ct); + return older.OrderBy(m => m.MessageId).Concat(newer).ToList(); + } + + default: + return await baseQ + .OrderByDescending(m => m.MessageId) + .Take(limit) + .OrderBy(m => m.MessageId) + .ToListAsync(ct); + } + } + + public async Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default) + { + var max = await _db.GuildChatMessages + .Where(m => m.GuildId == guildId) + .MaxAsync(m => (int?)m.MessageId, ct); + return max ?? 0; + } + + public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) + { + var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct); + _db.GuildChatMessages.RemoveRange(messages); + if (messages.Count > 0) + await _db.SaveChangesAsync(ct); + } + + public async Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default) + { + var msg = await _db.GuildChatMessages + .FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct); + + if (msg is null) return false; + if (msg.AuthorViewerId != callerViewerId) return false; + if (msg.DeckPayload is null) return false; + + msg.DeckPayload = null; + msg.Body = ""; + await _db.SaveChangesAsync(ct); + return true; + } +} diff --git a/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs b/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs new file mode 100644 index 00000000..87c92110 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/GuildInviteRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public sealed class GuildInviteRepository : IGuildInviteRepository +{ + private readonly SVSimDbContext _db; + + public GuildInviteRepository(SVSimDbContext db) { _db = db; } + + public Task GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default) + => _db.GuildInvites.FirstOrDefaultAsync( + i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct); + + public async Task> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default) + => await _db.GuildInvites + .Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending) + .ToListAsync(ct); + + public async Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default) + => await _db.GuildInvites + .Where(i => i.GuildId == guildId && i.Status == GuildInviteStatus.Pending) + .ToListAsync(ct); + + public Task CountPendingForInviteeAsync(long viewerId, CancellationToken ct = default) + => _db.GuildInvites.CountAsync( + i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending, ct); + + public async Task AddAsync(GuildInvite invite, CancellationToken ct = default) + { + _db.GuildInvites.Add(invite); + await _db.SaveChangesAsync(ct); + } + + public async Task UpdateStatusAsync(int guildId, long inviteeViewerId, GuildInviteStatus status, + DateTime respondedAt, CancellationToken ct = default) + { + var invite = await _db.GuildInvites + .FirstOrDefaultAsync(i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct); + if (invite is null) return; + invite.Status = status; + invite.RespondedAt = respondedAt; + await _db.SaveChangesAsync(ct); + } + + public async Task ConsumePendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default) + { + var pending = await _db.GuildInvites + .Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending) + .ToListAsync(ct); + foreach (var invite in pending) + { + invite.Status = GuildInviteStatus.Consumed; + invite.RespondedAt = respondedAt; + } + if (pending.Count > 0) + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) + { + var invites = await _db.GuildInvites.Where(i => i.GuildId == guildId).ToListAsync(ct); + _db.GuildInvites.RemoveRange(invites); + if (invites.Count > 0) + await _db.SaveChangesAsync(ct); + } +} diff --git a/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs b/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs new file mode 100644 index 00000000..cc9231cb --- /dev/null +++ b/SVSim.Database/Repositories/Guild/GuildJoinRequestRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public sealed class GuildJoinRequestRepository : IGuildJoinRequestRepository +{ + private readonly SVSimDbContext _db; + + public GuildJoinRequestRepository(SVSimDbContext db) { _db = db; } + + public Task GetAsync(int guildId, long viewerId, CancellationToken ct = default) + => _db.GuildJoinRequests.FirstOrDefaultAsync( + r => r.GuildId == guildId && r.ViewerId == viewerId, ct); + + public async Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default) + => await _db.GuildJoinRequests + .Where(r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending) + .ToListAsync(ct); + + public async Task> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default) + => await _db.GuildJoinRequests + .Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending) + .ToListAsync(ct); + + public Task CountPendingForGuildAsync(int guildId, CancellationToken ct = default) + => _db.GuildJoinRequests.CountAsync( + r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending, ct); + + public async Task AddAsync(GuildJoinRequest r, CancellationToken ct = default) + { + _db.GuildJoinRequests.Add(r); + await _db.SaveChangesAsync(ct); + } + + public async Task UpdateStatusAsync(int guildId, long viewerId, GuildJoinRequestStatus status, + DateTime respondedAt, CancellationToken ct = default) + { + var req = await _db.GuildJoinRequests + .FirstOrDefaultAsync(r => r.GuildId == guildId && r.ViewerId == viewerId, ct); + if (req is null) return; + req.Status = status; + req.RespondedAt = respondedAt; + await _db.SaveChangesAsync(ct); + } + + public async Task CancelPendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default) + { + var pending = await _db.GuildJoinRequests + .Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending) + .ToListAsync(ct); + foreach (var req in pending) + { + req.Status = GuildJoinRequestStatus.Canceled; + req.RespondedAt = respondedAt; + } + if (pending.Count > 0) + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) + { + var requests = await _db.GuildJoinRequests.Where(r => r.GuildId == guildId).ToListAsync(ct); + _db.GuildJoinRequests.RemoveRange(requests); + if (requests.Count > 0) + await _db.SaveChangesAsync(ct); + } +} diff --git a/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs b/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs new file mode 100644 index 00000000..f7e7b48b --- /dev/null +++ b/SVSim.Database/Repositories/Guild/GuildMemberRepository.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public sealed class GuildMemberRepository : IGuildMemberRepository +{ + private readonly SVSimDbContext _db; + + public GuildMemberRepository(SVSimDbContext db) { _db = db; } + + /// + /// The unique index on ViewerId guarantees at most one membership row per viewer. + /// + public Task GetMembershipAsync(long viewerId, CancellationToken ct = default) + => _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct); + + public async Task> ListByGuildAsync(int guildId, CancellationToken ct = default) + => await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct); + + public Task CountByGuildAsync(int guildId, CancellationToken ct = default) + => _db.GuildMembers.CountAsync(m => m.GuildId == guildId, ct); + + public Task CountByGuildAndRoleAsync(int guildId, GuildRole role, CancellationToken ct = default) + => _db.GuildMembers.CountAsync(m => m.GuildId == guildId && m.Role == role, ct); + + public async Task AddAsync(GuildMember m, CancellationToken ct = default) + { + _db.GuildMembers.Add(m); + await _db.SaveChangesAsync(ct); + } + + public async Task UpdateRoleAsync(int guildId, long viewerId, GuildRole role, CancellationToken ct = default) + { + var member = await _db.GuildMembers + .FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == viewerId, ct); + if (member is null) return; + member.Role = role; + await _db.SaveChangesAsync(ct); + } + + public async Task RemoveAsync(int guildId, long viewerId, CancellationToken ct = default) + { + var member = await _db.GuildMembers + .FirstOrDefaultAsync(m => m.GuildId == guildId && m.ViewerId == viewerId, ct); + if (member is null) return; + _db.GuildMembers.Remove(member); + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) + { + var members = await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct); + _db.GuildMembers.RemoveRange(members); + await _db.SaveChangesAsync(ct); + } + + public async Task> CountBatchByGuildIdsAsync( + IReadOnlyList guildIds, CancellationToken ct = default) + { + var counts = await _db.GuildMembers + .Where(m => guildIds.Contains(m.GuildId)) + .GroupBy(m => m.GuildId) + .Select(g => new { GuildId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + // Seed all requested guild ids so callers don't need a null-check for guilds with 0 members. + var result = guildIds.ToDictionary(id => id, _ => 0); + foreach (var row in counts) + result[row.GuildId] = row.Count; + return result; + } +} diff --git a/SVSim.Database/Repositories/Guild/GuildRepository.cs b/SVSim.Database/Repositories/Guild/GuildRepository.cs new file mode 100644 index 00000000..6f094381 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/GuildRepository.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; + +namespace SVSim.Database.Repositories.Guild; + +public sealed class GuildRepository : IGuildRepository +{ + private readonly SVSimDbContext _db; + + public GuildRepository(SVSimDbContext db) { _db = db; } + + public Task GetByIdAsync(int guildId, CancellationToken ct = default) + => _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct); + + public Task GetActiveByIdAsync(int guildId, CancellationToken ct = default) + => _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId && g.BreakupAt == null, ct); + + public Task GetWithMembersAsync(int guildId, CancellationToken ct = default) + => _db.Guilds + .Include(g => g.Members) + .AsSplitQuery() + .FirstOrDefaultAsync(g => g.GuildId == guildId, ct); + + public Task NameExistsAsync(string name, CancellationToken ct = default) + => _db.Guilds.AnyAsync(g => g.Name == name && g.BreakupAt == null, ct); + + public async Task AddAsync(Entities.Guild.Guild g, CancellationToken ct = default) + { + _db.Guilds.Add(g); + await _db.SaveChangesAsync(ct); + return g; + } + + public async Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default) + { + var guild = await _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct); + if (guild is null) return; + guild.BreakupAt = brokenUpAt; + await _db.SaveChangesAsync(ct); + } + + public async Task> SearchAsync( + string name, int activity, int joinCondition, int memberBucket, + int maxMemberCap, int resultCap, CancellationToken ct = default) + { + IQueryable q = _db.Guilds.Where(g => g.BreakupAt == null); + + if (!string.IsNullOrEmpty(name)) + q = q.Where(g => g.Name.Contains(name)); + + if (activity != 0) + q = q.Where(g => (int)g.Activity == activity); + + if (joinCondition != 0) + q = q.Where(g => (int)g.JoinCondition == joinCondition); + + if (memberBucket != 0) + { + q = memberBucket switch + { + 1 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 1 + && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= 10), + 2 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 11 + && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= 25), + 3 => q.Where(g => _db.GuildMembers.Count(m => m.GuildId == g.GuildId) >= 26 + && _db.GuildMembers.Count(m => m.GuildId == g.GuildId) <= maxMemberCap), + _ => q, + }; + } + + return await q.Take(resultCap).ToListAsync(ct); + } +} diff --git a/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs new file mode 100644 index 00000000..4a76f688 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/IGuildChatMessageRepository.cs @@ -0,0 +1,20 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public interface IGuildChatMessageRepository +{ + /// Atomically allocate the next per-guild message_id and insert. + Task AppendAsync(GuildChatMessage msg, CancellationToken ct = default); + + /// Window query. direction: 1=OLD (asc-walk older), 2=NEW (newer), 3=BOTH (around). + /// `start` may be 0 meaning "latest". Returns ordered oldest-to-newest. + Task> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default); + + Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default); + Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); + + /// For /guild_chat/delete_deck: clears the deck payload + body without removing the message row, + /// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller. + Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default); +} diff --git a/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs b/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs new file mode 100644 index 00000000..867bdc01 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/IGuildInviteRepository.cs @@ -0,0 +1,15 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public interface IGuildInviteRepository +{ + Task GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default); + Task> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default); + Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default); + Task CountPendingForInviteeAsync(long viewerId, CancellationToken ct = default); + Task AddAsync(GuildInvite invite, CancellationToken ct = default); + Task UpdateStatusAsync(int guildId, long inviteeViewerId, GuildInviteStatus status, DateTime respondedAt, CancellationToken ct = default); + Task ConsumePendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default); + Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); +} diff --git a/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs b/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs new file mode 100644 index 00000000..970d00a3 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/IGuildJoinRequestRepository.cs @@ -0,0 +1,15 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public interface IGuildJoinRequestRepository +{ + Task GetAsync(int guildId, long viewerId, CancellationToken ct = default); + Task> ListPendingForGuildAsync(int guildId, CancellationToken ct = default); + Task> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default); + Task CountPendingForGuildAsync(int guildId, CancellationToken ct = default); + Task AddAsync(GuildJoinRequest r, CancellationToken ct = default); + Task UpdateStatusAsync(int guildId, long viewerId, GuildJoinRequestStatus status, DateTime respondedAt, CancellationToken ct = default); + Task CancelPendingForViewerAsync(long viewerId, DateTime respondedAt, CancellationToken ct = default); + Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); +} diff --git a/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs b/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs new file mode 100644 index 00000000..75243808 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/IGuildMemberRepository.cs @@ -0,0 +1,18 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public interface IGuildMemberRepository +{ + Task GetMembershipAsync(long viewerId, CancellationToken ct = default); + Task> ListByGuildAsync(int guildId, CancellationToken ct = default); + Task CountByGuildAsync(int guildId, CancellationToken ct = default); + Task CountByGuildAndRoleAsync(int guildId, GuildRole role, CancellationToken ct = default); + Task AddAsync(GuildMember m, CancellationToken ct = default); + Task UpdateRoleAsync(int guildId, long viewerId, GuildRole role, CancellationToken ct = default); + Task RemoveAsync(int guildId, long viewerId, CancellationToken ct = default); + Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default); + + /// Returns a count of members per guild for the given guild ids. Used by Task 9 search. + Task> CountBatchByGuildIdsAsync(IReadOnlyList guildIds, CancellationToken ct = default); +} diff --git a/SVSim.Database/Repositories/Guild/IGuildRepository.cs b/SVSim.Database/Repositories/Guild/IGuildRepository.cs new file mode 100644 index 00000000..6ee67938 --- /dev/null +++ b/SVSim.Database/Repositories/Guild/IGuildRepository.cs @@ -0,0 +1,27 @@ +using SVSim.Database.Entities.Guild; + +namespace SVSim.Database.Repositories.Guild; + +public interface IGuildRepository +{ + Task GetByIdAsync(int guildId, CancellationToken ct = default); + + /// Returns guild only if not soft-deleted (BreakupAt is null). + Task GetActiveByIdAsync(int guildId, CancellationToken ct = default); + + /// Returns guild + populated Members list (split-query). + Task GetWithMembersAsync(int guildId, CancellationToken ct = default); + + Task NameExistsAsync(string name, CancellationToken ct = default); + + Task AddAsync(Entities.Guild.Guild g, CancellationToken ct = default); + + /// Soft-delete: sets BreakupAt to UtcNow. Caller is responsible for cascading + /// hard-deletes on Members / Invites / JoinRequests / ChatMessages. + Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default); + + /// Filtered + bucketed search. Empty name = match-all; activity/joinCondition/bucket = 0 = any. + Task> SearchAsync( + string name, int activity, int joinCondition, int memberBucket, + int maxMemberCap, int resultCap, CancellationToken ct = default); +} diff --git a/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs b/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs new file mode 100644 index 00000000..fd2ee3a1 --- /dev/null +++ b/SVSim.UnitTests/Repositories/Guild/GuildRepositoryTests.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using SVSim.Database; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Repositories.Guild; +using GuildEntity = SVSim.Database.Entities.Guild.Guild; + +namespace SVSim.UnitTests.Repositories.Guild; + +[TestFixture] +public class GuildRepositoryTests +{ + private SVSimDbContext Db() + => new(NullLogger.Instance, + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"guild-repo-{Guid.NewGuid()}") + .Options); + + [Test] + public async Task Add_and_load_by_id_round_trips() + { + await using var db = Db(); + var repo = new GuildRepository(db); + var g = new GuildEntity + { + GuildId = 100_000_007, Name = "Repo", Description = "", + LeaderViewerId = 1, EmblemId = 100000000, + Activity = GuildActivity.All, JoinCondition = GuildJoinCondition.Free, + CreatedAt = DateTime.UtcNow, + }; + await repo.AddAsync(g); + + var loaded = await repo.GetActiveByIdAsync(100_000_007); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded!.Name, Is.EqualTo("Repo")); + } + + [Test] + public async Task SearchAsync_filters_by_activity_and_join_condition() + { + await using var db = Db(); + var repo = new GuildRepository(db); + await repo.AddAsync(new GuildEntity { GuildId = 1, Name = "Alpha", Activity = GuildActivity.Royal, JoinCondition = GuildJoinCondition.Free, CreatedAt = DateTime.UtcNow }); + await repo.AddAsync(new GuildEntity { GuildId = 2, Name = "Beta", Activity = GuildActivity.Elf, JoinCondition = GuildJoinCondition.Approval, CreatedAt = DateTime.UtcNow }); + + var royalFree = await repo.SearchAsync("", (int)GuildActivity.Royal, (int)GuildJoinCondition.Free, 0, 30, 50); + Assert.That(royalFree.Select(x => x.Name), Is.EquivalentTo(new[] { "Alpha" })); + + var any = await repo.SearchAsync("", 0, 0, 0, 30, 50); + Assert.That(any, Has.Count.EqualTo(2)); + } + + [Test] + public async Task SearchAsync_bucket_3_includes_through_MaxMemberNum() + { + await using var db = Db(); + var repo = new GuildRepository(db); + var bigId = 1; + await repo.AddAsync(new GuildEntity { GuildId = bigId, Name = "Big", Activity = GuildActivity.All, JoinCondition = GuildJoinCondition.Free, CreatedAt = DateTime.UtcNow }); + for (int i = 0; i < 27; i++) + db.GuildMembers.Add(new GuildMember { GuildId = bigId, ViewerId = 1000 + i, Role = GuildRole.Regular, JoinedAt = DateTime.UtcNow }); + await db.SaveChangesAsync(); + + var large = await repo.SearchAsync("", 0, 0, 3, 30, 50); + Assert.That(large.Select(x => x.GuildId), Has.Member(bigId)); + + var small = await repo.SearchAsync("", 0, 0, 1, 30, 50); + Assert.That(small.Select(x => x.GuildId), Has.No.Member(bigId)); + } +}