feat(bp): /battle_pass/buy — crystal-cost + retroactive premium grants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,4 +38,35 @@ public class BattlePassController : SVSimController
|
||||
if (list is null) return Ok(new { });
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpPost("buy")]
|
||||
public async Task<IActionResult> Buy(BattlePassBuyRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var outcome = await _battlePass.BuyPremiumAsync(viewerId, request.SeasonId, request.Id, ct);
|
||||
|
||||
var response = new BattlePassBuyResponse
|
||||
{
|
||||
ResultCode = outcome.ResultCode,
|
||||
AchievedInfo = new BattlePassAchievedInfoDto
|
||||
{
|
||||
BattlePassRewardList = outcome.AchievedRewards
|
||||
.Select(g => new BattlePassReceivedRewardDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardDetailId = g.RewardId,
|
||||
RewardNumber = g.RewardNum,
|
||||
}).ToList(),
|
||||
},
|
||||
RewardList = outcome.PostStateTotals
|
||||
.Select(g => new BattlePassRewardListEntryDto
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
}).ToList(),
|
||||
};
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
/// <summary>
|
||||
/// achieved_info wrapper inside /battle_pass/buy response
|
||||
/// (Wizard/BattlePassBuyTask.cs:37-40).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BattlePassAchievedInfoDto
|
||||
{
|
||||
[JsonPropertyName("battle_pass_reward_list")]
|
||||
[Key("battle_pass_reward_list")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public List<BattlePassReceivedRewardDto> BattlePassRewardList { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
/// <summary>
|
||||
/// /battle_pass/buy request (Wizard/BattlePassBuyTask.cs:8-13). Inherits viewer_id, steam_id,
|
||||
/// steam_session_ticket from BaseRequest. Per memory feedback_msgpack_request_dtos, the
|
||||
/// [MessagePackObject] + [Key] attrs are required even though integration tests post JSON.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BattlePassBuyRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("season_id")]
|
||||
[Key("season_id")]
|
||||
public int SeasonId { get; set; }
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
[Key("id")]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
/// <summary>
|
||||
/// /battle_pass/buy response (Wizard/BattlePassBuyTask.cs:30-52). result_code carries the
|
||||
/// envelope failure path: 22 = insufficient crystals, 23 = already premium,
|
||||
/// 24 = outside BP period / season mismatch.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BattlePassBuyResponse
|
||||
{
|
||||
[JsonPropertyName("result_code")]
|
||||
[Key("result_code")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int ResultCode { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("achieved_info")]
|
||||
[Key("achieved_info")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public BattlePassAchievedInfoDto AchievedInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public List<BattlePassRewardListEntryDto> RewardList { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in /battle_pass/buy → achieved_info.battle_pass_reward_list[]
|
||||
/// (Wizard/BattlePassBuyTask.cs:42-48). Delta entries; numerics are int here, not string-typed.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BattlePassReceivedRewardDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int RewardType { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public long RewardDetailId { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_number")]
|
||||
[Key("reward_number")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in /battle_pass/buy → reward_list[]. POST-STATE TOTALS for affected goods +
|
||||
/// currency, per memory project_wire_reward_list_post_state. Matches RewardListEntry shape
|
||||
/// used by /pack/open etc.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BattlePassRewardListEntryDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int RewardType { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_id")]
|
||||
[Key("reward_id")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public long RewardId { get; set; }
|
||||
|
||||
[JsonPropertyName("reward_num")]
|
||||
[Key("reward_num")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int RewardNum { get; set; }
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.BattlePass;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
|
||||
|
||||
@@ -20,17 +22,20 @@ public sealed class BattlePassService : IBattlePassService
|
||||
private readonly IViewerBattlePassRepository _viewerBp;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
|
||||
public BattlePassService(
|
||||
IBattlePassRepository bp,
|
||||
IViewerBattlePassRepository viewerBp,
|
||||
TimeProvider time,
|
||||
SVSimDbContext db)
|
||||
SVSimDbContext db,
|
||||
RewardGrantService rewards)
|
||||
{
|
||||
_bp = bp;
|
||||
_viewerBp = viewerBp;
|
||||
_time = time;
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
|
||||
@@ -132,6 +137,77 @@ public sealed class BattlePassService : IBattlePassService
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<BattlePassBuyOutcome> BuyPremiumAsync(
|
||||
long viewerId, int seasonId, int productId, CancellationToken ct)
|
||||
{
|
||||
var now = _time.GetUtcNow();
|
||||
var season = await _bp.GetActiveSeasonAsync(now, ct);
|
||||
|
||||
// 24: outside BP period, season mismatch, or season not currently purchasable.
|
||||
if (season is null || season.Id != seasonId || !season.CanPurchase)
|
||||
return new BattlePassBuyOutcome(24, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||
|
||||
if (productId != season.Id * 1000)
|
||||
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves).Include(v => v.Emblems).Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees).Include(v => v.MyPageBackgrounds).Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery() // per memory project_ef_split_query
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
if (viewer is null)
|
||||
return new BattlePassBuyOutcome(0, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||
|
||||
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
|
||||
if (progress.IsPremium)
|
||||
return new BattlePassBuyOutcome(23, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||
|
||||
if (viewer.Currency.Crystals < (ulong)season.PriceCrystal)
|
||||
return new BattlePassBuyOutcome(22, Array.Empty<GrantedReward>(), Array.Empty<GrantedReward>());
|
||||
|
||||
// BeginTransactionAsync is a no-op on the SQLite in-memory test DB but is safe to call.
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
viewer.Currency.Crystals -= (ulong)season.PriceCrystal;
|
||||
progress.IsPremium = true;
|
||||
|
||||
// Retroactive grants: every premium reward at level <= current_level not already claimed.
|
||||
var rewards = await _bp.GetSeasonRewardsAsync(season.Id, ct);
|
||||
var claims = await _viewerBp.GetClaimsAsync(viewerId, season.Id, ct);
|
||||
var claimSet = claims.Select(c => (c.Track, c.Level)).ToHashSet();
|
||||
|
||||
var curve = await _bp.GetLevelCurveAsync(ct);
|
||||
int currentLevel = ComputeLevel(curve, progress.CurrentPoint);
|
||||
|
||||
// achieved = delta list (the original reward spec amounts — what was just granted).
|
||||
// postState = post-state totals from RewardGrantService (what goes in reward_list).
|
||||
var achieved = new List<GrantedReward>();
|
||||
var postState = new List<GrantedReward>();
|
||||
foreach (var r in rewards.Where(r => r.Track == BattlePassTrack.Premium && r.Level <= currentLevel))
|
||||
{
|
||||
if (claimSet.Contains((r.Track, r.Level))) continue;
|
||||
_viewerBp.AddClaim(viewerId, season.Id, r.Track, r.Level, now);
|
||||
var granted = await _rewards.ApplyAsync(
|
||||
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber, ct);
|
||||
// achieved_info uses the original reward spec (delta), not post-state.
|
||||
achieved.Add(new GrantedReward(r.RewardType, r.RewardDetailId, r.RewardNumber));
|
||||
postState.AddRange(granted);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
|
||||
// Post-state reward_list must also include the crystal balance after the deduction.
|
||||
if (!postState.Any(r => r.RewardType == (int)UserGoodsType.Crystal))
|
||||
{
|
||||
postState.Add(new GrantedReward(
|
||||
(int)UserGoodsType.Crystal, 0, checked((int)viewer.Currency.Crystals)));
|
||||
}
|
||||
|
||||
return new BattlePassBuyOutcome(1, achieved, postState);
|
||||
}
|
||||
|
||||
internal static int ComputeLevel(IReadOnlyList<BattlePassLevelEntry> curve, int point)
|
||||
{
|
||||
if (curve.Count == 0) return 1;
|
||||
|
||||
@@ -19,4 +19,12 @@ public interface IBattlePassService
|
||||
/// array if the viewer already owns premium for the active season. Null when no active season.
|
||||
/// </summary>
|
||||
Task<BattlePassItemListResponse?> GetItemListAsync(long viewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Purchase premium for the active season. Validates season_id matches active season,
|
||||
/// product id derives from season, viewer has crystals, viewer isn't already premium.
|
||||
/// On success: deducts crystals, flips IsPremium, retroactively grants premium rewards for
|
||||
/// every level <= current_level not yet claimed. All-or-nothing transaction.
|
||||
/// </summary>
|
||||
Task<BattlePassBuyOutcome> BuyPremiumAsync(long viewerId, int seasonId, int productId, CancellationToken ct);
|
||||
}
|
||||
|
||||
184
SVSim.UnitTests/Controllers/BattlePassControllerBuyTests.cs
Normal file
184
SVSim.UnitTests/Controllers/BattlePassControllerBuyTests.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class BattlePassControllerBuyTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Reward Id formula matches BattlePassRewardImporter.MakeId — keep in sync.
|
||||
private static long MakeRewardId(int seasonId, BattlePassTrack track, int level)
|
||||
=> seasonId * 10_000L + (long)track * 1_000 + level;
|
||||
|
||||
private static async Task SeedSeason23WithPremiumReward(SVSimTestFactory f, long viewerId, ulong viewerCrystals, int currentPoint)
|
||||
{
|
||||
using var scope = f.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
if (await db.BattlePassLevels.CountAsync() == 0)
|
||||
{
|
||||
for (int i = 1; i <= 100; i++)
|
||||
db.BattlePassLevels.Add(new BattlePassLevelEntry { Level = i, RequiredPoint = (i - 1) * 500 });
|
||||
}
|
||||
db.BattlePassSeasons.Add(new BattlePassSeasonEntry
|
||||
{
|
||||
Id = 23, Name = "Season 23", MaxLevel = 100,
|
||||
StartDate = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
EndDate = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CanPurchase = true, PriceCrystal = 980, Description = "",
|
||||
});
|
||||
db.BattlePassRewards.Add(new BattlePassRewardEntry
|
||||
{
|
||||
Id = MakeRewardId(23, BattlePassTrack.Premium, 2),
|
||||
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
|
||||
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = viewerCrystals;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (currentPoint > 0)
|
||||
{
|
||||
var p = new ViewerBattlePassProgressEntry
|
||||
{
|
||||
ViewerId = viewerId, SeasonId = 23, CurrentPoint = currentPoint,
|
||||
IsPremium = false, WeeklyPoints = 0,
|
||||
};
|
||||
db.ViewerBattlePassProgress.Add(p);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_happy_path_at_level_1_deducts_crystals_and_emits_empty_achieved()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 1000, currentPoint: 0);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}""";
|
||||
var response = await client.PostAsync("/battle_pass/buy", JsonBody(req));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var achievedList = doc.RootElement.GetProperty("achieved_info").GetProperty("battle_pass_reward_list");
|
||||
Assert.That(achievedList.GetArrayLength(), Is.EqualTo(0),
|
||||
"level 1 → no premium rewards crossed");
|
||||
|
||||
bool foundCrystal = false;
|
||||
foreach (var el in doc.RootElement.GetProperty("reward_list").EnumerateArray())
|
||||
{
|
||||
if (el.GetProperty("reward_type").GetInt32() == 2)
|
||||
{
|
||||
foundCrystal = true;
|
||||
Assert.That(el.GetProperty("reward_num").GetInt32(), Is.EqualTo(20),
|
||||
"post-state crystal total = 1000 - 980");
|
||||
}
|
||||
}
|
||||
Assert.That(foundCrystal, Is.True, "reward_list must carry post-state crystal total");
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
var progress = await db.ViewerBattlePassProgress.SingleAsync(p => p.ViewerId == viewerId);
|
||||
Assert.That(v.Currency.Crystals, Is.EqualTo(20UL));
|
||||
Assert.That(progress.IsPremium, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_at_level_2_includes_retroactive_premium_reward_in_achieved()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 1000, currentPoint: 500); // level 2
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}""";
|
||||
var response = await client.PostAsync("/battle_pass/buy", JsonBody(req));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
|
||||
var achievedList = doc.RootElement.GetProperty("achieved_info").GetProperty("battle_pass_reward_list");
|
||||
Assert.That(achievedList.GetArrayLength(), Is.EqualTo(1), "premium level-2 reward must drop");
|
||||
Assert.That(achievedList[0].GetProperty("reward_type").GetInt32(), Is.EqualTo(9));
|
||||
Assert.That(achievedList[0].GetProperty("reward_number").GetInt32(), Is.EqualTo(20));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
bool claimWritten = await db.ViewerBattlePassClaims.AnyAsync(
|
||||
c => c.ViewerId == viewerId && c.SeasonId == 23
|
||||
&& c.Track == BattlePassTrack.Premium && c.Level == 2);
|
||||
Assert.That(claimWritten, Is.True, "claim row must be persisted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_with_insufficient_crystals_returns_22_and_makes_no_changes()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 100, currentPoint: 0);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}""";
|
||||
var response = await client.PostAsync("/battle_pass/buy", JsonBody(req));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(22));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Crystals, Is.EqualTo(100UL), "no crystal deduction on failure");
|
||||
bool anyProgress = await db.ViewerBattlePassProgress
|
||||
.AnyAsync(p => p.ViewerId == viewerId && p.IsPremium);
|
||||
Assert.That(anyProgress, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_when_already_premium_returns_23()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await SeedSeason23WithPremiumReward(factory, viewerId, viewerCrystals: 5000, currentPoint: 0);
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var p = await db.ViewerBattlePassProgress.SingleOrDefaultAsync(x => x.ViewerId == viewerId)
|
||||
?? new ViewerBattlePassProgressEntry { ViewerId = viewerId, SeasonId = 23 };
|
||||
p.IsPremium = true;
|
||||
if (p.Id == 0) db.ViewerBattlePassProgress.Add(p);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}""";
|
||||
var response = await client.PostAsync("/battle_pass/buy", JsonBody(req));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(23));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Buy_outside_period_returns_24()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// No season seeded → active-season lookup returns null.
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","season_id":23,"id":23000}""";
|
||||
var response = await client.PostAsync("/battle_pass/buy", JsonBody(req));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(24));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user