feat(profile): ProfileController + DTOs + integration tests
Add /profile/index endpoint that returns user_rank_match_total_win (stubbed 0) and user_class_list built from viewer Classes + owned LeaderSkins. Six NUnit integration tests cover zero wins, all classes present, level/exp/default skin, leader_skin_id_list population, is_random_leader_skin round-trip, and 401 on unauthenticated access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
51
SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
Normal file
51
SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /profile/* — viewer-scoped profile read endpoint. Surfaces total rank-match wins
|
||||
/// and the per-class roster (level, exp, leader-skin selection).
|
||||
/// </summary>
|
||||
[Route("profile")]
|
||||
public sealed class ProfileController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
|
||||
public ProfileController(IViewerRepository viewerRepository) =>
|
||||
_viewerRepository = viewerRepository;
|
||||
|
||||
[HttpPost("index")]
|
||||
public async Task<ActionResult<ProfileIndexResponse>> Index(
|
||||
[FromBody] ProfileIndexRequest _,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
||||
return Unauthorized();
|
||||
|
||||
var viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null) return NotFound();
|
||||
|
||||
var skinsByClass = viewer.LeaderSkins
|
||||
.Where(s => s.ClassId.HasValue)
|
||||
.GroupBy(s => s.ClassId!.Value)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyCollection<int>)g.Select(s => s.Id).ToList());
|
||||
|
||||
var classes = viewer.Classes
|
||||
.Select(vc => new UserClass(
|
||||
vc,
|
||||
skinsByClass.GetValueOrDefault(vc.Class.Id, Array.Empty<int>())))
|
||||
.ToList();
|
||||
|
||||
return new ProfileIndexResponse
|
||||
{
|
||||
// TODO: when rank-match results are tracked, compute from viewer's rank history.
|
||||
UserRankMatchTotalWin = 0,
|
||||
UserClassList = classes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
|
||||
|
||||
/// <summary>
|
||||
/// Empty request body. The endpoint takes no parameters (client task uses BaseParam directly);
|
||||
/// this DTO exists so model binding resolves the envelope correctly.
|
||||
/// </summary>
|
||||
[MessagePackObject(true)]
|
||||
public sealed class ProfileIndexRequest
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ProfileIndexResponse
|
||||
{
|
||||
[JsonPropertyName("user_rank_match_total_win")]
|
||||
[Key("user_rank_match_total_win")]
|
||||
public int UserRankMatchTotalWin { get; set; }
|
||||
|
||||
[JsonPropertyName("user_class_list")]
|
||||
[Key("user_class_list")]
|
||||
public List<UserClass> UserClassList { get; set; } = new();
|
||||
}
|
||||
163
SVSim.UnitTests/Controllers/ProfileControllerTests.cs
Normal file
163
SVSim.UnitTests/Controllers/ProfileControllerTests.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
public class ProfileControllerTests
|
||||
{
|
||||
private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
|
||||
|
||||
[Test]
|
||||
public async Task Index_returns_zero_total_wins_for_fresh_viewer()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
Assert.That(doc.RootElement.GetProperty("user_rank_match_total_win").GetInt32(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_returns_all_viewer_classes()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var classes = doc.RootElement.GetProperty("user_class_list");
|
||||
Assert.That(classes.GetArrayLength(), Is.GreaterThanOrEqualTo(8),
|
||||
"fresh viewer should have at least 8 main-class entries");
|
||||
var classIds = Enumerable.Range(0, classes.GetArrayLength())
|
||||
.Select(i => classes[i].GetProperty("class_id").GetInt32())
|
||||
.ToList();
|
||||
Assert.That(classIds, Does.Contain(1));
|
||||
Assert.That(classIds, Does.Contain(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_class_entry_carries_level_exp_default_skin()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// Seed level + exp on class 1.
|
||||
using (var seedScope = factory.Services.CreateScope())
|
||||
{
|
||||
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await ctx.Viewers.Include(v => v.Classes).ThenInclude(c => c.Class).FirstAsync(v => v.Id == viewerId);
|
||||
var cls1 = viewer.Classes.First(c => c.Class.Id == 1);
|
||||
cls1.Level = 5;
|
||||
cls1.Exp = 600;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var entry = doc.RootElement.GetProperty("user_class_list")
|
||||
.EnumerateArray()
|
||||
.First(e => e.GetProperty("class_id").GetInt32() == 1);
|
||||
Assert.That(entry.GetProperty("level").GetInt32(), Is.EqualTo(5));
|
||||
Assert.That(entry.GetProperty("exp").GetInt32(), Is.EqualTo(600));
|
||||
Assert.That(entry.GetProperty("default_leader_skin_id").GetInt32(), Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_leader_skin_id_list_populated_from_owned_skins()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// Grant the viewer two additional leader skins for class 1.
|
||||
const int extraSkinA = 10001;
|
||||
const int extraSkinB = 10002;
|
||||
using (var seedScope = factory.Services.CreateScope())
|
||||
{
|
||||
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = extraSkinA, Name = "extraA", ClassId = 1 });
|
||||
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = extraSkinB, Name = "extraB", ClassId = 1 });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||
viewer.LeaderSkins.Add(await ctx.LeaderSkins.FindAsync(extraSkinA) ?? throw new InvalidOperationException());
|
||||
viewer.LeaderSkins.Add(await ctx.LeaderSkins.FindAsync(extraSkinB) ?? throw new InvalidOperationException());
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var entry = doc.RootElement.GetProperty("user_class_list")
|
||||
.EnumerateArray()
|
||||
.First(e => e.GetProperty("class_id").GetInt32() == 1);
|
||||
var list = entry.GetProperty("leader_skin_id_list");
|
||||
var ids = Enumerable.Range(0, list.GetArrayLength()).Select(i => list[i].GetInt32()).ToList();
|
||||
Assert.That(ids, Does.Contain(extraSkinA));
|
||||
Assert.That(ids, Does.Contain(extraSkinB));
|
||||
Assert.That(ids.Count, Is.GreaterThanOrEqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_is_random_leader_skin_reflects_persisted_value()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
using (var seedScope = factory.Services.CreateScope())
|
||||
{
|
||||
var ctx = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await ctx.Viewers.Include(v => v.Classes).ThenInclude(c => c.Class).FirstAsync(v => v.Id == viewerId);
|
||||
viewer.Classes.First(c => c.Class.Id == 1).IsRandomLeaderSkin = true;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
|
||||
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var class1 = doc.RootElement.GetProperty("user_class_list")
|
||||
.EnumerateArray()
|
||||
.First(e => e.GetProperty("class_id").GetInt32() == 1);
|
||||
Assert.That(class1.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(1));
|
||||
|
||||
var class2 = doc.RootElement.GetProperty("user_class_list")
|
||||
.EnumerateArray()
|
||||
.First(e => e.GetProperty("class_id").GetInt32() == 2);
|
||||
Assert.That(class2.GetProperty("is_random_leader_skin").GetInt32(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Index_without_auth_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient(); // no X-Test-Viewer-Id header
|
||||
|
||||
var response = await client.PostAsync("/profile/index", JsonBody("{}"));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user