diff --git a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs index 017e0b1c..76a4f546 100644 --- a/SVSim.Database/Repositories/Viewer/IViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/IViewerRepository.cs @@ -27,4 +27,10 @@ public interface IViewerRepository /// doesn't exist. /// Task LoadForMatchContextAsync(long viewerId); + + /// Sets Viewer.GuildId to . No-op if the viewer does not exist. + Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default); + + /// Clears Viewer.GuildId to null. No-op if the viewer does not exist. + Task ClearGuildIdAsync(long viewerId, CancellationToken ct = default); } diff --git a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs index daf2490f..0a0adcc0 100644 --- a/SVSim.Database/Repositories/Viewer/ViewerRepository.cs +++ b/SVSim.Database/Repositories/Viewer/ViewerRepository.cs @@ -268,6 +268,24 @@ public class ViewerRepository : IViewerRepository .FirstOrDefaultAsync(v => v.Id == viewerId); } + public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default) + { + var viewer = await _dbContext.Set() + .FirstOrDefaultAsync(v => v.Id == viewerId, ct); + if (viewer is null) return; + viewer.GuildId = guildId; + await _dbContext.SaveChangesAsync(ct); + } + + public async Task ClearGuildIdAsync(long viewerId, CancellationToken ct = default) + { + var viewer = await _dbContext.Set() + .FirstOrDefaultAsync(v => v.Id == viewerId, ct); + if (viewer is null) return; + viewer.GuildId = null; + await _dbContext.SaveChangesAsync(ct); + } + private async Task BuildDefaultViewer(string displayName, int initialTutorialState = 1) { Models.Viewer viewer = new Models.Viewer diff --git a/SVSim.Database/Services/Guild/GuildIdGenerator.cs b/SVSim.Database/Services/Guild/GuildIdGenerator.cs new file mode 100644 index 00000000..32d06653 --- /dev/null +++ b/SVSim.Database/Services/Guild/GuildIdGenerator.cs @@ -0,0 +1,26 @@ +namespace SVSim.Database.Services.Guild; + +public interface IGuildIdGenerator +{ + Task NextAsync(Func> existsAsync, CancellationToken ct); +} + +public sealed class GuildIdGenerator : IGuildIdGenerator +{ + private static readonly System.Security.Cryptography.RandomNumberGenerator _rng = + System.Security.Cryptography.RandomNumberGenerator.Create(); + + public async Task NextAsync(Func> existsAsync, CancellationToken ct) + { + const int min = 100_000_000, max = 999_999_999; + const int attempts = 16; + for (int i = 0; i < attempts; i++) + { + var bytes = new byte[4]; _rng.GetBytes(bytes); + var raw = BitConverter.ToUInt32(bytes, 0); + int id = min + (int)(raw % (uint)(max - min + 1)); + if (!await existsAsync(id, ct)) return id; + } + throw new InvalidOperationException("Exhausted guild_id collision retries — id space too saturated."); + } +} diff --git a/SVSim.Database/Services/Guild/GuildService.cs b/SVSim.Database/Services/Guild/GuildService.cs index b6cc2b93..c76a6ccf 100644 --- a/SVSim.Database/Services/Guild/GuildService.cs +++ b/SVSim.Database/Services/Guild/GuildService.cs @@ -1,4 +1,7 @@ +using Microsoft.EntityFrameworkCore; +using SVSim.Database.Entities.Guild; using SVSim.Database.Repositories.Guild; +using SVSim.Database.Repositories.Viewer; using SVSim.Database.Services; namespace SVSim.Database.Services.Guild; @@ -11,6 +14,10 @@ public sealed class GuildService : IGuildService private readonly IGuildJoinRequestRepository _joinRequests; private readonly IGuildChatMessageRepository _chatMessages; private readonly IGameConfigService _config; + private readonly IGuildIdGenerator _idGen; + private readonly IGuildChatService _chat; + private readonly IViewerRepository _viewers; + private readonly SVSimDbContext _db; public GuildService( IGuildRepository guilds, @@ -18,7 +25,11 @@ public sealed class GuildService : IGuildService IGuildInviteRepository invites, IGuildJoinRequestRepository joinRequests, IGuildChatMessageRepository chatMessages, - IGameConfigService config) + IGameConfigService config, + IGuildIdGenerator idGen, + IGuildChatService chat, + IViewerRepository viewers, + SVSimDbContext db) { _guilds = guilds; _members = members; @@ -26,20 +37,91 @@ public sealed class GuildService : IGuildService _joinRequests = joinRequests; _chatMessages = chatMessages; _config = config; + _idGen = idGen; + _chat = chat; + _viewers = viewers; + _db = db; } - // Returns null so /guild/info can short-circuit to a non-joined response in Phase 1. - public Task GetMyGuildAsync(long viewerId, CancellationToken ct = default) - => Task.FromResult(null); + public async Task GetMyGuildAsync(long viewerId, CancellationToken ct = default) + { + var membership = await _members.GetMembershipAsync(viewerId, ct); + if (membership is null) return null; + + var guild = await _guilds.GetWithMembersAsync(membership.GuildId, ct); + if (guild is null || guild.BreakupAt is not null) return null; + + int joinReqCount = await _joinRequests.CountPendingForGuildAsync(guild.GuildId, ct); + int inviteCount = await _invites.CountPendingForInviteeAsync(viewerId, ct); + + return new GuildFullView(guild, guild.Members, joinReqCount, inviteCount); + } public Task GetActiveAsync(int guildId, CancellationToken ct = default) - => throw new NotImplementedException(); + => _guilds.GetActiveByIdAsync(guildId, ct); public Task> SearchAsync(string name, int activity, int joinCondition, int memberBucket, CancellationToken ct = default) => throw new NotImplementedException(); - public Task CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default) - => throw new NotImplementedException(); + public async Task CreateAsync(long viewerId, CreateGuildRequest req, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(req.Name) || req.Name.Length > 64) + return new(GuildOpResultCode.NameInvalid); + if (req.Activity is < 1 or > 16) + return new(GuildOpResultCode.NameInvalid); + if (req.JoinCondition is < 1 or > 3) + return new(GuildOpResultCode.NameInvalid); + + if (await _members.GetMembershipAsync(viewerId, ct) is not null) + return new(GuildOpResultCode.AlreadyInGuild); + if (await _guilds.NameExistsAsync(req.Name, ct)) + return new(GuildOpResultCode.NameTaken); + + var viewer = await _db.Viewers + .Include(v => v.Info.SelectedEmblem) + .FirstOrDefaultAsync(v => v.Id == viewerId, ct); + if (viewer is null) return new(GuildOpResultCode.PermissionDenied); + + var id = await _idGen.NextAsync( + (cand, c) => _guilds.GetByIdAsync(cand, c).ContinueWith(t => t.Result is not null, c), + ct); + var now = DateTime.UtcNow; + var g = new Entities.Guild.Guild + { + GuildId = id, + Name = req.Name, + Description = "", + LeaderViewerId = viewerId, + EmblemId = DefaultEmblemId(viewer), + Activity = (GuildActivity)req.Activity, + JoinCondition = (GuildJoinCondition)req.JoinCondition, + CreatedAt = now, + }; + await _guilds.AddAsync(g, ct); + await _members.AddAsync( + new GuildMember { GuildId = id, ViewerId = viewerId, Role = GuildRole.Leader, JoinedAt = now }, + ct); + + // Side-effects: clear any pending invites + cancel pending join requests for this viewer. + await _invites.ConsumePendingForViewerAsync(viewerId, now, ct); + await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct); + + // Update Viewer.GuildId pointer for quick lookups. + viewer.GuildId = id; + await _db.SaveChangesAsync(ct); + + await _chat.EmitSystemEventAsync(id, viewerId, GuildChatMessageType.CreateGuild, body: null, ct); + return new(GuildOpResultCode.Ok, GuildId: id); + } + + /// + /// Returns the viewer's currently-equipped emblem id, or 100000000 (default) if none. + /// + private static long DefaultEmblemId(Models.Viewer viewer) + { + var emblemId = viewer.Info?.SelectedEmblem?.Id; + return emblemId is > 0 ? emblemId.Value : 100_000_000L; + } public Task UpdateAsync(long viewerId, UpdateGuildRequest req, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs index 60fc3a2e..b6af3dce 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/GuildController.cs @@ -1,8 +1,13 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Models; using SVSim.Database.Models.Config; using SVSim.Database.Services; using SVSim.Database.Services.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Common; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Guild; using SVSim.EmulatedEntrypoint.Models.Dtos.Requests; @@ -14,11 +19,16 @@ public sealed class GuildController : SVSimController { private readonly IGuildService _guild; private readonly IGameConfigService _configs; + private readonly SVSimDbContext _db; - public GuildController(IGuildService guild, IGameConfigService configs) + // Wire error code returned when guild operations fail (non-1 result_code). + private const int GuildErrorResultCode = 2; + + public GuildController(IGuildService guild, IGameConfigService configs, SVSimDbContext db) { _guild = guild; _configs = configs; + _db = db; } [HttpPost("info")] @@ -39,8 +49,14 @@ public sealed class GuildController : SVSimController } else { - resp.GuildStatus = 2; // JOINING — fully populated path lands in Task 7 - // Task 7 populates `Guild`, JoinRequestCount, InviteCount. + resp.GuildStatus = 2; // JOINING + resp.JoinRequestCount = view.JoinRequestCount; + resp.InviteCount = view.InviteCount; + resp.Guild = new GuildBundle + { + Detail = ToDetailDto(view.Guild, view.Members.Count), + Members = await ToMemberDtoListAsync(view.Members, viewerId, ct), + }; } return resp; } @@ -48,7 +64,13 @@ public sealed class GuildController : SVSimController // ===== 21 remaining stubs — each returns the response DTO with defaults ===== [HttpPost("create")] - public Task> Create([FromBody] GuildCreateRequest req, CancellationToken ct) => Stub(); + public async Task> Create([FromBody] GuildCreateRequest req, CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + var res = await _guild.CreateAsync(viewerId, new(req.GuildName, req.Activity, req.JoinCondition), ct); + if (res.IsOk) return new EmptyResponse(); + return MapErrorToWire(res); + } [HttpPost("breakup")] public Task> Breakup([FromBody] BaseRequest _, CancellationToken ct) => Stub(); @@ -124,6 +146,68 @@ public sealed class GuildController : SVSimController public Task> ChangeRole([FromBody] GuildChangeRoleRequest req, CancellationToken ct) => Task.FromResult>(new GuildChangeRoleResponse { Members = new() }); + // ===== Private helpers ===== + + private static GuildDetailDto ToDetailDto(SVSim.Database.Entities.Guild.Guild guild, int memberCount) => new() + { + GuildId = guild.GuildId, + GuildName = guild.Name, + Description = guild.Description, + GuildEmblemId = guild.EmblemId, + JoinCondition = (int)guild.JoinCondition, + Activity = (int)guild.Activity, + MemberNum = memberCount, + LeaderViewerId = guild.LeaderViewerId, + // LeaderName will be filled via members list join — leave "" here; caller populates it from member data if needed. + LeaderName = "", + }; + + private async Task> ToMemberDtoListAsync( + IReadOnlyList members, + long callerViewerId, + CancellationToken ct) + { + if (members.Count == 0) return new(); + + var viewerIds = members.Select(m => m.ViewerId).ToList(); + + // Batch-load viewer rows with Info + SelectedEmblem + SelectedDegree. + var viewers = await _db.Viewers + .AsNoTracking() + .Include(v => v.Info.SelectedEmblem) + .Include(v => v.Info.SelectedDegree) + .Where(v => viewerIds.Contains(v.Id)) + .ToDictionaryAsync(v => v.Id, ct); + + var result = new List(members.Count); + foreach (var m in members) + { + viewers.TryGetValue(m.ViewerId, out var v); + result.Add(ToMemberDto(m, v)); + } + return result; + } + + private static GuildMemberInfoDto ToMemberDto(GuildMember member, Viewer? viewer) => new() + { + ViewerId = member.ViewerId, + Name = viewer?.DisplayName ?? "", + EmblemId = viewer?.Info?.SelectedEmblem?.Id is > 0 ? viewer.Info.SelectedEmblem.Id : 100_000_000L, + CountryCode = viewer?.Info?.CountryCode ?? "", + Rank = 1, // TODO: populate from actual rank data when rank tracking lands + DegreeId = viewer?.Info?.SelectedDegree?.Id ?? 0, + IsOfficialMarkDisplayed = viewer?.Info?.IsOfficialMarkDisplayed == true ? 1 : 0, + Role = (int)member.Role, + }; + + /// + /// Maps a failed GuildOpResult to the wire error envelope convention used throughout the + /// codebase (see CampaignController.Fail(), AchievementController, MissionController). + /// Returns HTTP 200 with a JSON body carrying result_code: 2. + /// + private ActionResult MapErrorToWire(GuildOpResult res) + => Ok(new { result_code = GuildErrorResultCode }); + private static Task> Stub() => Task.FromResult>(new EmptyResponse()); } diff --git a/SVSim.EmulatedEntrypoint/Program.cs b/SVSim.EmulatedEntrypoint/Program.cs index fbc55a3b..e5ec51ea 100644 --- a/SVSim.EmulatedEntrypoint/Program.cs +++ b/SVSim.EmulatedEntrypoint/Program.cs @@ -126,6 +126,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SVSim.UnitTests/Integration/Guild/GuildCreateInfoFlowTests.cs b/SVSim.UnitTests/Integration/Guild/GuildCreateInfoFlowTests.cs new file mode 100644 index 00000000..c110e095 --- /dev/null +++ b/SVSim.UnitTests/Integration/Guild/GuildCreateInfoFlowTests.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using System.Text.Json; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Integration.Guild; + +public class GuildCreateInfoFlowTests +{ + [Test] + public async Task PostingCreate_then_Info_returns_populated_guild() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync( + steamId: 76_561_198_200_000_001UL, + displayName: "AlphaIntPlayer"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + + // POST /guild/create — mirror the arena integration test style: include the base-request fields + const string Vid = "0"; const int Sid = 0; const string Stk = ""; + var create = await client.PostAsync("/guild/create", + System.Net.Http.Json.JsonContent.Create(new { guild_name = "AlphaInt", activity = 1, join_condition = 1, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + Assert.That(create.IsSuccessStatusCode, Is.True, $"create failed ({create.StatusCode}): {await create.Content.ReadAsStringAsync()}"); + + // POST /guild/info — expect JOINING state with guild populated + var info = await client.PostAsync("/guild/info", + System.Net.Http.Json.JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk })); + var json = await info.Content.ReadAsStringAsync(); + Assert.That(info.IsSuccessStatusCode, Is.True, $"info failed: {json}"); + + // In test mode (no UnityPlayer user-agent) the translation middleware is a no-op; + // the controller's raw DTO is returned directly (no {data_headers, data} envelope). + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.That(root.GetProperty("guild_status").GetString(), Is.EqualTo("2"), + "guild_status should be JOINING (2)"); + Assert.That(root.GetProperty("guild").GetProperty("detail").GetProperty("guild_name").GetString(), + Is.EqualTo("AlphaInt")); + Assert.That(root.GetProperty("guild").GetProperty("members").GetArrayLength(), + Is.EqualTo(1)); + } + + [Test] + public async Task PostingCreate_twice_returns_error_second_time() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync( + steamId: 76_561_198_200_000_002UL, + displayName: "DoubleCreatePlayer"); + + using var client = factory.CreateAuthenticatedClient(viewerId); + + const string Vid = "0"; const int Sid = 0; const string Stk = ""; + var createBody = new { guild_name = "OnlyOnce", activity = 1, join_condition = 1, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }; + + // First create should succeed + var first = await client.PostAsync("/guild/create", + System.Net.Http.Json.JsonContent.Create(createBody)); + Assert.That(first.IsSuccessStatusCode, Is.True, $"First create failed: {await first.Content.ReadAsStringAsync()}"); + var firstJson = await first.Content.ReadAsStringAsync(); + using var firstDoc = JsonDocument.Parse(firstJson); + // In test mode (no envelope), result_code may be present (failure) or absent (success EmptyResponse). + // Success path returns EmptyResponse ({}), so result_code will not be present. + var firstRoot = firstDoc.RootElement; + if (firstRoot.TryGetProperty("result_code", out var rc1)) + { + // If present it must not be 2 (error code). + Assert.That(rc1.GetInt32(), Is.Not.EqualTo(2), "First create must not error"); + } + + // Second create for the same viewer should return an error envelope (result_code = 2). + var secondBody = new { guild_name = "OnlyOnceAgain", activity = 1, join_condition = 1, + viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }; + var second = await client.PostAsync("/guild/create", + System.Net.Http.Json.JsonContent.Create(secondBody)); + Assert.That(second.IsSuccessStatusCode, Is.True, "HTTP level should still be 200"); + var secondJson = await second.Content.ReadAsStringAsync(); + using var secondDoc = JsonDocument.Parse(secondJson); + // Error path: the MapErrorToWire helper returns { result_code = 2 } + Assert.That(secondDoc.RootElement.TryGetProperty("result_code", out var rc2), Is.True, + $"Second create should return result_code field, got: {secondJson}"); + Assert.That(rc2.GetInt32(), Is.EqualTo(2), "Second create should fail with result_code=2"); + } +} diff --git a/SVSim.UnitTests/Services/Guild/GuildServiceCreateTests.cs b/SVSim.UnitTests/Services/Guild/GuildServiceCreateTests.cs new file mode 100644 index 00000000..12cf5e64 --- /dev/null +++ b/SVSim.UnitTests/Services/Guild/GuildServiceCreateTests.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database.Entities.Guild; +using SVSim.Database.Services.Guild; +using SVSim.UnitTests.Infrastructure; +using GuildEntity = SVSim.Database.Entities.Guild.Guild; + +namespace SVSim.UnitTests.Services.Guild; + +public class GuildServiceCreateTests +{ + private static async Task CreateViewerAsync(SVSimTestFactory factory, ulong steamId = 76_561_198_100_000_001UL, string name = "GuildTestViewer") + => await factory.SeedViewerAsync(steamId: steamId, displayName: name); + + private static IGuildService GuildService(SVSimTestFactory factory, out IServiceScope scope) + { + scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + [Test] + public async Task CreateAsync_makes_a_guild_with_caller_as_leader() + { + using var factory = new SVSimTestFactory(); + var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_001UL, "Alpha Leader"); + + GuildOpResult res; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + res = await svc.CreateAsync(viewerId, new("Alpha", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + } + + Assert.That(res.IsOk, Is.True); + Assert.That(res.GuildId, Is.Not.Null); + + GuildFullView? view; + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + view = await svc.GetMyGuildAsync(viewerId); + } + + Assert.That(view, Is.Not.Null); + Assert.That(view!.Guild.Name, Is.EqualTo("Alpha")); + Assert.That(view.Guild.LeaderViewerId, Is.EqualTo(viewerId)); + Assert.That(view.Members.Single().Role, Is.EqualTo(GuildRole.Leader)); + } + + [Test] + public async Task CreateAsync_returns_AlreadyInGuild_when_viewer_already_in_a_guild() + { + using var factory = new SVSimTestFactory(); + var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_002UL, "Already In"); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var first = await svc.CreateAsync(viewerId, new("FirstGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(first.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var second = await svc.CreateAsync(viewerId, new("SecondGuild", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(second.Code, Is.EqualTo(GuildOpResultCode.AlreadyInGuild)); + } + } + + [Test] + public async Task CreateAsync_returns_NameTaken_when_guild_name_already_exists() + { + using var factory = new SVSimTestFactory(); + var v1 = await CreateViewerAsync(factory, 76_561_198_100_000_003UL, "Viewer1"); + var v2 = await CreateViewerAsync(factory, 76_561_198_100_000_004UL, "Viewer2"); + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var first = await svc.CreateAsync(v1, new("SameName", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(first.IsOk, Is.True); + } + + using (var scope = factory.Services.CreateScope()) + { + var svc = scope.ServiceProvider.GetRequiredService(); + var second = await svc.CreateAsync(v2, new("SameName", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(second.Code, Is.EqualTo(GuildOpResultCode.NameTaken)); + } + } + + [Test] + public async Task CreateAsync_returns_NameInvalid_for_empty_name() + { + using var factory = new SVSimTestFactory(); + var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_005UL); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + var res = await svc.CreateAsync(viewerId, new("", (int)GuildActivity.All, (int)GuildJoinCondition.Free)); + Assert.That(res.Code, Is.EqualTo(GuildOpResultCode.NameInvalid)); + } + + [Test] + public async Task GetMyGuildAsync_returns_null_for_viewer_not_in_any_guild() + { + using var factory = new SVSimTestFactory(); + var viewerId = await CreateViewerAsync(factory, 76_561_198_100_000_006UL); + + using var scope = factory.Services.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + var view = await svc.GetMyGuildAsync(viewerId); + Assert.That(view, Is.Null); + } +}