feat(guild_chat): deck + replay attachments
Implement all five guild_chat attachment endpoints:
- add_deck: server looks up deck by (viewerId, format, deckNo) and snapshots
the current state into a DECK-type chat message DeckPayload column.
- delete_deck: author OR leader/sub-leader may clear; returns refreshed
deck_log. Extended IGuildChatMessageRepository.ClearDeckAsync with
leaderOverride flag; added GetDeckMessagesAsync + GetByMessageIdAsync.
- add_replay: stores {"battle_id": N} in ReplayPayload; full shape is
TODO(post-merge) pending replay-subsystem capture.
- replay_detail: returns the stored ReplayPayload as JsonElement passthrough.
- deck_log: returns all active DECK messages grouped by API-side Format string
key ("1"=Rotation, "2"=Unlimited, "3"=PreRotation always present;
"4"=Crossover, "5"=MyRotation only when non-empty).
Adds DeckLogDataDto (typed) for deck_log / delete_deck responses.
Replay response uses JsonElement passthrough (TODO(post-merge)).
8 new integration tests — all green. Full suite 1530/1530 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -150,13 +150,25 @@ public sealed class GuildChatMessageRepository : IGuildChatMessageRepository
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default)
|
||||
public Task<GuildChatMessage?> GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default)
|
||||
=> _db.GuildChatMessages
|
||||
.FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct);
|
||||
|
||||
public async Task<IReadOnlyList<GuildChatMessage>> GetDeckMessagesAsync(int guildId, CancellationToken ct = default)
|
||||
=> await _db.GuildChatMessages
|
||||
.Where(m => m.GuildId == guildId
|
||||
&& m.MessageType == GuildChatMessageType.Deck
|
||||
&& m.DeckPayload != null)
|
||||
.OrderBy(m => m.MessageId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, bool leaderOverride, CancellationToken ct = default)
|
||||
{
|
||||
var msg = await _db.GuildChatMessages
|
||||
.FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct);
|
||||
|
||||
if (msg is null) return false;
|
||||
if (msg.AuthorViewerId != callerViewerId) return false;
|
||||
if (!leaderOverride && msg.AuthorViewerId != callerViewerId) return false;
|
||||
if (msg.DeckPayload is null) return false;
|
||||
|
||||
msg.DeckPayload = null;
|
||||
|
||||
@@ -14,7 +14,18 @@ public interface IGuildChatMessageRepository
|
||||
Task<int> GetMaxMessageIdAsync(int guildId, CancellationToken ct = default);
|
||||
Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>For /guild_chat/delete_deck: clears the deck payload + body without removing the message row,
|
||||
/// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller.</summary>
|
||||
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default);
|
||||
/// <summary>Fetch a single message by (guildId, messageId). Returns null if not found.</summary>
|
||||
Task<GuildChatMessage?> GetByMessageIdAsync(int guildId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch all DECK-type messages for a guild (non-null DeckPayload only — clears are excluded).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<GuildChatMessage>> GetDeckMessagesAsync(int guildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// For /guild_chat/delete_deck: clears the deck payload + body without removing the message row,
|
||||
/// so the slot remains in the timeline ("[deleted]"). Returns false if not found / not deletable by caller.
|
||||
/// <paramref name="leaderOverride"/> bypasses the author check — pass true when caller is Leader or SubLeader.
|
||||
/// </summary>
|
||||
Task<bool> ClearDeckAsync(int guildId, int messageId, long callerViewerId, bool leaderOverride, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Guild;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
@@ -10,15 +13,18 @@ public sealed class GuildChatService : IGuildChatService
|
||||
private readonly IGuildMemberRepository _members;
|
||||
private readonly IGuildChatMessageRepository _msgs;
|
||||
private readonly IGameConfigService _cfg;
|
||||
private readonly IDeckRepository _decks;
|
||||
|
||||
public GuildChatService(
|
||||
IGuildMemberRepository members,
|
||||
IGuildChatMessageRepository msgs,
|
||||
IGameConfigService cfg)
|
||||
IGameConfigService cfg,
|
||||
IDeckRepository decks)
|
||||
{
|
||||
_members = members;
|
||||
_msgs = msgs;
|
||||
_cfg = cfg;
|
||||
_decks = decks;
|
||||
}
|
||||
|
||||
// ── System-event emission ────────────────────────────────────────────────
|
||||
@@ -98,18 +104,108 @@ public sealed class GuildChatService : IGuildChatService
|
||||
return new(true, stored.MessageId);
|
||||
}
|
||||
|
||||
public Task<ChatPostResult> PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
// ── Deck attachment ──────────────────────────────────────────────────────
|
||||
|
||||
public Task<bool> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
public async Task<ChatPostResult> PostDeckAsync(long viewerId, Format format, int deckFormatApi, int deckNo, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return new(false, null);
|
||||
|
||||
public Task<ChatPostResult> PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
var deck = await _decks.GetDeck(viewerId, format, deckNo);
|
||||
if (deck is null) return new(false, null);
|
||||
|
||||
public Task<string?> GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
// Build the deck payload JSON that the client expects to parse via DeckLogData / DeckData.Initialize.
|
||||
// Fields: deck_format (API int), deck_no, deck_name, class_id, sleeve_id, leader_skin_id, card_id_array.
|
||||
var cardIdArray = deck.Cards
|
||||
.SelectMany(c => Enumerable.Repeat(c.Card.Id, c.Count))
|
||||
.ToList();
|
||||
|
||||
public Task<string?> GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||
=> throw new NotImplementedException();
|
||||
var payload = new
|
||||
{
|
||||
deck_format = deckFormatApi,
|
||||
deck_no = deck.Number,
|
||||
deck_name = deck.Name,
|
||||
class_id = deck.Class.Id,
|
||||
sleeve_id = (long)deck.Sleeve.Id,
|
||||
leader_skin_id = deck.LeaderSkin.Id,
|
||||
card_id_array = cardIdArray,
|
||||
};
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
|
||||
var stored = await _msgs.AppendAsync(new GuildChatMessage
|
||||
{
|
||||
GuildId = m.GuildId,
|
||||
AuthorViewerId = viewerId,
|
||||
MessageType = GuildChatMessageType.Deck,
|
||||
Body = "",
|
||||
DeckPayload = payloadJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
return new(true, stored.MessageId);
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return (false, null);
|
||||
|
||||
bool isLeader = m.Role is GuildRole.Leader or GuildRole.SubLeader;
|
||||
bool cleared = await _msgs.ClearDeckAsync(m.GuildId, messageId, viewerId, isLeader, ct);
|
||||
if (!cleared) return (false, null);
|
||||
|
||||
var log = await BuildDeckLogAsync(m.GuildId, ct);
|
||||
return (true, log);
|
||||
}
|
||||
|
||||
// ── Replay attachment ────────────────────────────────────────────────────
|
||||
|
||||
public async Task<ChatPostResult> PostReplayAsync(long viewerId, long battleId, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return new(false, null);
|
||||
|
||||
// Store minimal payload; replay subsystem detail is TODO(post-merge).
|
||||
var payloadJson = JsonSerializer.Serialize(new { battle_id = battleId });
|
||||
|
||||
var stored = await _msgs.AppendAsync(new GuildChatMessage
|
||||
{
|
||||
GuildId = m.GuildId,
|
||||
AuthorViewerId = viewerId,
|
||||
MessageType = GuildChatMessageType.Replay,
|
||||
Body = "",
|
||||
ReplayPayload = payloadJson,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, ct);
|
||||
return new(true, stored.MessageId);
|
||||
}
|
||||
|
||||
public async Task<string?> GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return null;
|
||||
|
||||
var msg = await _msgs.GetByMessageIdAsync(m.GuildId, messageId, ct);
|
||||
return msg?.ReplayPayload;
|
||||
}
|
||||
|
||||
// ── Deck log ─────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<DeckLogResult?> GetDeckLogAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
var m = await _members.GetMembershipAsync(viewerId, ct);
|
||||
if (m is null) return null;
|
||||
return await BuildDeckLogAsync(m.GuildId, ct);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<DeckLogResult> BuildDeckLogAsync(int guildId, CancellationToken ct)
|
||||
{
|
||||
var deckMessages = await _msgs.GetDeckMessagesAsync(guildId, ct);
|
||||
var entries = deckMessages
|
||||
.Where(msg => msg.DeckPayload is not null)
|
||||
.Select(msg => new DeckLogEntry(msg.MessageId, msg.AuthorViewerId, msg.DeckPayload!))
|
||||
.ToList();
|
||||
return new DeckLogResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,57 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Services.Guild;
|
||||
|
||||
public sealed record ChatWindow(IReadOnlyList<Entities.Guild.GuildChatMessage> Messages, int WaitIntervalSeconds);
|
||||
public sealed record ChatPostResult(bool Ok, int? AssignedMessageId);
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in the deck log: the raw JSON stored in DeckPayload, plus the envelope fields
|
||||
/// (messageId, authorViewerId) needed to compute delete_permission_exists in the controller.
|
||||
/// </summary>
|
||||
public sealed record DeckLogEntry(int MessageId, long AuthorViewerId, string PayloadJson);
|
||||
|
||||
/// <summary>
|
||||
/// Result for /guild_chat/deck_log and /guild_chat/delete_deck.
|
||||
/// All active (non-deleted) DECK messages in the guild — caller is responsible for grouping by
|
||||
/// deck_format and computing delete_permission_exists per entry.
|
||||
/// </summary>
|
||||
public sealed record DeckLogResult(IReadOnlyList<DeckLogEntry> Entries);
|
||||
|
||||
public interface IGuildChatService
|
||||
{
|
||||
Task<ChatWindow> GetWindowAsync(long viewerId, int startMessageId, int direction, int waitIntervalHint, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostTextOrStampAsync(long viewerId, int type, string message, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostDeckAsync(long viewerId, string deckPayloadJson, CancellationToken ct = default);
|
||||
Task<bool> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
Task<ChatPostResult> PostReplayAsync(long viewerId, string replayPayloadJson, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Share a deck snapshot to guild chat. The deck is looked up by (viewerId, format, deckNo)
|
||||
/// and its current state is stored as the DECK message's DeckPayload JSON.
|
||||
/// <paramref name="deckFormatApi"/> is the API-side wire integer (used verbatim in the stored payload).
|
||||
/// </summary>
|
||||
Task<ChatPostResult> PostDeckAsync(long viewerId, Format format, int deckFormatApi, int deckNo, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a shared deck. Caller must be the author OR a leader/sub-leader.
|
||||
/// Returns true on success, false if not found / no permission / already cleared.
|
||||
/// On success also returns the refreshed deck log.
|
||||
/// </summary>
|
||||
Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Share a replay to guild chat. Stores {"battle_id": N} as the REPLAY message's ReplayPayload.
|
||||
/// </summary>
|
||||
Task<ChatPostResult> PostReplayAsync(long viewerId, long battleId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetch the replay payload for a given REPLAY chat message. Returns null if not found / not a replay.
|
||||
/// </summary>
|
||||
Task<string?> GetReplayDetailAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
Task<string?> GetDeckLogAsync(long viewerId, int messageId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Return the guild's full deck log — all active (non-deleted) DECK messages grouped by
|
||||
/// API-side Format string key. Returns null if caller is not in a guild.
|
||||
/// </summary>
|
||||
Task<DeckLogResult?> GetDeckLogAsync(long viewerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Emit a system-event row. Called by IGuildService at create/join/leave/remove/role-change/description-change.</summary>
|
||||
Task EmitSystemEventAsync(int guildId, long actorViewerId, Entities.Guild.GuildChatMessageType type, string? body = null, CancellationToken ct = default);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services.Guild;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||
@@ -43,7 +46,9 @@ public sealed class GuildChatController : SVSimController
|
||||
MessageType = (int)m.MessageType,
|
||||
CreateTime = new DateTimeOffset(m.CreatedAt, TimeSpan.Zero).ToUnixTimeSeconds(),
|
||||
Message = m.Body,
|
||||
// deck / replay / room remain null until Task 17
|
||||
Deck = m.DeckPayload is not null ? ParseJsonElementOrNull(m.DeckPayload) : null,
|
||||
Replay = m.ReplayPayload is not null ? ParseJsonElementOrNull(m.ReplayPayload) : null,
|
||||
Room = m.RoomPayload is not null ? ParseJsonElementOrNull(m.RoomPayload) : null,
|
||||
}).ToList();
|
||||
|
||||
// Build deduplicated users[] catalog from message authors
|
||||
@@ -87,22 +92,163 @@ public sealed class GuildChatController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("add_deck")]
|
||||
public Task<ActionResult<EmptyResponse>> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
|
||||
public async Task<ActionResult<EmptyResponse>> AddDeck([FromBody] GuildChatAddDeckRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
Format format;
|
||||
try { format = FormatExtensions.FromApi(req.DeckFormat); }
|
||||
catch (ArgumentOutOfRangeException) { return Ok(new { result_code = 2 }); }
|
||||
|
||||
var result = await _chat.PostDeckAsync(viewerId, format, req.DeckFormat, req.DeckNo, ct);
|
||||
if (!result.Ok) return Ok(new { result_code = 2 });
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
[HttpPost("delete_deck")]
|
||||
public Task<ActionResult<EmptyResponse>> DeleteDeck([FromBody] GuildChatDeleteDeckRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
|
||||
public async Task<ActionResult<GuildChatDeleteDeckResponse>> DeleteDeck([FromBody] GuildChatDeleteDeckRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var (ok, log) = await _chat.DeleteDeckAsync(viewerId, (int)req.MessageId, ct);
|
||||
if (!ok || log is null) return Ok(new { result_code = 2 });
|
||||
|
||||
return new GuildChatDeleteDeckResponse
|
||||
{
|
||||
MaintenanceCardList = new(),
|
||||
DeckLog = BuildDeckLogDict(log, viewerId),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("add_replay")]
|
||||
public Task<ActionResult<EmptyResponse>> AddReplay([FromBody] GuildChatAddReplayRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
|
||||
public async Task<ActionResult<EmptyResponse>> AddReplay([FromBody] GuildChatAddReplayRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var result = await _chat.PostReplayAsync(viewerId, req.BattleId, ct);
|
||||
if (!result.Ok) return Ok(new { result_code = 2 });
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
[HttpPost("replay_detail")]
|
||||
public Task<ActionResult<GuildChatReplayDetailResponse>> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<GuildChatReplayDetailResponse>>(new GuildChatReplayDetailResponse());
|
||||
public async Task<ActionResult<GuildChatReplayDetailResponse>> ReplayDetail([FromBody] GuildChatReplayDetailRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var payloadJson = await _chat.GetReplayDetailAsync(viewerId, (int)req.MessageId, ct);
|
||||
if (payloadJson is null) return Ok(new { result_code = 2 });
|
||||
|
||||
var replayInfo = ParseJsonElementOrNull(payloadJson);
|
||||
return new GuildChatReplayDetailResponse { ReplayInfo = replayInfo };
|
||||
}
|
||||
|
||||
[HttpPost("deck_log")]
|
||||
public Task<ActionResult<GuildChatDeckLogResponse>> DeckLog([FromBody] GuildChatDeckLogRequest req, CancellationToken ct)
|
||||
=> Task.FromResult<ActionResult<GuildChatDeckLogResponse>>(new GuildChatDeckLogResponse());
|
||||
public async Task<ActionResult<GuildChatDeckLogResponse>> DeckLog([FromBody] GuildChatDeckLogRequest req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var viewerId)) return Unauthorized();
|
||||
|
||||
var log = await _chat.GetDeckLogAsync(viewerId, ct);
|
||||
if (log is null) return Ok(new { result_code = 2 });
|
||||
|
||||
return new GuildChatDeckLogResponse
|
||||
{
|
||||
MaintenanceCardList = new(),
|
||||
DeckLog = BuildDeckLogDict(log, viewerId),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Converts a DeckLogResult into the wire deck_log dictionary.
|
||||
/// Keys "1", "2", "3" (Rotation/Unlimited/PreRotation) are always present.
|
||||
/// Keys "4" (Crossover) and "5" (MyRotation) are only included when non-empty.
|
||||
/// delete_permission_exists is true when the calling viewer is the author (leader override
|
||||
/// is handled at the delete endpoint — here it's purely author-based for the display flag).
|
||||
/// </summary>
|
||||
private static Dictionary<string, List<DeckLogDataDto>> BuildDeckLogDict(DeckLogResult log, long callerViewerId)
|
||||
{
|
||||
// Always-present buckets.
|
||||
var dict = new Dictionary<string, List<DeckLogDataDto>>
|
||||
{
|
||||
["1"] = new(), // Rotation
|
||||
["2"] = new(), // Unlimited
|
||||
["3"] = new(), // PreRotation
|
||||
};
|
||||
|
||||
foreach (var entry in log.Entries)
|
||||
{
|
||||
DeckLogDataDto? dto = BuildDeckLogDataDto(entry, callerViewerId);
|
||||
if (dto is null) continue;
|
||||
|
||||
string key = dto.DeckFormat.ToApi().ToString();
|
||||
|
||||
if (!dict.TryGetValue(key, out var bucket))
|
||||
{
|
||||
// Optional bucket (Crossover=4, MyRotation=5) — create on first use.
|
||||
bucket = new List<DeckLogDataDto>();
|
||||
dict[key] = bucket;
|
||||
}
|
||||
bucket.Add(dto);
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a stored DeckPayload JSON into a DeckLogDataDto.
|
||||
/// Returns null if the JSON is malformed or missing required fields.
|
||||
/// </summary>
|
||||
private static DeckLogDataDto? BuildDeckLogDataDto(DeckLogEntry entry, long callerViewerId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(entry.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("deck_format", out var fmtEl)) return null;
|
||||
int deckFormatApi = fmtEl.GetInt32();
|
||||
|
||||
Format format;
|
||||
try { format = FormatExtensions.FromApi(deckFormatApi); }
|
||||
catch (ArgumentOutOfRangeException) { return null; }
|
||||
|
||||
var cardIdArray = new List<long>();
|
||||
if (root.TryGetProperty("card_id_array", out var arrEl))
|
||||
{
|
||||
foreach (var el in arrEl.EnumerateArray())
|
||||
cardIdArray.Add(el.GetInt64());
|
||||
}
|
||||
|
||||
return new DeckLogDataDto
|
||||
{
|
||||
DeckFormat = format,
|
||||
MessageId = entry.MessageId,
|
||||
DeletePermissionExists = entry.AuthorViewerId == callerViewerId,
|
||||
DeckNo = root.TryGetProperty("deck_no", out var dno) ? dno.GetInt32() : 0,
|
||||
DeckName = root.TryGetProperty("deck_name", out var dn) ? dn.GetString() ?? "" : "",
|
||||
ClassId = root.TryGetProperty("class_id", out var ci) ? ci.GetInt32() : 0,
|
||||
SleeveId = root.TryGetProperty("sleeve_id", out var si) ? si.GetInt64() : 3_000_011L,
|
||||
LeaderSkinId = root.TryGetProperty("leader_skin_id", out var ls) ? ls.GetInt32() : 0,
|
||||
CardIdArray = cardIdArray,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? ParseJsonElementOrNull(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using MessagePack;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape for a single entry in the deck_log bucket array, and for the inline
|
||||
/// `deck` object on a DECK-type chat message. Matches ChatMessageInfo.DeckLogData
|
||||
/// shape (Wizard/ChatMessageInfo.cs:DeckLogData).
|
||||
///
|
||||
/// Keys are a strict subset of what DeckData.Initialize() consumes:
|
||||
/// deck_format, message_id, delete_permission_exists, deck_no, deck_name,
|
||||
/// class_id, card_id_array, sleeve_id, leader_skin_id.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class DeckLogDataDto
|
||||
{
|
||||
/// <summary>API-side Format integer (via FormatJsonConverter).</summary>
|
||||
[JsonPropertyName("deck_format"), Key("deck_format")]
|
||||
[JsonConverter(typeof(FormatJsonConverter))]
|
||||
public Format DeckFormat { get; set; }
|
||||
|
||||
/// <summary>Per-guild monotonic message_id of the DECK chat message that shared this deck.</summary>
|
||||
[JsonPropertyName("message_id"), Key("message_id")]
|
||||
[JsonConverter(typeof(StringifiedIntConverter))]
|
||||
public int MessageId { get; set; }
|
||||
|
||||
/// <summary>Whether the viewing user may call /guild_chat/delete_deck for this entry.</summary>
|
||||
[JsonPropertyName("delete_permission_exists"), Key("delete_permission_exists")]
|
||||
public bool DeletePermissionExists { get; set; }
|
||||
|
||||
/// <summary>Deck slot number (within the format's personal deck list).</summary>
|
||||
[JsonPropertyName("deck_no"), Key("deck_no")]
|
||||
[JsonConverter(typeof(StringifiedIntConverter))]
|
||||
public int DeckNo { get; set; }
|
||||
|
||||
/// <summary>Deck name as it was at share-time.</summary>
|
||||
[JsonPropertyName("deck_name"), Key("deck_name")]
|
||||
public string DeckName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Class/craft id.</summary>
|
||||
[JsonPropertyName("class_id"), Key("class_id")]
|
||||
[JsonConverter(typeof(StringifiedIntConverter))]
|
||||
public int ClassId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Flat card list — card ids repeated by count, as DeckData.ParseCardIdList expects.
|
||||
/// </summary>
|
||||
[JsonPropertyName("card_id_array"), Key("card_id_array"),
|
||||
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public List<long> CardIdArray { get; set; } = new();
|
||||
|
||||
/// <summary>Sleeve id at share-time.</summary>
|
||||
[JsonPropertyName("sleeve_id"), Key("sleeve_id")]
|
||||
[JsonConverter(typeof(StringifiedLongConverter))]
|
||||
public long SleeveId { get; set; }
|
||||
|
||||
/// <summary>Leader skin id at share-time.</summary>
|
||||
[JsonPropertyName("leader_skin_id"), Key("leader_skin_id")]
|
||||
[JsonConverter(typeof(StringifiedIntConverter))]
|
||||
public int LeaderSkinId { get; set; }
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||
|
||||
/// <summary>
|
||||
/// Response for POST /guild_chat/deck_log.
|
||||
/// deck_log keys are stringified API-side Format ints (e.g. "1", "2").
|
||||
/// TODO(task-17): replace deck_log with typed DTOs when DeckLogData shape is fully specified.
|
||||
/// Crossover/MyRotation keys must be OMITTED (not empty arrays) when those formats are disabled.
|
||||
/// deck_log keys are stringified API-side Format ints (e.g. "1", "2", "3").
|
||||
/// Rotation / Unlimited / PreRotation buckets are always present.
|
||||
/// Crossover / MyRotation keys are included only when non-empty (client uses TryGetValue).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class GuildChatDeckLogResponse
|
||||
@@ -19,8 +20,9 @@ public class GuildChatDeckLogResponse
|
||||
|
||||
/// <summary>
|
||||
/// Decks shared in chat, bucketed by API-side Format value (stringified int keys).
|
||||
/// JsonElement pass-through for now — Task 17 will type the inner DeckLogEntry shape.
|
||||
/// Keys present: "1" (Rotation), "2" (Unlimited), "3" (PreRotation); optionally "4" (Crossover), "5" (MyRotation).
|
||||
/// </summary>
|
||||
[JsonPropertyName("deck_log"), Key("deck_log")]
|
||||
public JsonElement? DeckLog { get; set; }
|
||||
[JsonPropertyName("deck_log"), Key("deck_log"),
|
||||
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public Dictionary<string, List<DeckLogDataDto>> DeckLog { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Guild;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||
|
||||
/// <summary>
|
||||
/// Response for POST /guild_chat/delete_deck.
|
||||
/// Returns refreshed deck_log after deletion.
|
||||
/// TODO(task-17): replace deck_log with typed DeckLogData DTOs when deck-log shape is fully documented.
|
||||
/// Returns the refreshed deck_log after deletion — same shape as /guild_chat/deck_log.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class GuildChatDeleteDeckResponse
|
||||
@@ -17,9 +17,10 @@ public class GuildChatDeleteDeckResponse
|
||||
public List<int> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Refreshed deck log keyed by API-side Format int (stringified, e.g. "1", "2").
|
||||
/// Using JsonElement for now; Task 17 will type this properly.
|
||||
/// Refreshed deck log keyed by API-side Format int (stringified, e.g. "1", "2", "3").
|
||||
/// Crossover ("4") / MyRotation ("5") included only when non-empty.
|
||||
/// </summary>
|
||||
[JsonPropertyName("deck_log"), Key("deck_log")]
|
||||
public JsonElement? DeckLog { get; set; }
|
||||
[JsonPropertyName("deck_log"), Key("deck_log"),
|
||||
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public Dictionary<string, List<DeckLogDataDto>> DeckLog { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.GuildChat;
|
||||
/// <summary>
|
||||
/// Response for POST /guild_chat/replay_detail.
|
||||
/// Shape is opaque pending replay-subsystem documentation.
|
||||
/// TODO(task-17): enumerate fields from Wizard/ReplayDetailInfo.cs when replay subsystem is documented.
|
||||
/// TODO(post-merge): enumerate fields from Wizard/ReplayDetailInfo.cs when replay subsystem is documented.
|
||||
/// Using JsonElement pass-through so the stored ReplayPayload JSON is forwarded verbatim.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class GuildChatReplayDetailResponse
|
||||
{
|
||||
// Placeholder — replay detail shape is undocumented until we capture a live trace.
|
||||
// Using JsonElement pass-through for now.
|
||||
[JsonPropertyName("_todo"), Key("_todo")]
|
||||
public JsonElement? Placeholder { get; set; }
|
||||
/// <summary>
|
||||
/// Opaque replay payload forwarded verbatim from the stored ReplayPayload JSON.
|
||||
/// TODO(post-merge): type this properly once replay subsystem shape is captured.
|
||||
/// </summary>
|
||||
[JsonPropertyName("replay_info"), Key("replay_info")]
|
||||
public JsonElement? ReplayInfo { get; set; }
|
||||
}
|
||||
|
||||
416
SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs
Normal file
416
SVSim.UnitTests/Integration/Guild/GuildChatAttachmentTests.cs
Normal file
@@ -0,0 +1,416 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using SVSim.Database.Entities.Guild;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Integration.Guild;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the five guild_chat attachment endpoints:
|
||||
/// POST /guild_chat/add_deck, /delete_deck, /add_replay, /replay_detail, /deck_log
|
||||
/// </summary>
|
||||
public class GuildChatAttachmentTests
|
||||
{
|
||||
private const string Vid = "0";
|
||||
private const int Sid = 0;
|
||||
private const string Stk = "";
|
||||
|
||||
private static object BaseBody() => new { viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk };
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<int> CreateGuildAsync(HttpClient client, string name)
|
||||
{
|
||||
var createResp = await client.PostAsync("/guild/create",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
guild_name = name, activity = 1, join_condition = 1,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
Assert.That(createResp.IsSuccessStatusCode, Is.True,
|
||||
$"create guild failed: {await createResp.Content.ReadAsStringAsync()}");
|
||||
|
||||
var infoResp = await client.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||
var infoJson = await infoResp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(infoJson);
|
||||
return GetStringifiedInt(doc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a member to the guild using FREE join (join_condition=1).
|
||||
/// The guild must have been created with join_condition=1 for this to succeed directly.
|
||||
/// </summary>
|
||||
private static async Task AddMemberToGuildAsync(
|
||||
SVSimTestFactory factory, long leaderId, long memberId, int guildId, string guildName)
|
||||
{
|
||||
// Get the actual guild_id by querying the leader's guild info.
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
var infoResp = await leaderClient.PostAsync("/guild/info", JsonContent.Create(BaseBody()));
|
||||
var infoBody = await infoResp.Content.ReadAsStringAsync();
|
||||
using var infoDoc = JsonDocument.Parse(infoBody);
|
||||
int actualGuildId = GetStringifiedInt(infoDoc.RootElement.GetProperty("guild").GetProperty("detail"), "guild_id");
|
||||
|
||||
// Member joins directly (FREE guild, join_condition=1).
|
||||
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||
var joinResp = await memberClient.PostAsync("/guild/join",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
guild_id = actualGuildId,
|
||||
from_invite = false,
|
||||
viewer_id = memberId.ToString(), steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
var joinBody = await joinResp.Content.ReadAsStringAsync();
|
||||
Assert.That(joinResp.IsSuccessStatusCode, Is.True, $"guild/join failed: {joinBody}");
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, string body)> AddDeckAsync(HttpClient client, int deckFormat, int deckNo)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/add_deck",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
deck_format = deckFormat,
|
||||
deck_no = deckNo,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, string body)> DeleteDeckAsync(HttpClient client, int deckFormat, long messageId)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/delete_deck",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
deck_format = deckFormat,
|
||||
message_id = messageId.ToString(),
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, string body)> AddReplayAsync(HttpClient client, long battleId)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/add_replay",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
battle_id = battleId,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
return (resp.IsSuccessStatusCode, await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, JsonElement root)> DeckLogAsync(HttpClient client)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/deck_log", JsonContent.Create(BaseBody()));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return (resp.IsSuccessStatusCode, doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
private static async Task<(bool ok, JsonElement root)> ReplayDetailAsync(HttpClient client, long messageId)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/replay_detail",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
message_id = messageId.ToString(),
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
return (resp.IsSuccessStatusCode, doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
private static async Task<long> GetLastMessageIdAsync(HttpClient client)
|
||||
{
|
||||
var resp = await client.PostAsync("/guild_chat/messages",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
start_message_id = 0, direction = 1, wait_interval = 3,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var msgs = doc.RootElement.GetProperty("chat_message");
|
||||
long max = 0;
|
||||
for (int i = 0; i < msgs.GetArrayLength(); i++)
|
||||
max = Math.Max(max, GetStringifiedLong(msgs[i], "message_id"));
|
||||
return max;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 1: add_deck — creates DECK row with DeckPayload; appears in deck_log
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task AddDeck_creates_Deck_message_and_appears_in_deck_log()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_001UL, "AttLeader1");
|
||||
await factory.SeedDeckAsync(leaderId, Format.Rotation, number: 1, name: "MyDeck");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild1");
|
||||
|
||||
var (ok, body) = await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1);
|
||||
Assert.That(ok, Is.True, $"add_deck should return 200; got: {body}");
|
||||
|
||||
// deck_log should now include an entry in bucket "1" (Rotation)
|
||||
var (logOk, logRoot) = await DeckLogAsync(leaderClient);
|
||||
Assert.That(logOk, Is.True, "deck_log request should succeed");
|
||||
Assert.That(logRoot.TryGetProperty("deck_log", out var deckLogEl), Is.True, "deck_log must be present");
|
||||
Assert.That(deckLogEl.TryGetProperty("1", out var rotBucket), Is.True, "Rotation bucket '1' must exist");
|
||||
Assert.That(rotBucket.GetArrayLength(), Is.EqualTo(1), "One entry in Rotation bucket");
|
||||
|
||||
var entry = rotBucket[0];
|
||||
Assert.That(entry.GetProperty("deck_name").GetString(), Is.EqualTo("MyDeck"), "deck_name must match");
|
||||
Assert.That(entry.TryGetProperty("message_id", out _), Is.True, "entry must have message_id");
|
||||
Assert.That(entry.TryGetProperty("delete_permission_exists", out var dpEl), Is.True);
|
||||
Assert.That(dpEl.GetBoolean(), Is.True, "Author can delete own deck");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 2: add_deck — non-member returns error
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task AddDeck_non_member_returns_error()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_002UL, "AttLeader2");
|
||||
long outsiderId = await factory.SeedViewerAsync(76_561_198_700_000_003UL, "AttOutsider2");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
using var outsiderClient = factory.CreateAuthenticatedClient(outsiderId);
|
||||
|
||||
await factory.SeedDeckAsync(outsiderId, Format.Rotation, 1);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild2");
|
||||
|
||||
var (_, body) = await AddDeckAsync(outsiderClient, deckFormat: 1, deckNo: 1);
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True);
|
||||
Assert.That(rc.GetInt32(), Is.EqualTo(2), "Non-member must get result_code=2");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 3: delete_deck (author) — clears payload, row stays, deck_log shrinks
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task DeleteDeck_by_author_clears_payload_and_refreshes_log()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_004UL, "AttLeader3");
|
||||
await factory.SeedDeckAsync(leaderId, Format.Rotation, 1, "DeleteMe");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild3");
|
||||
|
||||
var (addOk, _) = await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1);
|
||||
Assert.That(addOk, Is.True);
|
||||
|
||||
// Get message_id of the DECK row
|
||||
long messageId = await GetLastMessageIdAsync(leaderClient);
|
||||
|
||||
// Delete
|
||||
var (delOk, delBody) = await DeleteDeckAsync(leaderClient, deckFormat: 1, messageId);
|
||||
Assert.That(delOk, Is.True, $"delete_deck should succeed; got: {delBody}");
|
||||
using var delDoc = JsonDocument.Parse(delBody);
|
||||
// Response must have deck_log + maintenance_card_list
|
||||
Assert.That(delDoc.RootElement.TryGetProperty("deck_log", out var afterLog), Is.True,
|
||||
"delete_deck must return refreshed deck_log");
|
||||
// The Rotation bucket should now be empty
|
||||
Assert.That(afterLog.TryGetProperty("1", out var rotBucket), Is.True);
|
||||
Assert.That(rotBucket.GetArrayLength(), Is.EqualTo(0), "Deck should be removed from log after delete");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 4: delete_deck (leader on someone else's deck) — success
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task DeleteDeck_by_leader_on_member_deck_succeeds()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_005UL, "AttLeader4");
|
||||
long memberId = await factory.SeedViewerAsync(76_561_198_700_000_006UL, "AttMember4");
|
||||
|
||||
await factory.SeedDeckAsync(memberId, Format.Rotation, 1, "MemberDeck");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
using var memberClient = factory.CreateAuthenticatedClient(memberId);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "AttGuild4");
|
||||
await AddMemberToGuildAsync(factory, leaderId, memberId, 0, "AttGuild4");
|
||||
|
||||
var (addOk, _) = await AddDeckAsync(memberClient, deckFormat: 1, deckNo: 1);
|
||||
Assert.That(addOk, Is.True, "Member should be able to add deck");
|
||||
|
||||
long messageId = await GetLastMessageIdAsync(leaderClient);
|
||||
|
||||
// Leader deletes member's deck
|
||||
var (delOk, delBody) = await DeleteDeckAsync(leaderClient, deckFormat: 1, messageId);
|
||||
Assert.That(delOk, Is.True, $"Leader delete should succeed; got: {delBody}");
|
||||
using var doc = JsonDocument.Parse(delBody);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out _), Is.False,
|
||||
"Successful delete must NOT return result_code");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 5: delete_deck (regular member on someone else's deck) — rejected
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task DeleteDeck_by_regular_member_on_other_deck_rejected()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_007UL, "AttLeader5");
|
||||
long member1Id = await factory.SeedViewerAsync(76_561_198_700_000_008UL, "AttMember5a");
|
||||
long member2Id = await factory.SeedViewerAsync(76_561_198_700_000_009UL, "AttMember5b");
|
||||
|
||||
await factory.SeedDeckAsync(member1Id, Format.Rotation, 1, "Member1Deck");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
using var member1Client = factory.CreateAuthenticatedClient(member1Id);
|
||||
using var member2Client = factory.CreateAuthenticatedClient(member2Id);
|
||||
|
||||
await CreateGuildAsync(leaderClient, "AttGuild5");
|
||||
await AddMemberToGuildAsync(factory, leaderId, member1Id, 0, "AttGuild5");
|
||||
await AddMemberToGuildAsync(factory, leaderId, member2Id, 0, "AttGuild5");
|
||||
|
||||
var (addOk, _) = await AddDeckAsync(member1Client, deckFormat: 1, deckNo: 1);
|
||||
Assert.That(addOk, Is.True, "Member1 should be able to add deck");
|
||||
|
||||
long messageId = await GetLastMessageIdAsync(leaderClient);
|
||||
|
||||
// Member2 tries to delete member1's deck — must fail
|
||||
var (delOk, delBody) = await DeleteDeckAsync(member2Client, deckFormat: 1, messageId);
|
||||
Assert.That(delOk, Is.True, "HTTP should be 200 even for logic rejection");
|
||||
using var doc = JsonDocument.Parse(delBody);
|
||||
Assert.That(doc.RootElement.TryGetProperty("result_code", out var rc), Is.True,
|
||||
"Rejected delete must return result_code");
|
||||
Assert.That(rc.GetInt32(), Is.EqualTo(2), "result_code must be 2 for permission denied");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 6: add_replay — creates REPLAY row; replay_detail returns payload
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task AddReplay_creates_replay_message_and_replay_detail_returns_payload()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_010UL, "AttLeader6");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild6");
|
||||
|
||||
const long battleId = 999_888_777L;
|
||||
var (addOk, addBody) = await AddReplayAsync(leaderClient, battleId);
|
||||
Assert.That(addOk, Is.True, $"add_replay should return 200; got: {addBody}");
|
||||
|
||||
// The REPLAY message should appear in the chat feed
|
||||
var msgResp = await leaderClient.PostAsync("/guild_chat/messages",
|
||||
JsonContent.Create(new
|
||||
{
|
||||
start_message_id = 0, direction = 1, wait_interval = 3,
|
||||
viewer_id = Vid, steam_id = Sid, steam_session_ticket = Stk
|
||||
}));
|
||||
var msgBody = await msgResp.Content.ReadAsStringAsync();
|
||||
using var msgDoc = JsonDocument.Parse(msgBody);
|
||||
var msgs = msgDoc.RootElement.GetProperty("chat_message");
|
||||
|
||||
bool foundReplay = false;
|
||||
long replayMsgId = 0;
|
||||
for (int i = 0; i < msgs.GetArrayLength(); i++)
|
||||
{
|
||||
int msgType = GetStringifiedInt(msgs[i], "message_type");
|
||||
if (msgType == (int)GuildChatMessageType.Replay)
|
||||
{
|
||||
foundReplay = true;
|
||||
replayMsgId = GetStringifiedLong(msgs[i], "message_id");
|
||||
}
|
||||
}
|
||||
Assert.That(foundReplay, Is.True, "A Replay-type message must appear in chat");
|
||||
Assert.That(replayMsgId, Is.GreaterThan(0), "Replay message must have a valid message_id");
|
||||
|
||||
// replay_detail should return the stored payload
|
||||
var (detailOk, detailRoot) = await ReplayDetailAsync(leaderClient, replayMsgId);
|
||||
Assert.That(detailOk, Is.True, "replay_detail should return 200");
|
||||
// Should NOT have result_code (error marker)
|
||||
Assert.That(detailRoot.TryGetProperty("result_code", out _), Is.False,
|
||||
"Successful replay_detail must not have result_code");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 7: deck_log — always-present Rotation/Unlimited/PreRotation buckets
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task DeckLog_always_returns_rotation_unlimited_prerotation_buckets()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_011UL, "AttLeader7");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild7");
|
||||
|
||||
var (ok, root) = await DeckLogAsync(leaderClient);
|
||||
Assert.That(ok, Is.True);
|
||||
Assert.That(root.TryGetProperty("deck_log", out var deckLogEl), Is.True);
|
||||
|
||||
// Buckets "1", "2", "3" must always be present (even when empty)
|
||||
Assert.That(deckLogEl.TryGetProperty("1", out var rot), Is.True, "Rotation bucket '1' must always exist");
|
||||
Assert.That(deckLogEl.TryGetProperty("2", out var unl), Is.True, "Unlimited bucket '2' must always exist");
|
||||
Assert.That(deckLogEl.TryGetProperty("3", out var pre), Is.True, "PreRotation bucket '3' must always exist");
|
||||
|
||||
Assert.That(rot.GetArrayLength(), Is.EqualTo(0), "Rotation bucket empty for fresh guild");
|
||||
Assert.That(unl.GetArrayLength(), Is.EqualTo(0), "Unlimited bucket empty");
|
||||
Assert.That(pre.GetArrayLength(), Is.EqualTo(0), "PreRotation bucket empty");
|
||||
|
||||
// Crossover ("4") and MyRotation ("5") must NOT be present when empty
|
||||
Assert.That(deckLogEl.TryGetProperty("4", out _), Is.False, "Crossover bucket must be absent when empty");
|
||||
Assert.That(deckLogEl.TryGetProperty("5", out _), Is.False, "MyRotation bucket must be absent when empty");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Test 8: deck_log — multiple formats go into correct buckets
|
||||
// =========================================================================
|
||||
|
||||
[Test]
|
||||
public async Task DeckLog_multiple_formats_go_into_correct_buckets()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long leaderId = await factory.SeedViewerAsync(76_561_198_700_000_012UL, "AttLeader8");
|
||||
|
||||
await factory.SeedDeckAsync(leaderId, Format.Rotation, number: 1, name: "RotDeck");
|
||||
await factory.SeedDeckAsync(leaderId, Format.Unlimited, number: 1, name: "UnlDeck");
|
||||
await factory.SeedDeckAsync(leaderId, Format.PreRotation, number: 1, name: "PreDeck");
|
||||
|
||||
using var leaderClient = factory.CreateAuthenticatedClient(leaderId);
|
||||
await CreateGuildAsync(leaderClient, "AttGuild8");
|
||||
|
||||
// Share all three
|
||||
await AddDeckAsync(leaderClient, deckFormat: 1, deckNo: 1); // Rotation
|
||||
await AddDeckAsync(leaderClient, deckFormat: 2, deckNo: 1); // Unlimited
|
||||
await AddDeckAsync(leaderClient, deckFormat: 3, deckNo: 1); // PreRotation
|
||||
|
||||
var (ok, root) = await DeckLogAsync(leaderClient);
|
||||
Assert.That(ok, Is.True);
|
||||
var deckLogEl = root.GetProperty("deck_log");
|
||||
|
||||
Assert.That(deckLogEl.GetProperty("1").GetArrayLength(), Is.EqualTo(1), "Rotation bucket has 1 entry");
|
||||
Assert.That(deckLogEl.GetProperty("2").GetArrayLength(), Is.EqualTo(1), "Unlimited bucket has 1 entry");
|
||||
Assert.That(deckLogEl.GetProperty("3").GetArrayLength(), Is.EqualTo(1), "PreRotation bucket has 1 entry");
|
||||
|
||||
// Check deck names
|
||||
Assert.That(deckLogEl.GetProperty("1")[0].GetProperty("deck_name").GetString(), Is.EqualTo("RotDeck"));
|
||||
Assert.That(deckLogEl.GetProperty("2")[0].GetProperty("deck_name").GetString(), Is.EqualTo("UnlDeck"));
|
||||
Assert.That(deckLogEl.GetProperty("3")[0].GetProperty("deck_name").GetString(), Is.EqualTo("PreDeck"));
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,11 @@ internal sealed class SpyGuildChatService : IGuildChatService
|
||||
// ---- stub out the rest -----------------------------------------------
|
||||
public Task<ChatWindow> GetWindowAsync(long v, int s, int d, int w, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<ChatPostResult> PostTextOrStampAsync(long v, int t, string m, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<ChatPostResult> PostDeckAsync(long v, string j, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<bool> DeleteDeckAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<ChatPostResult> PostReplayAsync(long v, string j, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<ChatPostResult> PostDeckAsync(long v, SVSim.Database.Enums.Format f, int fa, int dn, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<(bool Ok, DeckLogResult? Log)> DeleteDeckAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<ChatPostResult> PostReplayAsync(long v, long battleId, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<string?> GetReplayDetailAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<string?> GetDeckLogAsync(long v, int id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
public Task<DeckLogResult?> GetDeckLogAsync(long v, CancellationToken ct = default) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user