feat(guild): invite state machine

Add surrogate PK (IDENTITY bigint) to GuildInvite for wire invite_id.
Implement InviteAsync, CancelInviteAsync, RejectInviteAsync,
ListOutgoingInvitesAsync, ListInvitedGuildsAsync in GuildService.
Wire 5 endpoints: invite_user_list, invited_guild_list, invite,
cancel_invite, reject_invite in GuildController.
Migration: AddGuildInviteSurrogatePk — drops composite PK, adds Id
IDENTITY column, adds unique index on (GuildId, InviteeViewerId).
6 integration tests in GuildInviteFlowTests all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-27 13:21:57 -04:00
parent c103ab6a87
commit 1b7fa2d525
12 changed files with 5574 additions and 22 deletions

View File

@@ -2,6 +2,12 @@ namespace SVSim.Database.Entities.Guild;
public class GuildInvite
{
/// <summary>
/// Auto-increment surrogate PK. Used as the wire <c>invite_id</c> returned to the client
/// and echoed back in cancel_invite / reject_invite requests.
/// </summary>
public long Id { get; set; }
public int GuildId { get; set; }
public long InviteeViewerId { get; set; }
public long InviterViewerId { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddGuildInviteSurrogatePk : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites");
migrationBuilder.AddColumn<long>(
name: "Id",
table: "GuildInvites",
type: "bigint",
nullable: false,
defaultValue: 0L)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.AddPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites",
column: "Id");
migrationBuilder.CreateIndex(
name: "IX_GuildInvites_GuildId_InviteeViewerId",
table: "GuildInvites",
columns: new[] { "GuildId", "InviteeViewerId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites");
migrationBuilder.DropIndex(
name: "IX_GuildInvites_GuildId_InviteeViewerId",
table: "GuildInvites");
migrationBuilder.DropColumn(
name: "Id",
table: "GuildInvites");
migrationBuilder.AddPrimaryKey(
name: "PK_GuildInvites",
table: "GuildInvites",
columns: new[] { "GuildId", "InviteeViewerId" });
}
}
}

View File

