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 <noreply@anthropic.com>
This commit is contained in:
68
SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
Normal file
68
SVSim.EmulatedEntrypoint/Controllers/ReplayController.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<IActionResult> 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
104
SVSim.UnitTests/Controllers/ReplayControllerTests.cs
Normal file
104
SVSim.UnitTests/Controllers/ReplayControllerTests.cs
Normal file
@@ -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<ReplayInfoResponseDto>();
|
||||||
|
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<SVSimDbContext>();
|
||||||
|
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<ReplayInfoResponseDto>())
|
||||||
|
.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<SVSimDbContext>();
|
||||||
|
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<ReplayInfoResponseDto>())
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user