feat(guild): join / cancel_join_request / join_request_list
Implements three join-path state machine endpoints: - /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply), ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels join requests; MaxMemberNum cap enforced. - /guild/cancel_join_request: cancels all pending requests for caller (no request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept). - /guild/join_request_list: leader/subleader sees pending applicants with viewer profile (name/emblem/degree/request_time unix secs). IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId) to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched record added to GuildServiceTypes. Re-apply after cancel resets existing row in-place to avoid composite-PK conflict. 11 integration tests, all green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,8 @@ namespace SVSim.Database.Migrations
|
|||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("GuildId", "InviteeViewerId")
|
b.HasIndex("GuildId", "InviteeViewerId")
|
||||||
.IsUnique();
|
.IsUnique()
|
||||||
|
.HasFilter("\"Status\" = 0");
|
||||||
|
|
||||||
b.HasIndex("InviteeViewerId", "Status");
|
b.HasIndex("InviteeViewerId", "Status");
|
||||||
|
|
||||||
|
|||||||
@@ -250,11 +250,82 @@ public sealed class GuildService : IGuildService
|
|||||||
return GuildOpResult.Ok;
|
return GuildOpResult.Ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
|
public async Task<GuildOpResult> JoinAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
if (await _members.GetMembershipAsync(viewerId, ct) is not null)
|
||||||
|
return new(GuildOpResultCode.AlreadyInGuild);
|
||||||
|
|
||||||
public Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default)
|
var g = await _guilds.GetActiveByIdAsync(guildId, ct);
|
||||||
=> throw new NotImplementedException();
|
if (g is null) return new(GuildOpResultCode.GuildNotFound);
|
||||||
|
|
||||||
|
var cfg = _config.Get<GuildConfig>();
|
||||||
|
var memberCount = await _members.CountByGuildAsync(guildId, ct);
|
||||||
|
if (memberCount >= cfg.MaxMemberNum) return new(GuildOpResultCode.MemberCapReached);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
bool hasPendingInvite = await _invites.GetAsync(guildId, viewerId, ct)
|
||||||
|
is { Status: GuildInviteStatus.Pending };
|
||||||
|
|
||||||
|
if (g.JoinCondition == GuildJoinCondition.OnlyInvite && !hasPendingInvite)
|
||||||
|
return new(GuildOpResultCode.PermissionDenied);
|
||||||
|
|
||||||
|
if (g.JoinCondition == GuildJoinCondition.Approval && !hasPendingInvite)
|
||||||
|
{
|
||||||
|
var existing = await _joinRequests.GetAsync(guildId, viewerId, ct);
|
||||||
|
if (existing is { Status: GuildJoinRequestStatus.Pending })
|
||||||
|
{
|
||||||
|
// Already pending — idempotent, no-op.
|
||||||
|
return GuildOpResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// Row exists but was previously Canceled/Rejected — reset to Pending in place.
|
||||||
|
existing.Status = GuildJoinRequestStatus.Pending;
|
||||||
|
existing.CreatedAt = now;
|
||||||
|
existing.RespondedAt = null;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _joinRequests.AddAsync(new GuildJoinRequest
|
||||||
|
{
|
||||||
|
GuildId = guildId,
|
||||||
|
ViewerId = viewerId,
|
||||||
|
Status = GuildJoinRequestStatus.Pending,
|
||||||
|
CreatedAt = now,
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
return GuildOpResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CommitJoinAsync(viewerId, guildId, now, ct);
|
||||||
|
return GuildOpResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CommitJoinAsync(long viewerId, int guildId, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _members.AddAsync(
|
||||||
|
new GuildMember { GuildId = guildId, ViewerId = viewerId, Role = GuildRole.Regular, JoinedAt = now },
|
||||||
|
ct);
|
||||||
|
await _viewers.SetGuildIdAsync(viewerId, guildId, ct);
|
||||||
|
await _invites.ConsumePendingForViewerAsync(viewerId, now, ct);
|
||||||
|
await _joinRequests.CancelPendingForViewerAsync(viewerId, now, ct);
|
||||||
|
await _chat.EmitSystemEventAsync(guildId, viewerId, GuildChatMessageType.Join, body: null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Cancel all pending requests for this viewer (client sends no guildId — see decompile).
|
||||||
|
var pending = await _joinRequests.ListPendingForViewerAsync(viewerId, ct);
|
||||||
|
if (pending.Count == 0) return new(GuildOpResultCode.JoinRequestNotFound);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var req in pending)
|
||||||
|
await _joinRequests.UpdateStatusAsync(req.GuildId, viewerId, GuildJoinRequestStatus.Canceled, now, ct);
|
||||||
|
|
||||||
|
return GuildOpResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
|
public Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
=> throw new NotImplementedException();
|
||||||
@@ -328,6 +399,39 @@ public sealed class GuildService : IGuildService
|
|||||||
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
|
LeaderName: leaderNames.GetValueOrDefault(invite.Guild.LeaderViewerId, ""))).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
var membership = await _members.GetMembershipAsync(viewerId, ct);
|
||||||
|
if (membership is null) return Array.Empty<GuildJoinRequestEntry>();
|
||||||
|
// Only leader/subleader may view the list — return empty for regular members.
|
||||||
|
if (membership.Role is not (GuildRole.Leader or GuildRole.SubLeader))
|
||||||
|
return Array.Empty<GuildJoinRequestEntry>();
|
||||||
|
|
||||||
|
var requests = await _joinRequests.ListPendingForGuildAsync(membership.GuildId, ct);
|
||||||
|
if (requests.Count == 0) return Array.Empty<GuildJoinRequestEntry>();
|
||||||
|
|
||||||
|
var applicantIds = requests.Select(r => r.ViewerId).Distinct().ToList();
|
||||||
|
var names = await _viewers.LoadDisplayNamesAsync(applicantIds, ct);
|
||||||
|
|
||||||
|
var viewerRows = await _db.Viewers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(v => v.Info.SelectedEmblem)
|
||||||
|
.Include(v => v.Info.SelectedDegree)
|
||||||
|
.Where(v => applicantIds.Contains(v.Id))
|
||||||
|
.ToDictionaryAsync(v => v.Id, ct);
|
||||||
|
|
||||||
|
return requests.Select(r =>
|
||||||
|
{
|
||||||
|
viewerRows.TryGetValue(r.ViewerId, out var v);
|
||||||
|
return new GuildJoinRequestEntry(
|
||||||
|
ApplicantViewerId: r.ViewerId,
|
||||||
|
Name: names.GetValueOrDefault(r.ViewerId, ""),
|
||||||
|
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,
|
||||||
|
IsOfficialMarkDisplayed: v?.Info?.IsOfficialMarkDisplayed == true,
|
||||||
|
RequestedAt: r.CreatedAt);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,3 +62,17 @@ public sealed record GuildReceivedInviteEntry(
|
|||||||
Entities.Guild.Guild Guild,
|
Entities.Guild.Guild Guild,
|
||||||
int MemberNum,
|
int MemberNum,
|
||||||
string LeaderName);
|
string LeaderName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enriched join-request entry returned by <see cref="IGuildService.ListPendingJoinRequestsForMyGuildAsync"/>.
|
||||||
|
/// Combines applicant profile data with the request timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GuildJoinRequestEntry(
|
||||||
|
long ApplicantViewerId,
|
||||||
|
string Name,
|
||||||
|
long EmblemId,
|
||||||
|
string CountryCode,
|
||||||
|
int Rank,
|
||||||
|
int DegreeId,
|
||||||
|
bool IsOfficialMarkDisplayed,
|
||||||
|
DateTime RequestedAt);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public interface IGuildService
|
|||||||
Task<GuildOpResult> RejectInviteAsync(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> JoinAsync(long viewerId, int guildId, CancellationToken ct = default);
|
||||||
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, int guildId, CancellationToken ct = default);
|
Task<GuildOpResult> CancelJoinRequestAsync(long viewerId, CancellationToken ct = default);
|
||||||
Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
Task<GuildOpResult> AcceptJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
||||||
Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
Task<GuildOpResult> RejectJoinRequestAsync(long callerViewerId, long applicantViewerId, CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -38,5 +38,5 @@ public interface IGuildService
|
|||||||
Task<IReadOnlyList<Entities.Guild.GuildInvite>> ListPendingInvitesForMeAsync(long viewerId, 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<GuildOutgoingInviteEntry>> ListOutgoingInvitesAsync(long callerViewerId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default);
|
Task<IReadOnlyList<GuildReceivedInviteEntry>> ListInvitedGuildsAsync(long viewerId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<Entities.Guild.GuildJoinRequest>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
|
Task<IReadOnlyList<GuildJoinRequestEntry>> ListPendingJoinRequestsForMyGuildAsync(long viewerId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,15 +243,44 @@ public sealed class GuildController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("join")]
|
[HttpPost("join")]
|
||||||
public Task<ActionResult<GuildJoinResponse>> Join([FromBody] GuildJoinEndpointRequest req, CancellationToken ct)
|
public async Task<ActionResult<GuildJoinResponse>> Join([FromBody] GuildJoinEndpointRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildJoinResponse>>(new GuildJoinResponse { GuildStatus = 0 });
|
{
|
||||||
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
|
var r = await _guild.JoinAsync(viewerId, req.GuildId, ct);
|
||||||
|
if (!r.IsOk) return WireError();
|
||||||
|
// guild_status=2 (JOINING) on successful join; APPROVAL path also returns 2 as the viewer
|
||||||
|
// is now "awaiting" — client drives the display from result_code, not guild_status alone.
|
||||||
|
return new GuildJoinResponse { GuildStatus = 2 };
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("cancel_join_request")]
|
[HttpPost("cancel_join_request")]
|
||||||
public Task<ActionResult<EmptyResponse>> CancelJoinRequest([FromBody] GuildCancelJoinRequestRequest req, CancellationToken ct) => Stub();
|
public async Task<ActionResult<EmptyResponse>> CancelJoinRequest([FromBody] GuildCancelJoinRequestRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
|
var r = await _guild.CancelJoinRequestAsync(viewerId, ct);
|
||||||
|
return r.IsOk ? new EmptyResponse() : MapErrorToWire(r);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("join_request_list")]
|
[HttpPost("join_request_list")]
|
||||||
public Task<ActionResult<GuildJoinRequestListResponse>> JoinRequestList([FromBody] GuildJoinRequestListRequest req, CancellationToken ct)
|
public async Task<ActionResult<GuildJoinRequestListResponse>> JoinRequestList([FromBody] GuildJoinRequestListRequest req, CancellationToken ct)
|
||||||
=> Task.FromResult<ActionResult<GuildJoinRequestListResponse>>(new GuildJoinRequestListResponse { Users = new() });
|
{
|
||||||
|
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||||
|
var entries = await _guild.ListPendingJoinRequestsForMyGuildAsync(viewerId, ct);
|
||||||
|
return new GuildJoinRequestListResponse
|
||||||
|
{
|
||||||
|
Users = entries.Select(e => new GuildJoinRequestEntryDto
|
||||||
|
{
|
||||||
|
ViewerId = e.ApplicantViewerId,
|
||||||
|
Name = e.Name,
|
||||||
|
EmblemId = e.EmblemId,
|
||||||
|
CountryCode = e.CountryCode,
|
||||||
|
Rank = e.Rank,
|
||||||
|
DegreeId = e.DegreeId,
|
||||||
|
IsOfficialMarkDisplayed = e.IsOfficialMarkDisplayed ? 1 : 0,
|
||||||
|
RequestTime = new DateTimeOffset(e.RequestedAt, TimeSpan.Zero).ToUnixTimeSeconds(),
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("join_request_accept")]
|
[HttpPost("join_request_accept")]
|
||||||
public Task<ActionResult<EmptyResponse>> JoinRequestAccept([FromBody] GuildJoinRequestAcceptRequest req, CancellationToken ct) => Stub();
|
public Task<ActionResult<EmptyResponse>> JoinRequestAccept([FromBody] GuildJoinRequestAcceptRequest req, CancellationToken ct) => Stub();
|
||||||
|
|||||||
457
SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs
Normal file
457
SVSim.UnitTests/Integration/Guild/GuildJoinFlowTests.cs
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Integration.Guild;
|
||||||
|
|
||||||
|
using GuildEntity = SVSim.Database.Entities.Guild.Guild;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the join-path state machine:
|
||||||
|
/// /guild/join (FREE / APPROVAL / ONLY_INVITE branches),
|
||||||
|
/// /guild/cancel_join_request,
|
||||||
|
/// /guild/join_request_list.
|
||||||
|
/// </summary>
|
||||||
|
public class GuildJoinFlowTests
|
||||||
|
{
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// Many wire fields use StringifiedIntConverter — values arrive as "123" not 123.
|
||||||
|
private static int GetStringifiedInt(JsonElement el, string prop)
|
||||||
|
=> int.Parse(el.GetProperty(prop).GetString()!);
|
||||||
|
|
||||||
|
private static long GetStringifiedLong(JsonElement el, string prop)
|
||||||
|
=> long.Parse(el.GetProperty(prop).GetString()!);
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Creates a guild as the given client and returns the guild_id from /guild/info.</summary>
|
||||||
|
private static async Task<int> CreateGuildAndGetIdAsync(HttpClient client, string name, int joinCondition = 1)
|
||||||
|
{
|
||||||
|
var createResp = await client.PostAsync("/guild/create",
|
||||||
|
JsonContent.Create(new
|
||||||
|
{
|
||||||
|
guild_name = name,
|
||||||
|
activity = 1,
|
||||||
|
join_condition = joinCondition,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||||
|
}));
|
||||||
|
Assert.That(createResp.IsSuccessStatusCode, Is.True,
|
||||||
|
$"create failed: {await createResp.Content.ReadAsStringAsync()}");
|
||||||
|
|
||||||
|
var infoResp = await client.PostAsync("/guild/info",
|
||||||
|
JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(infoJson);
|
||||||
|
return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FREE branch ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Free_join_makes_viewer_a_member_instantly()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_001UL, displayName: "FreeLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "FreeGuild", joinCondition: 1);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_002UL, displayName: "FreeJoiner");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var joinJson = await joinResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}");
|
||||||
|
using var joinDoc = JsonDocument.Parse(joinJson);
|
||||||
|
if (joinDoc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"join returned error: {joinJson}");
|
||||||
|
|
||||||
|
// guild_status must be 2 (JOINING) — value is stringified.
|
||||||
|
Assert.That(GetStringifiedInt(joinDoc.RootElement, "guild_status"), Is.EqualTo(2),
|
||||||
|
$"guild_status must be 2 after FREE join, got: {joinJson}");
|
||||||
|
|
||||||
|
// B calling /guild/info should now be a member (guild_status=2).
|
||||||
|
var bInfoResp = await clientB.PostAsync("/guild/info",
|
||||||
|
JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var bInfoJson = await bInfoResp.Content.ReadAsStringAsync();
|
||||||
|
using var bInfoDoc = JsonDocument.Parse(bInfoJson);
|
||||||
|
Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(2),
|
||||||
|
$"B should be a member after FREE join, got: {bInfoJson}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Free_join_consumes_pending_invite()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_011UL, displayName: "FreeLeader2");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "FreeGuild2", joinCondition: 1);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_012UL, displayName: "FreeJoinerInvited");
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
// B joins via FREE path.
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True);
|
||||||
|
|
||||||
|
// After joining, B's invited_guild_list should be empty (invite consumed).
|
||||||
|
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 joining, pending invites for the viewer should be consumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── APPROVAL branch ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Approval_join_creates_pending_request_not_membership()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_021UL, displayName: "ApprovalLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "ApprovalGuild", joinCondition: 2);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_022UL, displayName: "ApprovalApplicant");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
// B applies.
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var joinJson = await joinResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}");
|
||||||
|
using var joinDoc = JsonDocument.Parse(joinJson);
|
||||||
|
if (joinDoc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"APPROVAL join returned error: {joinJson}");
|
||||||
|
|
||||||
|
// B must NOT be a member yet (guild_status=0, no guild sub-object).
|
||||||
|
var bInfoResp = await clientB.PostAsync("/guild/info",
|
||||||
|
JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var bInfoJson = await bInfoResp.Content.ReadAsStringAsync();
|
||||||
|
using var bInfoDoc = JsonDocument.Parse(bInfoJson);
|
||||||
|
Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(0),
|
||||||
|
$"B must NOT be a member after APPROVAL apply, got: {bInfoJson}");
|
||||||
|
|
||||||
|
// A (leader) should see B in the join_request_list.
|
||||||
|
var jrlResp = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var jrlJson = await jrlResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list HTTP failed: {jrlJson}");
|
||||||
|
using var jrlDoc = JsonDocument.Parse(jrlJson);
|
||||||
|
var list = jrlDoc.RootElement.GetProperty("list");
|
||||||
|
Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"Leader should see 1 pending applicant, got: {jrlJson}");
|
||||||
|
// viewer_id in list entries is StringifiedLong.
|
||||||
|
Assert.That(GetStringifiedLong(list[0], "viewer_id"), Is.EqualTo(viewerB),
|
||||||
|
"The applicant must be viewer B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Approval_join_is_idempotent()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_031UL, displayName: "IdempotentLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "IdempotentGuild", joinCondition: 2);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_032UL, displayName: "IdempotentApplicant");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
var joinPayload = new { guild_id = guildId, from_invite = false, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
|
||||||
|
|
||||||
|
// B applies twice.
|
||||||
|
await clientB.PostAsync("/guild/join", JsonContent.Create(joinPayload));
|
||||||
|
var join2 = await clientB.PostAsync("/guild/join", JsonContent.Create(joinPayload));
|
||||||
|
var join2Json = await join2.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(join2.IsSuccessStatusCode, Is.True, $"Second apply must not HTTP-fail: {join2Json}");
|
||||||
|
using var join2Doc = JsonDocument.Parse(join2Json);
|
||||||
|
if (join2Doc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"Second apply must return Ok: {join2Json}");
|
||||||
|
|
||||||
|
// Still only 1 entry in the list.
|
||||||
|
var jrlResp = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
using var jrlDoc = JsonDocument.Parse(await jrlResp.Content.ReadAsStringAsync());
|
||||||
|
Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1),
|
||||||
|
"Idempotent re-apply must not duplicate the pending row");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ONLY_INVITE branch ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OnlyInvite_without_invite_is_rejected()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_041UL, displayName: "InviteOnlyLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "InviteOnlyGuild", joinCondition: 3);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_042UL, displayName: "UninvitedB");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var joinJson = await joinResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True, "HTTP must be 200");
|
||||||
|
using var joinDoc = JsonDocument.Parse(joinJson);
|
||||||
|
Assert.That(joinDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True,
|
||||||
|
$"Expected error result_code, got: {joinJson}");
|
||||||
|
Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Must be rejected (result_code=2), got: {joinJson}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OnlyInvite_with_pending_invite_joins_and_consumes_invite()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_051UL, displayName: "InviteOnlyLeader2");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "InviteOnlyGuild2", joinCondition: 3);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_052UL, displayName: "InvitedB");
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
// B joins via ONLY_INVITE path.
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = true,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var joinJson = await joinResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"join HTTP failed: {joinJson}");
|
||||||
|
using var joinDoc = JsonDocument.Parse(joinJson);
|
||||||
|
if (joinDoc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"ONLY_INVITE join with invite returned error: {joinJson}");
|
||||||
|
|
||||||
|
// B is now a member.
|
||||||
|
var bInfoResp = await clientB.PostAsync("/guild/info",
|
||||||
|
JsonContent.Create(new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var bInfoJson = await bInfoResp.Content.ReadAsStringAsync();
|
||||||
|
using var bInfoDoc = JsonDocument.Parse(bInfoJson);
|
||||||
|
Assert.That(GetStringifiedInt(bInfoDoc.RootElement, "guild_status"), Is.EqualTo(2),
|
||||||
|
$"B must be a member after ONLY_INVITE join, got: {bInfoJson}");
|
||||||
|
|
||||||
|
// Invite was consumed — B's invited_guild_list is now 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),
|
||||||
|
"Invite must be consumed (Pending→Consumed) after ONLY_INVITE join");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Member cap ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Full_guild_rejects_free_join()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
// Seed a GuildConfig with MaxMemberNum = 1 so the guild fills at the leader.
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSim.Database.SVSimDbContext>();
|
||||||
|
const string json = "{\"MaxMemberNum\":1,\"MaxSubLeaderNum\":3,\"SearchResultCap\":20,\"UsableStampList\":[1,2,3,4,5]}";
|
||||||
|
var existing = db.GameConfigs.FirstOrDefault(s => s.SectionName == "Guild");
|
||||||
|
if (existing is null)
|
||||||
|
db.GameConfigs.Add(new SVSim.Database.Models.GameConfigSection { SectionName = "Guild", ValueJson = json });
|
||||||
|
else
|
||||||
|
existing.ValueJson = json;
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_061UL, displayName: "CapLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "CapGuild", joinCondition: 1);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_062UL, displayName: "CapJoiner");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
var joinResp = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var joinJson = await joinResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(joinResp.IsSuccessStatusCode, Is.True, "HTTP must be 200");
|
||||||
|
using var joinDoc = JsonDocument.Parse(joinJson);
|
||||||
|
Assert.That(joinDoc.RootElement.TryGetProperty("result_code", out var rc), Is.True,
|
||||||
|
$"Expected error result_code when guild is full, got: {joinJson}");
|
||||||
|
Assert.That(rc.GetInt32(), Is.EqualTo(2), $"Full guild must reject join (result_code=2), got: {joinJson}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── cancel_join_request ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Cancel_join_request_removes_pending_row()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_071UL, displayName: "CancelReqLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "CancelReqGuild", joinCondition: 2);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_072UL, displayName: "CancelApplicant");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
// B applies.
|
||||||
|
await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
|
||||||
|
// A sees B in the list.
|
||||||
|
var jrl1 = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
using var jrl1Doc = JsonDocument.Parse(await jrl1.Content.ReadAsStringAsync());
|
||||||
|
Assert.That(jrl1Doc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1),
|
||||||
|
"Should see 1 pending before cancel");
|
||||||
|
|
||||||
|
// B cancels (no guild_id in request — server infers from viewer state).
|
||||||
|
var cancelResp = await clientB.PostAsync("/guild/cancel_join_request",
|
||||||
|
JsonContent.Create(BaseReq()));
|
||||||
|
var cancelJson = await cancelResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(cancelResp.IsSuccessStatusCode, Is.True, $"cancel_join_request HTTP failed: {cancelJson}");
|
||||||
|
using var cancelDoc = JsonDocument.Parse(cancelJson);
|
||||||
|
if (cancelDoc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"cancel_join_request returned error: {cancelJson}");
|
||||||
|
|
||||||
|
// A no longer sees any pending applicants.
|
||||||
|
var jrl2 = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
using var jrl2Doc = JsonDocument.Parse(await jrl2.Content.ReadAsStringAsync());
|
||||||
|
Assert.That(jrl2Doc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0),
|
||||||
|
"After cancel, no pending applicants should be visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task After_cancel_viewer_can_reapply()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_081UL, displayName: "ReApplyLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "ReApplyGuild", joinCondition: 2);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_082UL, displayName: "ReApplicant");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
|
||||||
|
// Apply → cancel → re-apply.
|
||||||
|
await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
|
||||||
|
await clientB.PostAsync("/guild/cancel_join_request", JsonContent.Create(BaseReq()));
|
||||||
|
|
||||||
|
var reApply = await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var reApplyJson = await reApply.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(reApply.IsSuccessStatusCode, Is.True, $"Re-apply after cancel HTTP failed: {reApplyJson}");
|
||||||
|
using var doc = JsonDocument.Parse(reApplyJson);
|
||||||
|
if (doc.RootElement.TryGetProperty("result_code", out var rc))
|
||||||
|
Assert.That(rc.GetInt32(), Is.Not.EqualTo(2), $"Re-apply after cancel must not error: {reApplyJson}");
|
||||||
|
|
||||||
|
// A should see 1 pending applicant again.
|
||||||
|
var jrlResp = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
using var jrlDoc = JsonDocument.Parse(await jrlResp.Content.ReadAsStringAsync());
|
||||||
|
Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(1),
|
||||||
|
"After re-apply, applicant should appear in list again");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── join_request_list permission ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Join_request_list_returns_empty_for_regular_member()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_091UL, displayName: "ListLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "ListGuild", joinCondition: 1);
|
||||||
|
|
||||||
|
// B joins (FREE) — becomes Regular member.
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_092UL, displayName: "RegularMember");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
|
||||||
|
// Regular member B gets an empty list from join_request_list.
|
||||||
|
var jrlResp = await clientB.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var jrlJson = await jrlResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list HTTP failed: {jrlJson}");
|
||||||
|
using var jrlDoc = JsonDocument.Parse(jrlJson);
|
||||||
|
Assert.That(jrlDoc.RootElement.GetProperty("list").GetArrayLength(), Is.EqualTo(0),
|
||||||
|
"Regular member must see empty list from join_request_list");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wire-shape test for join_request_list ───────────────────────────────────
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Join_request_list_response_shape_is_correct()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
|
||||||
|
long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_101UL, displayName: "ShapeLeader");
|
||||||
|
using var clientA = factory.CreateAuthenticatedClient(viewerA);
|
||||||
|
int guildId = await CreateGuildAndGetIdAsync(clientA, "ShapeGuild", joinCondition: 2);
|
||||||
|
|
||||||
|
long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_400_000_102UL, displayName: "ShapeApplicant");
|
||||||
|
using var clientB = factory.CreateAuthenticatedClient(viewerB);
|
||||||
|
await clientB.PostAsync("/guild/join",
|
||||||
|
JsonContent.Create(new { guild_id = guildId, from_invite = false,
|
||||||
|
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
|
||||||
|
var jrlResp = await clientA.PostAsync("/guild/join_request_list",
|
||||||
|
JsonContent.Create(new { page = 0, oldest_time = 0, viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk }));
|
||||||
|
var jrlJson = await jrlResp.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(jrlResp.IsSuccessStatusCode, Is.True, $"join_request_list failed: {jrlJson}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(jrlJson);
|
||||||
|
var list = doc.RootElement.GetProperty("list");
|
||||||
|
Assert.That(list.GetArrayLength(), Is.EqualTo(1), $"Expected 1 entry, got: {jrlJson}");
|
||||||
|
|
||||||
|
var entry = list[0];
|
||||||
|
|
||||||
|
// GuildUserInfo fields (flat siblings per GuildUserInfo constructor) + request-list extras.
|
||||||
|
string[] requiredFields = [
|
||||||
|
"viewer_id", "name", "emblem_id", "country_code", "rank", "degree_id",
|
||||||
|
"is_official_mark_displayed", "request_time"
|
||||||
|
];
|
||||||
|
foreach (var field in requiredFields)
|
||||||
|
{
|
||||||
|
Assert.That(entry.TryGetProperty(field, out _), Is.True,
|
||||||
|
$"Field '{field}' must be present in join_request_list entry, got: {jrlJson}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// request_time must be a positive integer (unix seconds, NOT milliseconds).
|
||||||
|
Assert.That(entry.GetProperty("request_time").GetInt64(), Is.GreaterThan(0),
|
||||||
|
"request_time must be a positive unix seconds timestamp");
|
||||||
|
|
||||||
|
// viewer_id is stringified — must parse as a valid long.
|
||||||
|
Assert.That(long.TryParse(entry.GetProperty("viewer_id").GetString(), out var parsedId), Is.True,
|
||||||
|
$"viewer_id must be stringified long, got: {jrlJson}");
|
||||||
|
Assert.That(parsedId, Is.EqualTo(viewerB), "viewer_id must match the applicant");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user