@@ -171,15 +171,21 @@ namespace SVSim.Database.Migrations
modelBuilder.Entity("SVSim.Database.Entities.Guild.GuildInvite", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("GuildId")
.HasColumnType("integer");
b.Property<long>("InviteeViewerId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("InviterViewerId")
.HasColumnType("bigint");
@@ -189,7 +195,10 @@ namespace SVSim.Database.Migrations
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("GuildId", "InviteeViewerId");
b.HasKey("Id");
b.HasIndex("GuildId", "InviteeViewerId")
.IsUnique();
b.HasIndex("InviteeViewerId", "Status");

View File

@@ -13,8 +13,12 @@ public sealed class GuildInviteRepository : IGuildInviteRepository
=> _db.GuildInvites.FirstOrDefaultAsync(
i => i.GuildId == guildId && i.InviteeViewerId == inviteeViewerId, ct);
public Task<GuildInvite?> GetByIdAsync(long inviteId, CancellationToken ct = default)
=> _db.GuildInvites.FirstOrDefaultAsync(i => i.Id == inviteId, ct);
public async Task<IReadOnlyList<GuildInvite>> ListPendingForInviteeAsync(long viewerId, CancellationToken ct = default)
=> await _db.GuildInvites
.Include(i => i.Guild)
.Where(i => i.InviteeViewerId == viewerId && i.Status == GuildInviteStatus.Pending)
.ToListAsync(ct);

View File

@@ -5,6 +5,7 @@ namespace SVSim.Database.Repositories.Guild;
public interface IGuildInviteRepository
{
Task<GuildInvite?> GetAsync(int guildId, long inviteeViewerId, CancellationToken ct = default);
Task<GuildInvite?> GetByIdAsync(long inviteId, 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);

View File

@@ -532,10 +532,12 @@ public class SVSimDbContext : DbContext
modelBuilder.Entity<GuildInvite>(e =>
{
e.HasKey(i => new { i.GuildId, i.InviteeViewerId });
e.HasKey(i => i.Id);
e.Property(i => i.Id).ValueGeneratedOnAdd();
e.HasOne(i => i.Guild).WithMany().HasForeignKey(i => i.GuildId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(i => new { i.InviteeViewerId, i.Status }); // "my pending invites"
e.HasIndex(i => new { i.GuildId, i.InviteeViewerId }).IsUnique(); // one pending invite per pair
e.HasIndex(i => new { i.InviteeViewerId, i.Status }); // "my pending invites"
});
modelBuilder.Entity<GuildJoinRequest>(e =>

View File

@@ -190,14 +190,65 @@ public sealed class GuildService : IGuildService
return GuildOpResult.Ok;
}
public Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
public async Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
{
// Caller must be a guild member with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
public Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
// Target must not already be in any guild.
var targetMembership = await _members.GetMembershipAsync(targetViewerId, ct);
if (targetMembership is not null) return new(GuildOpResultCode.AlreadyInGuild);
public Task<GuildOpResult> RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
// No duplicate pending invite to the same target from the same guild.
var existing = await _invites.GetAsync(callerMembership.GuildId, targetViewerId, ct);
if (existing is { Status: GuildInviteStatus.Pending }) return new(GuildOpResultCode.InviteAlreadyResolved);
var now = DateTime.UtcNow;
await _invites.AddAsync(new GuildInvite
{
GuildId = callerMembership.GuildId,
InviteeViewerId = targetViewerId,
InviterViewerId = callerViewerId,
Status = GuildInviteStatus.Pending,
CreatedAt = now,
}, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Caller must be in their guild with Leader or SubLeader role.
var callerMembership = await _members.GetMembershipAsync(callerViewerId, ct);
if (callerMembership is null) return new(GuildOpResultCode.NotInGuild);
if (callerMembership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
return new(GuildOpResultCode.PermissionDenied);
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must belong to the same guild as the invite.
if (invite.GuildId != callerMembership.GuildId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, invite.InviteeViewerId, GuildInviteStatus.Canceled, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public async Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default)
{
// Invitee rejects — no guild membership check needed.
var invite = await _invites.GetByIdAsync(inviteId, ct);
if (invite is null) return new(GuildOpResultCode.InviteNotFound);
// Caller must be the invitee.
if (invite.InviteeViewerId != callerViewerId) return new(GuildOpResultCode.PermissionDenied);
if (invite.Status != GuildInviteStatus.Pending) return new(GuildOpResultCode.InviteAlreadyResolved);
await _invites.UpdateStatusAsync(invite.GuildId, callerViewerId, GuildInviteStatus.Rejected, DateTime.UtcNow, ct);
return GuildOpResult.Ok;
}
public Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
=> throw new NotImplementedException();
@@ -221,7 +272,61 @@ public sealed class GuildService : IGuildService
=> throw new NotImplementedException();
public Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();
=> _invites.ListPendingForInviteeAsync(viewerId, ct);
public async Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default)
{
var membership = await _members.GetMembershipAsync(callerViewerId, ct);
if (membership is null) return Array.Empty<GuildOutgoingInviteEntry>();
var pendingInvites = await _invites.ListPendingForGuildAsync(membership.GuildId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildOutgoingInviteEntry>();
var inviteeIds = pendingInvites.Select(i => i.InviteeViewerId).Distinct().ToList();
var names = await _viewers.LoadDisplayNamesAsync(inviteeIds, ct);
// Load emblem + degree data for invitees.
var viewerRows = await _db.Viewers
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.Where(v => inviteeIds.Contains(v.Id))
.ToDictionaryAsync(v => v.Id, ct);
var result = new List<GuildOutgoingInviteEntry>(pendingInvites.Count);
foreach (var invite in pendingInvites)
{
viewerRows.TryGetValue(invite.InviteeViewerId, out var v);
result.Add(new GuildOutgoingInviteEntry(
InviteId: invite.Id,
InviteeViewerId: invite.InviteeViewerId,
Name: names.GetValueOrDefault(invite.InviteeViewerId, ""),
EmblemId: v?.Info?.SelectedEmblem?.Id is > 0 ? v.Info.SelectedEmblem.Id : 100_000_000L,
CountryCode: v?.Info?.CountryCode ?? "",
Rank: 1,
DegreeId: v?.Info?.SelectedDegree?.Id ?? 0,
CreatedAt: invite.CreatedAt));
}
return result;
}
public async Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default)
{
// ListPendingForInviteeAsync already includes the Guild nav prop.
var pendingInvites = await _invites.ListPendingForInviteeAsync(viewerId, ct);
if (pendingInvites.Count == 0) return Array.Empty<GuildReceivedInviteEntry>();
var guildIds = pendingInvites.Select(i => i.GuildId).Distinct().ToList();
var memberCounts = await _members.CountBatchByGuildIdsAsync(guildIds, ct);
var leaderIds = pendingInvites.Select(i => i.Guild.LeaderViewerId).Distinct().ToList();
var leaderNames = await _viewers.LoadDisplayNamesAsync(leaderIds, ct);
return pendingInvites.Select(invite => new GuildReceivedInviteEntry(
InviteId: invite.Id,
Guild: invite.Guild,
MemberNum: memberCounts.GetValueOrDefault(invite.GuildId, 0),
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
}
public Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
=> throw new NotImplementedException();

View File

@@ -38,3 +38,27 @@ public sealed record GuildSearchEntry(Entities.Guild.Guild Guild, int MemberNum,
public sealed record CreateGuildRequest(string Name, int Activity, int JoinCondition);
public sealed record UpdateGuildRequest(int? Activity, int? JoinCondition, string? Name = null);
/// <summary>
/// Enriched outgoing invite entry returned by <see cref="IGuildService.ListOutgoingInvitesAsync"/>.
/// Combines invite metadata with invitee viewer profile for the <c>/guild/invite_user_list</c> response.
/// </summary>
public sealed record GuildOutgoingInviteEntry(
long InviteId,
long InviteeViewerId,
string Name,
long EmblemId,
string CountryCode,
int Rank,
int DegreeId,
DateTime CreatedAt);
/// <summary>
/// Enriched received invite entry returned by <see cref="IGuildService.ListInvitedGuildsAsync"/>.
/// Combines invite id with guild detail fields for the <c>/guild/invited_guild_list</c> response.
/// </summary>
public sealed record GuildReceivedInviteEntry(
long InviteId,
Entities.Guild.Guild Guild,
int MemberNum,
string LeaderName);

View File

@@ -23,8 +23,8 @@ public interface IGuildService
Task<GuildOpResult> BreakupAsync(long viewerId, CancellationToken ct = default);
Task<GuildOpResult> InviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default);
Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long targetViewerId, CancellationToken ct = default);
Task<GuildOpResult> RejectInviteAsync(long callerViewerId, int guildId, CancellationToken ct = default);
Task<GuildOpResult> CancelInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default);
Task<GuildOpResult> RejectInviteAsync(long callerViewerId, long inviteId, CancellationToken ct = default);
Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default);
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default);
@@ -36,5 +36,7 @@ public interface IGuildService
Task<GuildOpResult> ChangeRoleAsync(long callerViewerId, long targetViewerId, int newRoleId, CancellationToken ct = default);
Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default);
Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default);
Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
}

View File

@@ -175,21 +175,72 @@ public sealed class GuildController : SVSimController
=> Task.FromResult<ActionResult<GuildFriendListResponse>>(new GuildFriendListResponse { Friends = new() });
[HttpPost("invite_user_list")]
public Task<ActionResult<GuildInviteUserListResponse>> InviteUserList([FromBody] BaseRequest _, CancellationToken ct)
=> Task.FromResult<ActionResult<GuildInviteUserListResponse>>(new GuildInviteUserListResponse { Users = new() });
public async Task<ActionResult<GuildInviteUserListResponse>> InviteUserList([FromBody] BaseRequest _, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var entries = await _guild.ListOutgoingInvitesAsync(viewerId, ct);
return new GuildInviteUserListResponse
{
Users = entries.Select(e => new GuildOutgoingInviteDto
{
ViewerId = e.InviteeViewerId,
Name = e.Name,
EmblemId = e.EmblemId,
CountryCode = e.CountryCode,
Rank = e.Rank,
DegreeId = e.DegreeId,
InviteId = e.InviteId,
InviteTime = new DateTimeOffset(e.CreatedAt, TimeSpan.Zero).ToUnixTimeSeconds(),
}).ToList(),
};
}
[HttpPost("invited_guild_list")]
public Task<ActionResult<GuildInvitedGuildListResponse>> InvitedGuildList([FromBody] GuildInvitedGuildListRequest req, CancellationToken ct)
=> Task.FromResult<ActionResult<GuildInvitedGuildListResponse>>(new GuildInvitedGuildListResponse { List = new() });
public async Task<ActionResult<GuildInvitedGuildListResponse>> InvitedGuildList([FromBody] GuildInvitedGuildListRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var entries = await _guild.ListInvitedGuildsAsync(viewerId, ct);
return new GuildInvitedGuildListResponse
{
List = entries.Select(e => new GuildReceivedInviteDto
{
GuildId = e.Guild.GuildId,
GuildName = e.Guild.Name,
Description = e.Guild.Description,
GuildEmblemId = e.Guild.EmblemId,
JoinCondition = (int)e.Guild.JoinCondition,
Activity = (int)e.Guild.Activity,
MemberNum = e.MemberNum,
LeaderName = e.LeaderName,
LeaderViewerId = e.Guild.LeaderViewerId,
InviteId = e.InviteId,
}).ToList(),
};
}
[HttpPost("invite")]
public Task<ActionResult<EmptyResponse>> Invite([FromBody] GuildInviteRequest req, CancellationToken ct) => Stub();
public async Task<ActionResult<EmptyResponse>> Invite([FromBody] GuildInviteRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var r = await _guild.InviteAsync(viewerId, req.InvitedViewerId, ct);
return r.IsOk ? new EmptyResponse() : MapErrorToWire(r);
}
[HttpPost("cancel_invite")]
public Task<ActionResult<EmptyResponse>> CancelInvite([FromBody] GuildCancelInviteRequest req, CancellationToken ct) => Stub();
public async Task<ActionResult<EmptyResponse>> CancelInvite([FromBody] GuildCancelInviteRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var r = await _guild.CancelInviteAsync(viewerId, req.InviteId, ct);
return r.IsOk ? new EmptyResponse() : MapErrorToWire(r);
}
[HttpPost("reject_invite")]
public Task<ActionResult<EmptyResponse>> RejectInvite([FromBody] GuildRejectInviteRequest req, CancellationToken ct) => Stub();
public async Task<ActionResult<EmptyResponse>> RejectInvite([FromBody] GuildRejectInviteRequest req, CancellationToken ct)
{
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
var r = await _guild.RejectInviteAsync(viewerId, req.InviteId, ct);
return r.IsOk ? new EmptyResponse() : MapErrorToWire(r);
}
[HttpPost("join")]
public Task<ActionResult<GuildJoinResponse>> Join([FromBody] GuildJoinEndpointRequest req, CancellationToken ct)

View File

@@ -0,0 +1,282 @@
using System.Net.Http.Json;
using System.Text.Json;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Integration.Guild;
using GuildEntity = SVSim.Database.Entities.Guild.Guild;
/// <summary>
/// Integration tests for the 5 guild invite endpoints:
/// /guild/invite, /guild/cancel_invite, /guild/reject_invite,
/// /guild/invite_user_list, /guild/invited_guild_list.
/// </summary>
public class GuildInviteFlowTests
{
// Shared base-request fields (test mode — always zeros)
private const string Vid = "0";
private const int Sid = 0;
private const string Stk = "";
private static object BaseReq() => new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
// ─── Happy-path: A invites B → B sees it → A sees outgoing ────────────────
[Test]
public async Task Invite_then_invited_guild_list_shows_pending_entry()
{
using var factory = new SVSimTestFactory();
// Viewer A: leader of a guild.
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_001UL, displayName: "InviterA");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
// Create guild as A.
var create = await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "InviteTestGuild", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
Assert.That(create.IsSuccessStatusCode, Is.True, $"create failed: {await create.Content.ReadAsStringAsync()}");
// Viewer B: not in any guild.
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_002UL, displayName: "InviteeB");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
// A invites B.
var invite = await clientA.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerB,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var inviteJson = await invite.Content.ReadAsStringAsync();
Assert.That(invite.IsSuccessStatusCode, Is.True, $"invite failed: {inviteJson}");
using var inviteDoc = JsonDocument.Parse(inviteJson);
if (inviteDoc.RootElement.TryGetProperty("result_code", out var rc))
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"invite returned error: {inviteJson}");
// B should see pending invite in /guild/invited_guild_list.
var invitedList = await clientB.PostAsync("/guild/invited_guild_list",
JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var invitedJson = await invitedList.Content.ReadAsStringAsync();
Assert.That(invitedList.IsSuccessStatusCode, Is.True, $"invited_guild_list failed: {invitedJson}");
using var invitedDoc = JsonDocument.Parse(invitedJson);
var list = invitedDoc.RootElement.GetProperty("list");
Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"B should see exactly 1 pending invite, got: {invitedJson}");
var entry = list[0];
Assert.That(entry.GetProperty("guild_name").GetString(), Is.EqualTo("InviteTestGuild"),
"guild_name must match the created guild");
Assert.That(entry.TryGetProperty("invite_id", out var inviteIdProp), Is.True,
"invite_id must be present in the response");
Assert.That(inviteIdProp.GetInt64(), Is.GreaterThan(0), "invite_id must be a positive integer");
// Wire-shape check: flat siblings (no nesting under 'detail')
Assert.That(entry.TryGetProperty("guild_id", out _), Is.True, "guild_id must be flat sibling");
Assert.That(entry.TryGetProperty("leader_name", out _), Is.True, "leader_name must be flat sibling");
}
[Test]
public async Task Invite_user_list_shows_outgoing_invite_with_correct_shape()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_011UL, displayName: "LeaderA");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "OutgoingTestGuild", activity = 2, join_condition = 2,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_012UL, displayName: "InviteeB2");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
// A invites B.
await clientA.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerB,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
// A checks invite_user_list — should see B.
var userList = await clientA.PostAsync("/guild/invite_user_list",
JsonContent.Create(BaseReq()));
var userListJson = await userList.Content.ReadAsStringAsync();
Assert.That(userList.IsSuccessStatusCode, Is.True, $"invite_user_list failed: {userListJson}");
using var doc = JsonDocument.Parse(userListJson);
var list = doc.RootElement.GetProperty("list");
Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"Should see 1 outgoing invite, got: {userListJson}");
var entry = list[0];
// Wire-shape: GuildUserInfo fields + invite_id + invite_time as flat siblings
Assert.That(entry.TryGetProperty("viewer_id", out _), Is.True, "viewer_id must be present");
Assert.That(entry.TryGetProperty("name", out _), Is.True, "name must be present");
Assert.That(entry.TryGetProperty("invite_id", out var invId), Is.True, "invite_id must be present");
Assert.That(invId.GetInt64(), Is.GreaterThan(0), "invite_id must be positive");
Assert.That(entry.TryGetProperty("invite_time", out var invTime), Is.True, "invite_time must be present");
Assert.That(invTime.GetInt64(), Is.GreaterThan(0), "invite_time must be positive unix timestamp");
}
// ─── Cancel path ────────────────────────────────────────────────────────────
[Test]
public async Task Cancel_invite_removes_pending_from_invitee_list()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_021UL, displayName: "CancelLeader");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "CancelTestGuild", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_022UL, displayName: "CancelInvitee");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
// Invite B.
await clientA.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerB,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
// Retrieve the invite_id from invite_user_list.
var userList = await clientA.PostAsync("/guild/invite_user_list", JsonContent.Create(BaseReq()));
using var ulDoc = JsonDocument.Parse(await userList.Content.ReadAsStringAsync());
long inviteId = ulDoc.RootElement.GetProperty("list")[0].GetProperty("invite_id").GetInt64();
// A cancels.
var cancel = await clientA.PostAsync("/guild/cancel_invite",
JsonContent.Create(new { invite_id = inviteId, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var cancelJson = await cancel.Content.ReadAsStringAsync();
Assert.That(cancel.IsSuccessStatusCode, Is.True, $"cancel_invite HTTP failed: {cancelJson}");
using var cancelDoc = JsonDocument.Parse(cancelJson);
if (cancelDoc.RootElement.TryGetProperty("result_code", out var crc))
Assert.That(crc.GetInt32(), Is.Not.EqualTo(2), $"cancel_invite returned error: {cancelJson}");
// B's invited_guild_list should now be empty.
var invitedList = await clientB.PostAsync("/guild/invited_guild_list",
JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
using var ilDoc = JsonDocument.Parse(await invitedList.Content.ReadAsStringAsync());
Assert.That(ilDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0),
"After cancel, invitee should see 0 pending invites");
}
// ─── Reject path ────────────────────────────────────────────────────────────
[Test]
public async Task Reject_invite_removes_pending_from_invitee_list()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_031UL, displayName: "RejectLeader");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "RejectTestGuild", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_032UL, displayName: "RejectInvitee");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
// A invites B.
await clientA.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerB,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
// Get invite_id from B's invited_guild_list.
var invitedList = await clientB.PostAsync("/guild/invited_guild_list",
JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
using var ilDoc = JsonDocument.Parse(await invitedList.Content.ReadAsStringAsync());
long inviteId = ilDoc.RootElement.GetProperty("list")[0].GetProperty("invite_id").GetInt64();
// B rejects.
var reject = await clientB.PostAsync("/guild/reject_invite",
JsonContent.Create(new { invite_id = inviteId, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var rejectJson = await reject.Content.ReadAsStringAsync();
Assert.That(reject.IsSuccessStatusCode, Is.True, $"reject_invite HTTP failed: {rejectJson}");
using var rejectDoc = JsonDocument.Parse(rejectJson);
if (rejectDoc.RootElement.TryGetProperty("result_code", out var rrc))
Assert.That(rrc.GetInt32(), Is.Not.EqualTo(2), $"reject_invite returned error: {rejectJson}");
// B's list should now be empty.
var afterReject = await clientB.PostAsync("/guild/invited_guild_list",
JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
using var arDoc = JsonDocument.Parse(await afterReject.Content.ReadAsStringAsync());
Assert.That(arDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0),
"After reject, invitee should see 0 pending invites");
}
// ─── Permission: regular member cannot invite ────────────────────────────
[Test]
public async Task Regular_member_cannot_invite()
{
using var factory = new SVSimTestFactory();
// A creates guild (becomes leader).
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_041UL, displayName: "PermLeader");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "PermTestGuild", activity = 1, join_condition = 1,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
// B: not in any guild — tries to invite C (but B is not leader/subleader of any guild).
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_042UL, displayName: "RegularB");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
long viewerC = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_043UL, displayName: "TargetC");
// B tries to invite C (B has no guild — should get PermissionDenied / error).
var inviteAttempt = await clientB.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerC,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var attemptJson = await inviteAttempt.Content.ReadAsStringAsync();
Assert.That(inviteAttempt.IsSuccessStatusCode, Is.True, "HTTP level must still be 200");
using var doc = JsonDocument.Parse(attemptJson);
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True,
$"Expected result_code error, got: {attemptJson}");
Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Expected error code 2, got: {attemptJson}");
}
// ─── Wire-shape literal JSON test for invited_guild_list ─────────────────
[Test]
public async Task InvitedGuildList_response_shape_is_flat_siblings_not_nested()
{
using var factory = new SVSimTestFactory();
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_051UL, displayName: "ShapeLeader");
using var clientA = factory.CreateAuthenticatedClient(viewerA);
await clientA.PostAsync("/guild/create",
JsonContent.Create(new { guild_name = "ShapeGuild", activity = 3, join_condition = 3,
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_300_000_052UL, displayName: "ShapeInvitee");
using var clientB = factory.CreateAuthenticatedClient(viewerB);
await clientA.PostAsync("/guild/invite",
JsonContent.Create(new { invited_viewer_id = (int)viewerB,
invite_message = "", viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var resp = await clientB.PostAsync("/guild/invited_guild_list",
JsonContent.Create(new { page = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
var json = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var entry = doc.RootElement.GetProperty("list")[0];
// GuildDetailInfo fields must be FLAT siblings of invite_id (not nested under 'detail' or similar).
// This matches GuildInvitedListTask.Parse() which calls new GuildDetailInfo(json) and reads
// invite_id from the SAME json node.
string[] required = [
"guild_id", "guild_name", "description", "guild_emblem_id",
"join_condition", "activity", "member_num", "leader_name", "leader_viewer_id",
"invite_id"
];
foreach (var field in required)
{
Assert.That(entry.TryGetProperty(field, out _), Is.True,
$"Field '{field}' must be a flat sibling in /guild/invited_guild_list response, got: {json}");
}
// Verify no nesting — there must NOT be a 'detail' sub-object.
Assert.That(entry.TryGetProperty("detail", out _), Is.False,
$"'detail' wrapper must NOT be present; fields must be flat. Response: {json}");
}
}