fix(shops): smoke-test fallout from today's shop-cluster ship
Two issues caught in a real-client smoke run against the freshly
bootstrapped DB:
1. NRE in ShadowverseTranslationMiddleware for parameterless actions.
Five new actions (Sleeve.Info, LeaderSkin.{Ids,Products},
ItemPurchase.Info, SpotCardExchange.Top) took no parameters, but
the middleware does
`endpointDescriptor.Parameters.FirstOrDefault().ParameterType`
to discover the request DTO — `FirstOrDefault` returns null on a
zero-param action and `.ParameterType` NREs. Tests didn't catch it
because the test client POSTs plain JSON, bypassing this path.
Fix: each action now takes `BaseRequest _` matching the codebase
convention (PuzzleController.Info, BattlePassController.Info, etc.),
plus the middleware throws an actionable
InvalidOperationException pointing at the convention so the next
contributor doesn't repeat the mistake.
2. Leader-skin set sale showed up as "FREE / Claim" with empty
Includes panel after the viewer bought every skin in a series
with no configured bonus items. Root cause: ComputeRewardStatus
emitted status=1 (not_got) when set_sales_status != 0 regardless
of whether rewards.items was empty, and SkinPurchaseInfoTask.
CreateSetSaleInfo flags `is_free=true` on (is_completed &&
not_got). Prod ships status=0 when items is empty even with
set_sales_status==1 — we now mirror that.
504 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
@@ -31,7 +32,7 @@ public class ItemPurchaseController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info()
|
||||
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info(BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.LeaderSkin;
|
||||
|
||||
@@ -76,7 +77,7 @@ public class LeaderSkinController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("ids")]
|
||||
public async Task<ActionResult<LeaderSkinIdsResponse>> Ids()
|
||||
public async Task<ActionResult<LeaderSkinIdsResponse>> Ids(BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
@@ -90,7 +91,7 @@ public class LeaderSkinController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("products")]
|
||||
public async Task<ActionResult<Dictionary<string, SkinSeriesDto>>> Products()
|
||||
public async Task<ActionResult<Dictionary<string, SkinSeriesDto>>> Products(BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
@@ -262,15 +263,24 @@ public class LeaderSkinController : SVSimController
|
||||
|
||||
/// <summary>
|
||||
/// Computes the per-viewer <c>rewards.status</c> for a series:
|
||||
/// 0=none — set_sales_status==0 (no set sale active)
|
||||
/// 1=not_got — series completed by viewer but bonus unclaimed
|
||||
/// 0=none — set_sales_status==0 OR no bonus items configured (matches prod, which ships
|
||||
/// status=0 for series where items[] is empty even when set_sales_status==1)
|
||||
/// 1=not_got — bonus exists, series completed by viewer, bonus unclaimed
|
||||
/// 2=got — viewer claimed the bonus
|
||||
/// 1 (effectively "available later") when set sale active but viewer hasn't completed it.
|
||||
/// 1 (effectively "available later") when set sale active with bonus and viewer hasn't
|
||||
/// completed the series.
|
||||
/// The 1/2 distinction matches the client enum (RewardStatus.not_got vs .got).
|
||||
/// <para>
|
||||
/// Important: emitting status=1 when items[] is empty triggers the client's
|
||||
/// <c>is_completed && not_got</c> branch in SkinPurchaseInfoTask.CreateSetSaleInfo,
|
||||
/// which marks the set sale as FREE and renders a useless "claim" button for a
|
||||
/// nonexistent bonus. Always return 0 when there's nothing to claim.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static int ComputeRewardStatus(LeaderSkinShopSeriesEntry series, bool seriesCompleted, bool claimed)
|
||||
{
|
||||
if (series.SetSalesStatus == 0) return 0;
|
||||
if (series.SetCompletionRewards.Count == 0) return 0;
|
||||
if (claimed) return 2;
|
||||
if (seriesCompleted) return 1;
|
||||
return 1;
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Sleeve;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class SleeveController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<SleeveInfoResponse>> Info()
|
||||
public async Task<ActionResult<SleeveInfoResponse>> Info(BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.SpotCardExchange;
|
||||
|
||||
@@ -38,7 +39,7 @@ public class SpotCardExchangeController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("top")]
|
||||
public async Task<ActionResult<SpotCardExchangeTopResponse>> Top()
|
||||
public async Task<ActionResult<SpotCardExchangeTopResponse>> Top(BaseRequest _)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
|
||||
@@ -100,7 +100,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware
|
||||
throw;
|
||||
}
|
||||
|
||||
Type requestType = endpointDescriptor.Parameters.FirstOrDefault().ParameterType;
|
||||
var firstParam = endpointDescriptor.Parameters.FirstOrDefault();
|
||||
if (firstParam is null)
|
||||
{
|
||||
// Action method has no parameters — middleware can't bind the (encrypted+msgpacked)
|
||||
// body to anything. The codebase convention is to take a BaseRequest even for body-
|
||||
// less endpoints (see e.g. PuzzleController.Info(BaseRequest _)). Fail loud with a
|
||||
// specific message rather than NREing below on .ParameterType.
|
||||
throw new InvalidOperationException(
|
||||
$"Action {endpointDescriptor.DisplayName} has no parameters; the SV translation " +
|
||||
"middleware needs at least one to bind the decrypted body. Add a BaseRequest parameter " +
|
||||
"(or a derived DTO) — see other *Info/*Top actions for the convention.");
|
||||
}
|
||||
Type requestType = firstParam.ParameterType;
|
||||
object? data;
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user