Skip to content

Commit 8b65a7e

Browse files
feat: Implement connected player activation code management and enhance profile view
1 parent 7477db1 commit 8b65a7e

5 files changed

Lines changed: 465 additions & 5 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Net;
2+
using System.Security.Claims;
3+
4+
using Microsoft.ApplicationInsights;
5+
using Microsoft.ApplicationInsights.Extensibility;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.AspNetCore.Mvc.ViewFeatures;
9+
using Microsoft.Extensions.Configuration;
10+
using Microsoft.Extensions.Logging;
11+
12+
using Moq;
13+
using MX.Api.Abstractions;
14+
using MX.Observability.ApplicationInsights.Auditing;
15+
using Newtonsoft.Json;
16+
17+
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
18+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.ConnectedPlayers;
19+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.UserProfiles;
20+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
21+
using XtremeIdiots.Portal.Web.Controllers;
22+
using XtremeIdiots.Portal.Web.Models;
23+
24+
namespace XtremeIdiots.Portal.Web.Tests.Controllers;
25+
26+
public class ProfileControllerTests
27+
{
28+
private readonly Mock<IRepositoryApiClient> mockRepositoryApiClient = new(MockBehavior.Default) { DefaultValue = DefaultValue.Mock };
29+
private readonly TelemetryClient telemetryClient = new(new TelemetryConfiguration());
30+
private readonly Mock<ILogger<ProfileController>> mockLogger = new();
31+
private readonly Mock<IConfiguration> mockConfiguration = new();
32+
private readonly IAuditLogger auditLogger = new Mock<IAuditLogger>().Object;
33+
34+
private ProfileController CreateSut(ClaimsPrincipal? user = null)
35+
{
36+
var controller = new ProfileController(
37+
mockRepositoryApiClient.Object,
38+
telemetryClient,
39+
mockLogger.Object,
40+
mockConfiguration.Object,
41+
auditLogger);
42+
43+
var httpContext = new DefaultHttpContext
44+
{
45+
User = user ?? new ClaimsPrincipal(new ClaimsIdentity("TestAuth"))
46+
};
47+
48+
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
49+
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
50+
51+
return controller;
52+
}
53+
54+
[Fact]
55+
public async Task Manage_WhenUserClaimMissing_ReturnsEmptyProfileModel()
56+
{
57+
// Arrange
58+
var sut = CreateSut();
59+
60+
// Act
61+
var result = await sut.Manage();
62+
63+
// Assert
64+
var viewResult = Assert.IsType<ViewResult>(result);
65+
var model = Assert.IsType<ProfileManageViewModel>(viewResult.Model);
66+
67+
Assert.Null(model.UserProfileId);
68+
Assert.Null(model.ActiveActivationCode);
69+
Assert.Empty(model.LinkedPlayers);
70+
Assert.Equal(0, model.TotalLinkedPlayers);
71+
Assert.False(model.IsLinkedPlayersCapped);
72+
}
73+
74+
[Fact]
75+
public async Task ActivateConnectedPlayerCode_WhenProfileNotFound_DoesNotCallActivationEndpoint()
76+
{
77+
// Arrange
78+
mockRepositoryApiClient
79+
.Setup(x => x.UserProfiles.V1.GetUserProfileByXtremeIdiotsId(It.IsAny<string>(), It.IsAny<CancellationToken>()))
80+
.ReturnsAsync(new ApiResult<UserProfileDto>(HttpStatusCode.NotFound));
81+
82+
var user = new ClaimsPrincipal(
83+
new ClaimsIdentity([
84+
new Claim(UserProfileClaimType.XtremeIdiotsId, "123456")
85+
], "TestAuth"));
86+
87+
var sut = CreateSut(user);
88+
89+
// Act
90+
var result = await sut.ActivateConnectedPlayerCode();
91+
92+
// Assert
93+
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
94+
Assert.Equal(nameof(ProfileController.Manage), redirectResult.ActionName);
95+
96+
mockRepositoryApiClient
97+
.Verify(x => x.ConnectedPlayers.V1.ActivateConnectedPlayerActivationCode(
98+
It.IsAny<ActivateConnectedPlayerActivationCodeDto>(),
99+
It.IsAny<CancellationToken>()), Times.Never);
100+
}
101+
102+
[Fact]
103+
public async Task ActivateConnectedPlayerCode_WhenProfileFound_CallsActivationEndpoint()
104+
{
105+
// Arrange
106+
var userProfileId = Guid.NewGuid();
107+
var userProfile = CreateUserProfileDto(userProfileId);
108+
109+
mockRepositoryApiClient
110+
.Setup(x => x.UserProfiles.V1.GetUserProfileByXtremeIdiotsId(It.IsAny<string>(), It.IsAny<CancellationToken>()))
111+
.ReturnsAsync(new ApiResult<UserProfileDto>(HttpStatusCode.OK, new ApiResponse<UserProfileDto>(userProfile)));
112+
113+
var activationDto = CreateActivationCodeDto(userProfileId, "ABC123");
114+
115+
mockRepositoryApiClient
116+
.Setup(x => x.ConnectedPlayers.V1.ActivateConnectedPlayerActivationCode(It.IsAny<ActivateConnectedPlayerActivationCodeDto>(), It.IsAny<CancellationToken>()))
117+
.ReturnsAsync(new ApiResult<ConnectedPlayerActivationCodeDto>(HttpStatusCode.OK, new ApiResponse<ConnectedPlayerActivationCodeDto>(activationDto)));
118+
119+
var user = new ClaimsPrincipal(
120+
new ClaimsIdentity([
121+
new Claim(UserProfileClaimType.XtremeIdiotsId, "123456")
122+
], "TestAuth"));
123+
124+
var sut = CreateSut(user);
125+
126+
// Act
127+
var result = await sut.ActivateConnectedPlayerCode();
128+
129+
// Assert
130+
var redirectResult = Assert.IsType<RedirectToActionResult>(result);
131+
Assert.Equal(nameof(ProfileController.Manage), redirectResult.ActionName);
132+
133+
mockRepositoryApiClient
134+
.Verify(x => x.ConnectedPlayers.V1.ActivateConnectedPlayerActivationCode(
135+
It.Is<ActivateConnectedPlayerActivationCodeDto>(dto => dto.UserProfileId == userProfileId),
136+
It.IsAny<CancellationToken>()), Times.Once);
137+
}
138+
139+
private static UserProfileDto CreateUserProfileDto(Guid userProfileId)
140+
{
141+
var json = JsonConvert.SerializeObject(new
142+
{
143+
UserProfileId = userProfileId,
144+
XtremeIdiotsForumId = "123456",
145+
DisplayName = "Test User",
146+
UserProfileClaims = Array.Empty<object>()
147+
});
148+
149+
return JsonConvert.DeserializeObject<UserProfileDto>(json)!;
150+
}
151+
152+
private static ConnectedPlayerActivationCodeDto CreateActivationCodeDto(Guid userProfileId, string code)
153+
{
154+
var json = JsonConvert.SerializeObject(new
155+
{
156+
ConnectedPlayerActivationCodeId = Guid.NewGuid(),
157+
UserProfileId = userProfileId,
158+
Code = code,
159+
ExpiresAtUtc = DateTime.UtcNow.AddMinutes(5),
160+
AttemptCount = 0,
161+
MaxAttempts = 3,
162+
IsActive = true,
163+
ActivatedAtUtc = DateTime.UtcNow
164+
});
165+
166+
return JsonConvert.DeserializeObject<ConnectedPlayerActivationCodeDto>(json)!;
167+
}
168+
}

