feat(guild): repositories for Guild aggregate

This commit is contained in:
gamer147
2026-06-27 11:17:51 -04:00
parent 7f245b50d6
commit 3fdc20d21f
11 changed files with 614 additions and 0 deletions

View File

@@ -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; }
/// <summary>
/// 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.
/// </summary>
public async Task<GuildChatMessage> 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.");
}
/// <summary>
/// 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.
/// </summary>
public async Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(
int guildId, int start, int direction, int limit, CancellationToken ct = default)
{
IQueryable<GuildChatMessage> 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<int> 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<bool> 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;
}
}

View File

@@ -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<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default)
=> _db.GuildInvites.FirstOrDefaultAsync(
i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
public async Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
=> await _db.GuildInvites
.Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
.ToListAsync(ct);
public async Task<IReadOnlyList<GuildInvite>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
=> await _db.GuildInvites
.Where(i => i.GuildId == guildId && i.Status == GuildInviteStatus.Pending)
.ToListAsync(ct);
public Task<int> 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);
}
}

View File

@@ -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<GuildJoinRequest?> GetAsync(int guildId, long viewerId, CancellationToken ct = default)
=> _db.GuildJoinRequests.FirstOrDefaultAsync(
r => r.GuildId == guildId && r.ViewerId == viewerId, ct);
public async Task<IReadOnlyList<GuildJoinRequest>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default)
=> await _db.GuildJoinRequests
.Where(r => r.GuildId == guildId && r.Status == GuildJoinRequestStatus.Pending)
.ToListAsync(ct);
public async Task<IReadOnlyList<GuildJoinRequest>> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default)
=> await _db.GuildJoinRequests
.Where(r => r.ViewerId == viewerId && r.Status == GuildJoinRequestStatus.Pending)
.ToListAsync(ct);
public Task<int> 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);
}
}

View File

@@ -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; }
/// <summary>
/// The unique index on ViewerId guarantees at most one membership row per viewer.
/// </summary>
public Task<GuildMember?> GetMembershipAsync(long viewerId, CancellationToken ct = default)
=> _db.GuildMembers.FirstOrDefaultAsync(m => m.ViewerId == viewerId, ct);
public async Task<IReadOnlyList<GuildMember>> ListByGuildAsync(int guildId, CancellationToken ct = default)
=> await _db.GuildMembers.Where(m => m.GuildId == guildId).ToListAsync(ct);
public Task<int> CountByGuildAsync(int guildId, CancellationToken ct = default)
=> _db.GuildMembers.CountAsync(m => m.GuildId == guildId, ct);
public Task<int> 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<Dictionary<int, int>> CountBatchByGuildIdsAsync(
IReadOnlyList<int> 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;
}
}

View File

@@ -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<Entities.Guild.Guild?> GetByIdAsync(int guildId, CancellationToken ct = default)
=> _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
public Task<Entities.Guild.Guild?> GetActiveByIdAsync(int guildId, CancellationToken ct = default)
=> _db.Guilds.FirstOrDefaultAsync(g => g.GuildId == guildId && g.BreakupAt == null, ct);
public Task<Entities.Guild.Guild?> GetWithMembersAsync(int guildId, CancellationToken ct = default)
=> _db.Guilds
.Include(g => g.Members)
.AsSplitQuery()
.FirstOrDefaultAsync(g => g.GuildId == guildId, ct);
public Task<bool> NameExistsAsync(string name, CancellationToken ct = default)
=> _db.Guilds.AnyAsync(g => g.Name == name && g.BreakupAt == null, ct);
public async Task<Entities.Guild.Guild> 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<IReadOnlyList<Entities.Guild.Guild>> SearchAsync(
string name, int activity, int joinCondition, int memberBucket,
int maxMemberCap, int resultCap, CancellationToken ct = default)
{
IQueryable<Entities.Guild.Guild> 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);
}
}

View File

@@ -0,0 +1,20 @@
using SVSim.Database.Entities.Guild;
namespace SVSim.Database.Repositories.Guild;
public interface IGuildChatMessageRepository
{
/// <summary>Atomically allocate the next per-guild message_id and insert.</summary>
Task<GuildChatMessage> AppendAsync(GuildChatMessage msg, CancellationToken ct = default);
/// <summary>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.</summary>
Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(int guildId, int start, int direction, int limit, CancellationToken ct = default);
Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
/// <summary>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.</summary>
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,15 @@
using SVSim.Database.Entities.Guild;
namespace SVSim.Database.Repositories.Guild;
public interface IGuildInviteRepository
{
Task<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildInvite>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
Task<int> 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);
}

View File

@@ -0,0 +1,15 @@
using SVSim.Database.Entities.Guild;
namespace SVSim.Database.Repositories.Guild;
public interface IGuildJoinRequestRepository
{
Task<GuildJoinRequest?> GetAsync(int guildId, long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildJoinRequest>> ListPendingForGuildAsync(int guildId, CancellationToken ct = default);
Task<IReadOnlyList<GuildJoinRequest>> ListPendingForViewerAsync(long viewerId, CancellationToken ct = default);
Task<int> 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);
}

View File

@@ -0,0 +1,18 @@
using SVSim.Database.Entities.Guild;
namespace SVSim.Database.Repositories.Guild;
public interface IGuildMemberRepository
{
Task<GuildMember?> GetMembershipAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildMember>> ListByGuildAsync(int guildId, CancellationToken ct = default);
Task<int> CountByGuildAsync(int guildId, CancellationToken ct = default);
Task<int> 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);
/// <summary>Returns a count of members per guild for the given guild ids. Used by Task 9 search.</summary>
Task<Dictionary<int, int>> CountBatchByGuildIdsAsync(IReadOnlyList<int> guildIds, CancellationToken ct = default);
}

View File

@@ -0,0 +1,27 @@
using SVSim.Database.Entities.Guild;
namespace SVSim.Database.Repositories.Guild;
public interface IGuildRepository
{
Task<Entities.Guild.Guild?> GetByIdAsync(int guildId, CancellationToken ct = default);
/// <summary>Returns guild only if not soft-deleted (BreakupAt is null).</summary>
Task<Entities.Guild.Guild?> GetActiveByIdAsync(int guildId, CancellationToken ct = default);
/// <summary>Returns guild + populated Members list (split-query).</summary>
Task<Entities.Guild.Guild?> GetWithMembersAsync(int guildId, CancellationToken ct = default);
Task<bool> NameExistsAsync(string name, CancellationToken ct = default);
Task<Entities.Guild.Guild> AddAsync(Entities.Guild.Guild g, CancellationToken ct = default);
/// <summary>Soft-delete: sets BreakupAt to UtcNow. Caller is responsible for cascading
/// hard-deletes on Members / Invites / JoinRequests / ChatMessages.</summary>
Task MarkBrokenUpAsync(int guildId, DateTime brokenUpAt, CancellationToken ct = default);
/// <summary>Filtered + bucketed search. Empty name = match-all; activity/joinCondition/bucket = 0 = any.</summary>
Task<IReadOnlyList<Entities.Guild.Guild>> SearchAsync(
string name, int activity, int joinCondition, int memberBucket,
int maxMemberCap, int resultCap, CancellationToken ct = default);
}

View File

@@ -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<SVSimDbContext>.Instance,
new DbContextOptionsBuilder<SVSimDbContext>()
.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));
}
}