feat(bp): /battle_pass/info — service + controller + 3 tests

Also fixes BattlePassRepository.GetActiveSeasonAsync to use client-side
DateTimeOffset filtering (SQLite provider cannot translate DateTimeOffset
comparisons in LINQ WHERE/ORDER BY clauses).
This commit is contained in:
gamer147
2026-05-26 23:14:26 -04:00
parent 6ed61ea9f1
commit 8a35f8c40b
5 changed files with 280 additions and 12 deletions

View File

@@ -18,11 +18,17 @@ public sealed class BattlePassRepository : IBattlePassRepository
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
{
return await _db.BattlePassSeasons
// Use UtcDateTime for the LINQ comparison so the query translates on both Postgres and
// SQLite. DateTimeOffset arithmetic in LINQ isn't supported by the SQLite provider;
// DateTime (UTC) is stored and compared as ISO-8601 text which SQLite handles fine.
var utcNow = when.UtcDateTime;
var candidates = await _db.BattlePassSeasons
.AsNoTracking()
.Where(s => s.StartDate <= when && s.EndDate > when)
.ToListAsync(ct);
return candidates
.Where(s => s.StartDate.UtcDateTime <= utcNow && s.EndDate.UtcDateTime > utcNow)
.OrderByDescending(s => s.StartDate)
.FirstOrDefaultAsync(ct);
.FirstOrDefault();
}
public Task<BattlePassSeasonEntry?> GetSeasonAsync(int seasonId, CancellationToken ct) =>

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /battle_pass/* — season metadata, premium-pass purchase. Wire shapes mirror
/// Wizard/BattlePass{Info,PurchaseInfo,Buy}Task.cs.
/// </summary>
[Route("battle_pass")]
public class BattlePassController : SVSimController
{
private readonly IBattlePassService _battlePass;
public BattlePassController(IBattlePassService battlePass)
{
_battlePass = battlePass;
}
[HttpPost("info")]
public async Task<IActionResult> Info(BaseRequest request, CancellationToken ct)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var info = await _battlePass.GetInfoAsync(viewerId, ct);
if (info is null) return Ok(new { }); // off-season: empty payload
return Ok(info);
}
}

View File

@@ -1,16 +1,33 @@
using System.Globalization;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BattlePass;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
namespace SVSim.EmulatedEntrypoint.Services;
public sealed class BattlePassService : IBattlePassService
{
private readonly IBattlePassRepository _bp;
// Default cap mirrors the captured /battle_pass/info.gauge_info.weekly_limit_point.
public const int WeeklyLimitPointDefault = 3000;
public BattlePassService(IBattlePassRepository bp)
private readonly IBattlePassRepository _bp;
private readonly IViewerBattlePassRepository _viewerBp;
private readonly TimeProvider _time;
private readonly SVSimDbContext _db;
public BattlePassService(
IBattlePassRepository bp,
IViewerBattlePassRepository viewerBp,
TimeProvider time,
SVSimDbContext db)
{
_bp = bp;
_viewerBp = viewerBp;
_time = time;
_db = db;
}
public async Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct)
@@ -18,11 +35,97 @@ public sealed class BattlePassService : IBattlePassService
var rows = await _bp.GetLevelCurveAsync(ct);
if (rows.Count == 0) return null;
return rows.ToDictionary(
r => r.Level.ToString(CultureInfo.InvariantCulture),
r => new BattlePassLevel
{
Level = r.Level.ToString(CultureInfo.InvariantCulture),
RequiredPoint = r.RequiredPoint.ToString(CultureInfo.InvariantCulture),
});
r => Inv(r.Level),
r => new BattlePassLevel { Level = Inv(r.Level), RequiredPoint = Inv(r.RequiredPoint) });
}
public async Task<BattlePassInfoResponse?> GetInfoAsync(long viewerId, CancellationToken ct)
{
var now = _time.GetUtcNow();
var season = await _bp.GetActiveSeasonAsync(now, ct);
if (season is null) return null;
var progress = await _viewerBp.GetOrCreateProgressAsync(viewerId, season.Id, ct);
// Persist the lazy-created row so concurrent /info calls don't try to create twice.
await _db.SaveChangesAsync(ct);
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);
return new BattlePassInfoResponse
{
SeasonInfo = new BattlePassSeasonInfoDto
{
Id = Inv(season.Id),
SeasonName = season.Name,
MaxLevel = Inv(season.MaxLevel),
StartDate = FormatWireDate(season.StartDate),
EndDate = FormatWireDate(season.EndDate),
CanPurchase = season.CanPurchase,
},
RewardInfo = new BattlePassRewardInfoDto
{
Normal = new BattlePassRewardListDto
{
Reward = rewards.Where(r => r.Track == BattlePassTrack.Normal)
.Select(r => ToRewardDto(r, claimSet))
.ToList(),
},
Premium = new BattlePassRewardListDto
{
Reward = rewards.Where(r => r.Track == BattlePassTrack.Premium)
.Select(r => ToRewardDto(r, claimSet))
.ToList(),
},
},
GaugeInfo = new BattlePassGaugeInfoDto
{
CurrentPoint = Inv(progress.CurrentPoint),
CurrentLevel = Inv(currentLevel),
WeeklyBattlePassPoint = progress.WeeklyPoints,
WeeklyLimitPoint = WeeklyLimitPointDefault,
},
PremiumAppealLevel = null, // populated when premium_appeal config is wired (future)
};
}
internal static int ComputeLevel(IReadOnlyList<BattlePassLevelEntry> curve, int point)
{
if (curve.Count == 0) return 1;
int level = curve[0].Level;
foreach (var row in curve)
{
if (point >= row.RequiredPoint) level = row.Level;
else break;
}
return level;
}
private static BattlePassRewardDto ToRewardDto(BattlePassRewardEntry r, HashSet<(BattlePassTrack, int)> claimSet)
{
return new BattlePassRewardDto
{
RewardLevel = Inv(r.Level),
RewardType = Inv(r.RewardType),
RewardDetailId = Inv(r.RewardDetailId),
RewardNumber = Inv(r.RewardNumber),
IsReceived = claimSet.Contains((r.Track, r.Level)),
IsAppealExclusion = r.Track == BattlePassTrack.Premium
? (r.IsAppealExclusion ? "1" : "0")
: null,
};
}
private static string FormatWireDate(DateTimeOffset dt) =>
// Capture format is "2026-04-01 02:00:00" (JST, space-separated). Emit in same shape
// in JST so the client gets back what it gave.
dt.ToOffset(TimeSpan.FromHours(9))
.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
private static string Inv(long v) => v.ToString(CultureInfo.InvariantCulture);
private static string Inv(int v) => v.ToString(CultureInfo.InvariantCulture);
}

View File

@@ -1,4 +1,5 @@
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.BattlePass;
namespace SVSim.EmulatedEntrypoint.Services;
@@ -7,5 +8,9 @@ public interface IBattlePassService
/// <summary>Global level curve as wire-string dictionary; null if no levels seeded.</summary>
Task<IReadOnlyDictionary<string, BattlePassLevel>?> GetLevelCurveAsync(CancellationToken ct);
// The Info / ItemList / Buy / AddPoints methods are added in later tasks (11, 12, 13, 14).
/// <summary>
/// /battle_pass/info payload. Returns null when no active season window covers <c>now</c>
/// (controller emits empty body in that case).
/// </summary>
Task<BattlePassInfoResponse?> GetInfoAsync(long viewerId, CancellationToken ct);
}

View File

@@ -0,0 +1,123 @@
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 BattlePassControllerInfoTests
{
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
private const string EmptyAuthBody = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
private static async Task SeedSeason23WithRewards(SVSimTestFactory f)
{
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 = "test",
});
db.BattlePassRewards.Add(new BattlePassRewardEntry
{
Id = 23 * 10_000L + 0 * 1_000 + 2, // MakeId(23, Normal=0, 2)
SeasonId = 23, Track = BattlePassTrack.Normal, Level = 2, RewardType = 9,
RewardDetailId = 0, RewardNumber = 50, IsAppealExclusion = false,
});
db.BattlePassRewards.Add(new BattlePassRewardEntry
{
Id = 23 * 10_000L + 1 * 1_000 + 2, // MakeId(23, Premium=1, 2)
SeasonId = 23, Track = BattlePassTrack.Premium, Level = 2, RewardType = 9,
RewardDetailId = 0, RewardNumber = 20, IsAppealExclusion = false,
});
await db.SaveChangesAsync();
}
[Test]
public async Task Info_returns_season_with_zero_progress_for_fresh_viewer()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedSeason23WithRewards(factory);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.GetProperty("season_info").GetProperty("id").GetString(), Is.EqualTo("23"));
Assert.That(root.GetProperty("season_info").GetProperty("max_level").GetString(), Is.EqualTo("100"));
Assert.That(root.GetProperty("season_info").GetProperty("can_purchase").GetBoolean(), Is.True);
Assert.That(root.GetProperty("gauge_info").GetProperty("current_point").GetString(), Is.EqualTo("0"));
Assert.That(root.GetProperty("gauge_info").GetProperty("current_level").GetString(), Is.EqualTo("1"));
var normalRewards = root.GetProperty("reward_info").GetProperty("normal").GetProperty("reward");
Assert.That(normalRewards.GetArrayLength(), Is.EqualTo(1));
Assert.That(normalRewards[0].GetProperty("reward_level").GetString(), Is.EqualTo("2"));
Assert.That(normalRewards[0].GetProperty("is_received").GetBoolean(), Is.False);
}
[Test]
public async Task Info_marks_claimed_rewards_as_received()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
await SeedSeason23WithRewards(factory);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.ViewerBattlePassClaims.Add(new ViewerBattlePassClaimEntry
{
ViewerId = viewerId, SeasonId = 23, Track = BattlePassTrack.Normal,
Level = 2, ClaimedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var normalReward = doc.RootElement
.GetProperty("reward_info").GetProperty("normal").GetProperty("reward")[0];
var premiumReward = doc.RootElement
.GetProperty("reward_info").GetProperty("premium").GetProperty("reward")[0];
Assert.That(normalReward.GetProperty("is_received").GetBoolean(), Is.True);
Assert.That(premiumReward.GetProperty("is_received").GetBoolean(), Is.False);
}
[Test]
public async Task Info_returns_empty_payload_outside_any_season_window()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
// No season seeded.
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/battle_pass/info", JsonBody(EmptyAuthBody));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
Assert.That(body, Does.Not.Contain("season_info").Or.Contains("\"season_info\":null"),
"off-season response should omit or null season_info");
}
}