src/XtremeIdiots.Portal.Web/Controllers/ProfileController.cs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Microsoft.AspNetCore.Authorization;
33
using Microsoft.AspNetCore.Mvc;
44

5+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.ConnectedPlayers;
56
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Notifications;
67
using XtremeIdiots.Portal.Repository.Api.Client.V1;
78
using XtremeIdiots.Portal.Web.Auth.Constants;
@@ -37,7 +38,48 @@ public class ProfileController(
3738
[HttpGet]
3839
public async Task<IActionResult> Manage(CancellationToken cancellationToken = default)
3940
{
40-
return await ExecuteWithErrorHandlingAsync(() => Task.FromResult<IActionResult>(View()), nameof(Manage)).ConfigureAwait(false);
41+
return await ExecuteWithErrorHandlingAsync(async () =>
42+
{
43+
var model = await BuildProfileManageViewModel(cancellationToken).ConfigureAwait(false);
44+
return View(model);
45+
}, nameof(Manage)).ConfigureAwait(false);
46+
}
47+
48+
/// <summary>
49+
/// Creates a new active connected-player activation code for the current profile.
50+
/// </summary>
51+
[HttpPost]
52+
[ValidateAntiForgeryToken]
53+
public async Task<IActionResult> ActivateConnectedPlayerCode(CancellationToken cancellationToken = default)
54+
{
55+
return await ExecuteWithErrorHandlingAsync(async () =>
56+
{
57+
var userProfile = await GetCurrentUserProfile(cancellationToken).ConfigureAwait(false);
58+
if (userProfile is null)
59+
{
60+
this.AddAlertDanger("Unable to resolve your profile. Please sign in again and retry.");
61+
return RedirectToAction(nameof(Manage));
62+
}
63+
64+
var activationResponse = await repositoryApiClient.ConnectedPlayers.V1
65+
.ActivateConnectedPlayerActivationCode(new ActivateConnectedPlayerActivationCodeDto
66+
{
67+
UserProfileId = userProfile.UserProfileId
68+
}, cancellationToken)
69+
.ConfigureAwait(false);
70+
71+
var activationCode = activationResponse.Result?.Data?.Code;
72+
if (activationResponse.IsSuccess && !string.IsNullOrWhiteSpace(activationCode))
73+
{
74+
this.AddAlertSuccess($"Activation code generated: {activationCode}");
75+
}
76+
else
77+
{
78+
this.AddAlertDanger("Failed to generate activation code. Please try again.");
79+
}
80+
81+
return RedirectToAction(nameof(Manage));
82+
}, nameof(ActivateConnectedPlayerCode)).ConfigureAwait(false);
4183
}
4284

4385
/// <summary>
@@ -203,4 +245,65 @@ public async Task<IActionResult> AllNotifications(int page = 1, CancellationToke
203245
}, nameof(AllNotifications)).ConfigureAwait(false);
204246
}
205247

