From 9ff6c70faf2f1fcfc0c0d7896fb8eb1847d3f11c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 9 Jun 2026 16:46:49 -0400 Subject: [PATCH] feat(user-mypage): UserMyPageController + DTOs + persistence tests Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/UserMyPageController.cs | 51 ++++++ .../UserMyPage/UserMyPageUpdateRequest.cs | 28 ++++ .../UserMyPage/UserMyPageUpdateResponse.cs | 12 ++ .../Controllers/UserMyPageControllerTests.cs | 149 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateResponse.cs create mode 100644 SVSim.UnitTests/Controllers/UserMyPageControllerTests.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs b/SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs new file mode 100644 index 0000000..4e942c0 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Controllers/UserMyPageController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SVSim.Database; +using SVSim.Database.Models; +using SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage; + +namespace SVSim.EmulatedEntrypoint.Controllers; + +/// +/// /user_mypage/* — viewer-scoped MyPage configuration writes. Separate from the +/// /mypage/* family because the wire URL family is distinct. +/// +[Route("user_mypage")] +public sealed class UserMyPageController : SVSimController +{ + private readonly SVSimDbContext _db; + + public UserMyPageController(SVSimDbContext db) => _db = db; + + [HttpPost("update")] + public async Task> Update( + [FromBody] UserMyPageUpdateRequest request, + CancellationToken ct) + { + if (!TryGetViewerId(out var viewerId)) return Unauthorized(); + + var viewer = await _db.Viewers + .Include(v => v.MyPageBgRotation) + .FirstOrDefaultAsync(v => v.Id == viewerId, ct); + if (viewer is null) return NotFound(); + + viewer.MyPageBgSelectType = request.SelectType; + viewer.MyPageBgId = ParseIdOrZero(request.MyPageId); + + viewer.MyPageBgRotation.Clear(); + for (int slot = 0; slot < request.MyPageIdList.Count; slot++) + { + viewer.MyPageBgRotation.Add(new MyPageBgRotationEntry + { + Slot = slot, + BgId = ParseIdOrZero(request.MyPageIdList[slot]), + }); + } + + await _db.SaveChangesAsync(ct); + return new UserMyPageUpdateResponse(); + } + + private static int ParseIdOrZero(string s) => + int.TryParse(s, out var n) ? n : 0; +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs new file mode 100644 index 0000000..6fbeec7 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateRequest.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage; + +/// +/// Body of POST /user_mypage/update. Client task: MyPageSettingUpdateTask +/// (Shadowverse_Code_2026-05-23/Wizard/MyPageSettingUpdateTask.cs). Note that +/// select_type is the only int on the wire — id fields are strings. +/// +[MessagePackObject] +public sealed class UserMyPageUpdateRequest +{ + /// BGType enum: 0=Deck, 1=CustomBG, 2=RandomBG. Client sends as an int. + [JsonPropertyName("select_type")] + [Key("select_type")] + public int SelectType { get; set; } + + /// Chosen BG id when SelectType=CustomBG; empty or "0" otherwise. + [JsonPropertyName("mypage_id")] + [Key("mypage_id")] + public string MyPageId { get; set; } = "0"; + + /// Saved rotation pool, in slot order; client sends the full list on every call. + [JsonPropertyName("mypage_id_list")] + [Key("mypage_id_list")] + public List MyPageIdList { get; set; } = new(); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateResponse.cs new file mode 100644 index 0000000..481bc63 --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/UserMyPage/UserMyPageUpdateResponse.cs @@ -0,0 +1,12 @@ +using MessagePack; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.UserMyPage; + +/// +/// Empty response payload. The client's MyPageSettingUpdateTask.Parse() is the default +/// pass-through; server just acknowledges. +/// +[MessagePackObject(true)] +public sealed class UserMyPageUpdateResponse +{ +} diff --git a/SVSim.UnitTests/Controllers/UserMyPageControllerTests.cs b/SVSim.UnitTests/Controllers/UserMyPageControllerTests.cs new file mode 100644 index 0000000..fb20901 --- /dev/null +++ b/SVSim.UnitTests/Controllers/UserMyPageControllerTests.cs @@ -0,0 +1,149 @@ +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 UserMyPageControllerTests +{ + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static async Task LoadViewerWithRotation(SVSimTestFactory factory, long viewerId) + { + using var scope = factory.Services.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + return await ctx.Viewers + .Include(v => v.MyPageBgRotation) + .AsNoTracking() + .FirstAsync(v => v.Id == viewerId); + } + + [Test] + public async Task Update_persists_select_type_and_single_bg() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = JsonBody(""" + {"select_type":1,"mypage_id":"1213410310","mypage_id_list":[]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + var viewer = await LoadViewerWithRotation(factory, viewerId); + Assert.That(viewer.MyPageBgSelectType, Is.EqualTo(1)); + Assert.That(viewer.MyPageBgId, Is.EqualTo(1213410310)); + Assert.That(viewer.MyPageBgRotation, Is.Empty); + } + + [Test] + public async Task Update_persists_rotation_pool_in_slot_order() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = JsonBody(""" + {"select_type":2,"mypage_id":"0","mypage_id_list":["1211410310","1212410310","1213410310"]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var viewer = await LoadViewerWithRotation(factory, viewerId); + var pool = viewer.MyPageBgRotation.OrderBy(r => r.Slot).Select(r => r.BgId).ToList(); + Assert.That(pool, Is.EqualTo(new[] { 1211410310, 1212410310, 1213410310 })); + } + + [Test] + public async Task Update_overwrites_previous_rotation_atomically() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + + // Seed a 5-entry rotation directly. + using (var seedScope = factory.Services.CreateScope()) + { + var ctx = seedScope.ServiceProvider.GetRequiredService(); + var viewer = await ctx.Viewers.Include(v => v.MyPageBgRotation).FirstAsync(v => v.Id == viewerId); + for (int slot = 0; slot < 5; slot++) + { + viewer.MyPageBgRotation.Add(new MyPageBgRotationEntry { Slot = slot, BgId = 9000 + slot }); + } + await ctx.SaveChangesAsync(); + } + + using var client = factory.CreateAuthenticatedClient(viewerId); + var body = JsonBody(""" + {"select_type":2,"mypage_id":"0","mypage_id_list":["1001","1002","1003"]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var viewer2 = await LoadViewerWithRotation(factory, viewerId); + var pool = viewer2.MyPageBgRotation.OrderBy(r => r.Slot).Select(r => r.BgId).ToList(); + Assert.That(pool, Is.EqualTo(new[] { 1001, 1002, 1003 }), + "old slots 3-4 should have been deleted, not orphaned"); + } + + [Test] + public async Task Update_with_empty_mypage_id_falls_back_to_zero() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = JsonBody(""" + {"select_type":0,"mypage_id":"","mypage_id_list":[]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var viewer = await LoadViewerWithRotation(factory, viewerId); + Assert.That(viewer.MyPageBgSelectType, Is.EqualTo(0)); + Assert.That(viewer.MyPageBgId, Is.EqualTo(0)); + } + + [Test] + public async Task Update_with_unparseable_mypage_id_falls_back_to_zero() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = JsonBody(""" + {"select_type":0,"mypage_id":"garbage","mypage_id_list":[]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + var viewer = await LoadViewerWithRotation(factory, viewerId); + Assert.That(viewer.MyPageBgId, Is.EqualTo(0)); + } + + [Test] + public async Task Update_returns_envelope_with_empty_data_payload() + { + using var factory = new SVSimTestFactory(); + long viewerId = await factory.SeedViewerAsync(); + using var client = factory.CreateAuthenticatedClient(viewerId); + + var body = JsonBody(""" + {"select_type":0,"mypage_id":"0","mypage_id_list":[]} + """); + var response = await client.PostAsync("/user_mypage/update", body); + var raw = await response.Content.ReadAsStringAsync(); + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw); + + // Test path bypasses the translation middleware (gated on UnityPlayer UA), so the + // controller's literal return value is what comes back. An empty class serializes to "{}". + Assert.That(raw, Is.EqualTo("{}")); + } +}