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:
@@ -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; }
|
||||
|
||||
5007
SVSim.Database/Migrations/20260627171751_AddGuildInviteSurrogatePk.Designer.cs
generated
Normal file
5007
SVSim.Database/Migrations/20260627171751_AddGuildInviteSurrogatePk.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
282
SVSim.UnitTests/Integration/Guild/GuildInviteFlowTests.cs
Normal file
282
SVSim.UnitTests/Integration/Guild/GuildInviteFlowTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user