From 11215bd69f11ddc77dcd45a07513ed851602faa6 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 17:37:35 -0400 Subject: [PATCH] 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 --- .../Controllers/ProfileController.cs | 51 ++++++ .../Dtos/Profile/ProfileIndexRequest.cs | 12 ++ .../Dtos/Profile/ProfileIndexResponse.cs | 17 ++ .../Controllers/ProfileControllerTests.cs | 163 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs create mode 100644 SVSim.UnitTests/Controllers/ProfileControllerTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs b/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs new file mode 100644 index 0000000..843a3de --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/ProfileController.cs @@ -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; + +/// +/// /profile/* — viewer-scoped profile read endpoint. Surfaces total rank-match wins +/// and the per-class roster (level, exp, leader-skin selection). +/// +[Route("profile")] +public sealed class ProfileController : SVSimController +{ + private readonly IViewerRepository _viewerRepository; + + public ProfileController(IViewerRepository viewerRepository) => + _viewerRepository = viewerRepository; + + [HttpPost("index")] + public async Task> 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)g.Select(s => s.Id).ToList()); + + var classes = viewer.Classes + .Select(vc => new UserClass( + vc, + skinsByClass.GetValueOrDefault(vc.Class.Id, Array.Empty()))) + .ToList(); + + return new ProfileIndexResponse + { + // TODO: when rank-match results are tracked, compute from viewer's rank history. + UserRankMatchTotalWin = 0, + UserClassList = classes, + }; + } +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs new file mode 100644 index 0000000..0ee1ce8 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexRequest.cs @@ -0,0 +1,12 @@ +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Profile; + +/// +/// Empty request body. The endpoint takes no parameters (client task uses BaseParam directly); +/// this DTO exists so model binding resolves the envelope correctly. +/// +[MessagePackObject(true)] +public sealed class ProfileIndexRequest +{ +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs new file mode 100644 index 0000000..9d94d2f --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Profile/ProfileIndexResponse.cs @@ -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 UserClassList { get; set; } = new(); +} diff --git a/SVSim.UnitTests/Controllers/ProfileControllerTests.cs b/SVSim.UnitTests/Controllers/ProfileControllerTests.cs new file mode 100644 index 0000000..a6ac642 --- /dev/null +++ b/SVSim.UnitTests/Controllers/ProfileControllerTests.cs @@ -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(); + 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(); + 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(); + 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)); + } +}