248+
private async Task<ProfileManageViewModel> BuildProfileManageViewModel(CancellationToken cancellationToken)
249+
{
250+
var userProfile = await GetCurrentUserProfile(cancellationToken).ConfigureAwait(false);
251+
if (userProfile is null)
252+
return new ProfileManageViewModel(null, null, [], 0, false);
253+
254+
const int linkedPlayersPageSize = 100;
255+
256+
var activeCodeResult = await repositoryApiClient.ConnectedPlayers.V1
257+
.GetActiveConnectedPlayerActivationCode(userProfile.UserProfileId, cancellationToken)
258+
.ConfigureAwait(false);
259+
260+
var linkedPlayersResult = await repositoryApiClient.ConnectedPlayers.V1
261+
.GetConnectedPlayersByUserProfile(userProfile.UserProfileId, 0, linkedPlayersPageSize, cancellationToken)
262+
.ConfigureAwait(false);
263+
264+
var linkedPlayers = (linkedPlayersResult.Result?.Data?.Items ?? [])
265+
.Select(x => new ProfileLinkedPlayerViewModel(
266+
x.GameType.ToString(),
267+
x.Username,
268+
x.LinkMethod.ToString(),
269+
x.LinkedAtUtc,
270+
x.UnlinkedAtUtc,
271+
x.IsActive))
272+
.ToList();
273+
274+
var activeCodeData = activeCodeResult.IsNotFound ? null : activeCodeResult.Result?.Data;
275+
var activeCode = activeCodeData is null
276+
? null
277+
: new ProfileActivationCodeViewModel(
278+
activeCodeData.Code,
279+
activeCodeData.ExpiresAtUtc,
280+
activeCodeData.IsActive);
281+
282+
var totalLinkedPlayers = linkedPlayersResult.Result?.Pagination?.TotalCount ?? linkedPlayers.Count;
283+
var isLinkedPlayersCapped = totalLinkedPlayers > linkedPlayers.Count;
284+
285+
return new ProfileManageViewModel(
286+
userProfile.UserProfileId,
287+
activeCode,
288+
linkedPlayers,
289+
totalLinkedPlayers,
290+
isLinkedPlayersCapped);
291+
}
292+
293+
private async Task<XtremeIdiots.Portal.Repository.Abstractions.Models.V1.UserProfiles.UserProfileDto?> GetCurrentUserProfile(CancellationToken cancellationToken)
294+
{
295+
var xtremeIdiotsId = User.XtremeIdiotsId();
296+
if (string.IsNullOrEmpty(xtremeIdiotsId))
297+
return null;
298+
299+
var userProfileResponse = await repositoryApiClient.UserProfiles.V1
300+
.GetUserProfileByXtremeIdiotsId(xtremeIdiotsId, cancellationToken)
301+
.ConfigureAwait(false);
302+
303+
if (userProfileResponse.IsNotFound || userProfileResponse.Result?.Data is null)
304+
return null;
305+
306+
return userProfileResponse.Result.Data;
307+
}
308+
206309
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace XtremeIdiots.Portal.Web.Models;
2+
3+
/// <summary>
4+
/// View model for the profile manage page with connected-player linking state.
5+
/// </summary>
6+
/// <param name="UserProfileId">Current portal user profile id, if available</param>
7+
/// <param name="ActiveActivationCode">Current active activation code, if available</param>
8+
/// <param name="LinkedPlayers">Connected player links for the current profile</param>
9+
/// <param name="TotalLinkedPlayers">Total linked players reported by the API for this profile</param>
10+
/// <param name="IsLinkedPlayersCapped">Indicates whether the linked players list shown in UI is capped</param>
11+
public record ProfileManageViewModel(
12+
Guid? UserProfileId,
13+
ProfileActivationCodeViewModel? ActiveActivationCode,
14+
IList<ProfileLinkedPlayerViewModel> LinkedPlayers,
15+
int TotalLinkedPlayers,
16+
bool IsLinkedPlayersCapped);
17+
18+
public record ProfileActivationCodeViewModel(
19+
string Code,
20+
DateTime ExpiresAtUtc,
21+
bool IsActive);
22+
23+
public record ProfileLinkedPlayerViewModel(
24+
string GameType,
25+
string Username,
26+
string LinkMethod,
27+
DateTime LinkedAtUtc,
28+
DateTime? UnlinkedAtUtc,
29+
bool IsActive);

0 commit comments

Comments
 (0)