From c6259e5a14283b5852000e8fcec8f8e5b710378a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 10 Jun 2026 07:59:51 -0400 Subject: [PATCH] feat(replay): add ReplayController for /replay/{info,detail} /replay/info reads from ReplayHistoryReader, newest-first, capped at 50. /replay/detail returns 400 with result_code=99 - local cache is the canonical playback source so this endpoint is cache-miss fallback only. Co-Authored-By: Claude Opus 4.7 --- .../Controllers/ReplayController.cs | 68 ++++++++++++ .../Controllers/ReplayControllerTests.cs | 104 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs create mode 100644 SVSim.UnitTests/Controllers/ReplayControllerTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs b/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs new file mode 100644 index 0000000..db4faa6 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using Microsoft.AspNetCore.Mvc; +using SVSim.Database.Services.Replay; +using SVSim.EmulatedEntrypoint.Models.Dtos.Replay; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// Replay menu — recent-battles list + per-battle detail stub. +/// /replay/info returns up to 50 rows newest-first from ViewerBattleHistories. +/// /replay/detail returns 400 (result_code=99) — local cache is the canonical +/// playback source; this endpoint is only hit on cache miss, and we don't store +/// replay payloads. The client (ReplayDialogContent.GoReplay) aborts the scene +/// transition cleanly on non-success. +/// +[Route("replay")] +public sealed class ReplayController : SVSimController +{ + private const string TimeFormat = "yyyy-MM-dd HH:mm:ss"; + + private readonly IReplayHistoryReader _reader; + + public ReplayController(IReplayHistoryReader reader) => _reader = reader; + + [HttpPost("info")] + public async Task Info(CancellationToken ct) + { + if (!TryGetViewerId(out var vid)) return Unauthorized(); + + var rows = await _reader.GetRecentAsync(vid, take: 50, ct); + var resp = new ReplayInfoResponseDto + { + ReplayList = rows.Select(MapToWire).ToList(), + }; + return Ok(resp); + } + + [HttpPost("detail")] + public IActionResult Detail([FromBody] ReplayDetailRequestDto req) + { + if (!TryGetViewerId(out _)) return Unauthorized(); + return BadRequest(new { result_code = 99 }); + } + + private static ReplayInfoItemDto MapToWire(ReplayHistoryEntry e) => new() + { + BattleType = e.BattleType.ToString(CultureInfo.InvariantCulture), + TwoPickType = e.TwoPickType.ToString(CultureInfo.InvariantCulture), + DeckFormat = e.DeckFormat.ToString(CultureInfo.InvariantCulture), + BattleId = e.BattleId.ToString(CultureInfo.InvariantCulture), + IsLimitTurn = e.IsLimitTurn.ToString(CultureInfo.InvariantCulture), + OpponentName = e.OpponentName, + ClassId = e.SelfClassId.ToString(CultureInfo.InvariantCulture), + OpponentClassId = e.OpponentClassId.ToString(CultureInfo.InvariantCulture), + SubClassId = e.SelfSubClassId.ToString(CultureInfo.InvariantCulture), + OpponentSubClassId = e.OpponentSubClassId.ToString(CultureInfo.InvariantCulture), + RotationId = e.SelfRotationId, + OpponentRotationId = e.OpponentRotationId, + OpponentCountryCode = e.OpponentCountryCode, + CharaId = e.SelfCharaId.ToString(CultureInfo.InvariantCulture), + OpponentCharaId = e.OpponentCharaId.ToString(CultureInfo.InvariantCulture), + OpponentEmblemId = e.OpponentEmblemId.ToString(CultureInfo.InvariantCulture), + OpponentDegreeId = e.OpponentDegreeId.ToString(CultureInfo.InvariantCulture), + IsWin = e.IsWin ? "1" : "0", + BattleStartTime = e.BattleStartTime.ToString(TimeFormat, CultureInfo.InvariantCulture), + CreateTime = e.CreateTime.ToString(TimeFormat, CultureInfo.InvariantCulture), + }; +} diff --git a/SVSim.UnitTests/Controllers/ReplayControllerTests.cs b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs new file mode 100644 index 0000000..5ede95e --- /dev/null +++ b/SVSim.UnitTests/Controllers/ReplayControllerTests.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Models.Dtos.Replay; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.Controllers; + +public class ReplayControllerTests +{ + [Test] + public async Task ReplayInfo_returns_empty_list_for_fresh_viewer() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/replay/info", new { }); + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var body = await resp.Content.ReadFromJsonAsync(); + Assert.That(body!.ReplayList, Is.Empty); + } + + [Test] + public async Task ReplayInfo_returns_recent_rows_newest_first() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + + using (var seedScope = factory.Services.CreateScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + db.ViewerBattleHistories.AddRange( + NewRow(viewerId, battleId: 1, createTime: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)), + NewRow(viewerId, battleId: 2, createTime: new DateTime(2026, 6, 2, 0, 0, 0, DateTimeKind.Utc)), + NewRow(viewerId, battleId: 3, createTime: new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc))); + await db.SaveChangesAsync(); + } + + var client = factory.CreateAuthenticatedClient(viewerId); + var body = await client.PostAsJsonAsync("/replay/info", new { }) + .ContinueWith(t => t.Result.Content.ReadFromJsonAsync()) + .Unwrap(); + + Assert.That(body!.ReplayList.Select(r => r.BattleId).ToList(), + Is.EqualTo(new[] { "3", "2", "1" })); + } + + [Test] + public async Task ReplayInfo_does_not_leak_other_viewers_rows() + { + using var factory = new SVSimTestFactory(); + long viewerA = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + long viewerB = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL); + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ViewerBattleHistories.Add(NewRow(viewerA, battleId: 100, createTime: DateTime.UtcNow)); + db.ViewerBattleHistories.Add(NewRow(viewerB, battleId: 200, createTime: DateTime.UtcNow)); + await db.SaveChangesAsync(); + } + + var client = factory.CreateAuthenticatedClient(viewerA); + var body = await client.PostAsJsonAsync("/replay/info", new { }) + .ContinueWith(t => t.Result.Content.ReadFromJsonAsync()) + .Unwrap(); + + Assert.That(body!.ReplayList, Has.Count.EqualTo(1)); + Assert.That(body.ReplayList[0].BattleId, Is.EqualTo("100")); + } + + [Test] + public async Task ReplayDetail_returns_non_success_status() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL); + var client = factory.CreateAuthenticatedClient(viewerId); + + var resp = await client.PostAsJsonAsync("/replay/detail", new + { + viewer_id = viewerId, + battle_id = 234_471_983_876L, + }); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + private static ViewerBattleHistory NewRow(long viewerId, long battleId, DateTime createTime) => new() + { + ViewerId = viewerId, + BattleId = battleId, + SelfRotationId = "0", + OpponentName = "", + OpponentCountryCode = "", + OpponentRotationId = "0", + BattleStartTime = createTime.AddMinutes(-3), + CreateTime = createTime, + }; +}