Skip to content

Commit 7a854a7

Browse files
Add broadcasts edit module with load/save mapping and tests
Co-authored-by: frasermolyneux <34033625+frasermolyneux@users.noreply.github.com>
1 parent cae69cf commit 7a854a7

6 files changed

Lines changed: 542 additions & 2 deletions

File tree

src/XtremeIdiots.Portal.Web.Tests/Controllers/GameServersControllerTests.cs

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@
77
using Microsoft.Extensions.Logging;
88
using Moq;
99
using MX.Observability.ApplicationInsights.Auditing;
10+
using MX.Api.Abstractions;
11+
using Newtonsoft.Json;
12+
using System.Net;
13+
using System.Reflection;
1014
using System.Security.Claims;
15+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Configurations;
1116
using XtremeIdiots.Portal.Repository.Api.Client.V1;
1217
using XtremeIdiots.Portal.Web.Controllers;
18+
using XtremeIdiots.Portal.Web.ViewModels;
1319

1420
namespace XtremeIdiots.Portal.Web.Tests.Controllers;
1521

1622
public class GameServersControllerTests
1723
{
1824
private readonly Mock<IAuthorizationService> mockAuthorizationService = new();
19-
private readonly Mock<IRepositoryApiClient> mockRepositoryApiClient = new();
25+
private readonly Mock<IRepositoryApiClient> mockRepositoryApiClient = new(MockBehavior.Default) { DefaultValue = DefaultValue.Mock };
2026
private readonly TelemetryClient telemetryClient = new(new TelemetryConfiguration());
2127
private readonly Mock<ILogger<GameServersController>> mockLogger = new();
2228
private readonly Mock<IConfiguration> mockConfiguration = new();
@@ -92,4 +98,101 @@ public void Constructor_WithNullConfiguration_ThrowsArgumentNullException()
9298
null!,
9399
auditLogger));
94100
}
101+
102+
[Fact]
103+
public void PopulateConfigFromNamespace_BroadcastsNamespace_MapsAllFields()
104+
{
105+
// Arrange
106+
var sut = CreateSut();
107+
var method = GetPrivateInstanceMethod("PopulateConfigFromNamespace");
108+
var model = new GameServerEditViewModel();
109+
var config = JsonConvert.DeserializeObject<ConfigurationDto>(JsonConvert.SerializeObject(new
110+
{
111+
Namespace = "broadcasts",
112+
Configuration = """
113+
{
114+
"enabled": true,
115+
"intervalSeconds": 700,
116+
"messages": [
117+
{ "message": "^1Welcome to XI", "enabled": true },
118+
{ "message": "^2Admins online", "enabled": false }
119+
]
120+
}
121+
"""
122+
}));
123+
124+
// Act
125+
method.Invoke(sut, [model, config]);
126+
127+
// Assert
128+
Assert.True(model.BroadcastsEnabled);
129+
Assert.Equal(700, model.BroadcastsIntervalSeconds);
130+
Assert.Equal(2, model.BroadcastMessages.Count);
131+
Assert.Equal("^1Welcome to XI", model.BroadcastMessages[0].Message);
132+
Assert.True(model.BroadcastMessages[0].Enabled);
133+
Assert.Equal("^2Admins online", model.BroadcastMessages[1].Message);
134+
Assert.False(model.BroadcastMessages[1].Enabled);
135+
}
136+
137+
[Fact]
138+
public async Task SaveConfigNamespacesAsync_AgentEnabled_UpsertsBroadcastsContract()
139+
{
140+
// Arrange
141+
var sut = CreateSut();
142+
var method = GetPrivateInstanceMethod("SaveConfigNamespacesAsync");
143+
var gameServerId = Guid.NewGuid();
144+
var upsertPayloads = new Dictionary<string, string>();
145+
146+
mockRepositoryApiClient
147+
.Setup(x => x.GameServerConfigurations.V1.UpsertConfiguration(
148+
It.IsAny<Guid>(),
149+
It.IsAny<string>(),
150+
It.IsAny<UpsertConfigurationDto>(),
151+
It.IsAny<CancellationToken>()))
152+
.ReturnsAsync((Guid _, string ns, UpsertConfigurationDto dto, CancellationToken _) =>
153+
{
154+
upsertPayloads[ns] = dto.Configuration;
155+
var responseDto = JsonConvert.DeserializeObject<ConfigurationDto>("{}");
156+
return new ApiResult<ConfigurationDto>(HttpStatusCode.OK, new ApiResponse<ConfigurationDto>(responseDto));
157+
});
158+
159+
var model = new GameServerEditViewModel
160+
{
161+
GameServer = new GameServerViewModel
162+
{
163+
GameServerId = gameServerId,
164+
Title = "Server Alpha",
165+
AgentEnabled = true
166+
},
167+
BroadcastsEnabled = true,
168+
BroadcastsIntervalSeconds = null,
169+
BroadcastMessages =
170+
[
171+
new BroadcastMessageViewModel { Message = "^1Welcome", Enabled = true },
172+
new BroadcastMessageViewModel { Message = "^2Rules", Enabled = false }
173+
]
174+
};
175+
176+
// Act
177+
var task = (Task)method.Invoke(sut, [model, gameServerId, false, false, new List<string>(), CancellationToken.None])!;
178+
await task;
179+
180+
// Assert
181+
Assert.True(upsertPayloads.TryGetValue("broadcasts", out var broadcastsJson));
182+
using var doc = System.Text.Json.JsonDocument.Parse(broadcastsJson);
183+
var root = doc.RootElement;
184+
185+
Assert.True(root.GetProperty("enabled").GetBoolean());
186+
Assert.Equal(500, root.GetProperty("intervalSeconds").GetInt32());
187+
Assert.Equal(2, root.GetProperty("messages").GetArrayLength());
188+
Assert.False(root.GetProperty("messages")[1].GetProperty("enabled").GetBoolean());
189+
Assert.Equal("^2Rules", root.GetProperty("messages")[1].GetProperty("message").GetString());
190+
}
191+
192+
private static MethodInfo GetPrivateInstanceMethod(string name)
193+
{
194+
var method = typeof(GameServersController).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic);
195+
Assert.NotNull(method);
196+
return method;
197+
}
95198
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using XtremeIdiots.Portal.Web.ViewModels;
3+
4+
namespace XtremeIdiots.Portal.Web.Tests.ViewModels;
5+
6+
public class GameServerEditViewModelTests
7+
{
8+
[Fact]
9+
public void BroadcastsIntervalSeconds_DefaultsTo500()
10+
{
11+
var model = new GameServerEditViewModel();
12+
13+
Assert.Equal(500, model.BroadcastsIntervalSeconds);
14+
}
15+
16+
[Fact]
17+
public void Validate_WhenBroadcastMessageExceeds120Characters_ReturnsValidationError()
18+
{
19+
var model = CreateValidModel();
20+
model.BroadcastMessages =
21+
[
22+
new BroadcastMessageViewModel
23+
{
24+
Message = new string('a', 121),
25+
Enabled = true
26+
}
27+
];
28+
29+
var isValid = Validator.TryValidateObject(model, new ValidationContext(model), [], true);
30+
31+
Assert.False(isValid);
32+
}
33+
34+
[Fact]
35+
public void Validate_WhenBroadcastMessageIs120Characters_IsValid()
36+
{
37+
var model = CreateValidModel();
38+
model.BroadcastMessages =
39+
[
40+
new BroadcastMessageViewModel
41+
{
42+
Message = new string('a', 120),
43+
Enabled = true
44+
}
45+
];
46+
47+
var isValid = Validator.TryValidateObject(model, new ValidationContext(model), [], true);
48+
49+
Assert.True(isValid);
50+
}
51+
52+
private static GameServerEditViewModel CreateValidModel()
53+
{
54+
return new GameServerEditViewModel
55+
{
56+
GameServer = new GameServerViewModel
57+
{
58+
Title = "Test Server",
59+
Hostname = "localhost",
60+
QueryPort = 28960
61+
}
62+
};
63+
}
64+
}

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,11 @@ private void PopulateConfigFromNamespace(GameServerEditViewModel editModel, Conf
626626
editModel.EventsStaleThresholdSeconds = GetNullableIntProperty(root, "staleThresholdSeconds");
627627
editModel.EventsPlayerCacheExpirationSeconds = GetNullableIntProperty(root, "playerCacheExpirationSeconds");
628628
break;
629+
case "broadcasts":
630+
editModel.BroadcastsEnabled = GetBoolProperty(root, "enabled", false);
631+
editModel.BroadcastsIntervalSeconds = GetNullableIntProperty(root, "intervalSeconds") ?? 500;
632+
editModel.BroadcastMessages = GetBroadcastMessages(root);
633+
break;
629634
default:
630635
Logger.LogDebug("Unknown configuration namespace '{Namespace}' for game server", config.Namespace);
631636
break;
@@ -678,6 +683,28 @@ private static bool GetBoolProperty(JsonElement root, string propertyName, bool
678683
: null;
679684
}
680685

686+
private static List<BroadcastMessageViewModel> GetBroadcastMessages(JsonElement root)
687+
{
688+
var messages = new List<BroadcastMessageViewModel>();
689+
690+
if (!root.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
691+
return messages;
692+
693+
foreach (var item in messagesElement.EnumerateArray())
694+
{
695+
if (item.ValueKind != JsonValueKind.Object)
696+
continue;
697+
698+
messages.Add(new BroadcastMessageViewModel
699+
{
700+
Message = GetStringProperty(item, "message") ?? string.Empty,
701+
Enabled = GetBoolProperty(item, "enabled", true)
702+
});
703+
}
704+
705+
return messages;
706+
}
707+
681708
private void PopulateGlobalDefaults(GameServerEditViewModel editModel, ConfigurationDto config)
682709
{
683710
try
@@ -892,6 +919,28 @@ await UpsertConfigSafeAsync(gameServerId, "events",
892919
"{}", serverTitle, errors, cancellationToken).ConfigureAwait(false);
893920
}
894921
}
922+
923+
// Save Broadcasts config (only when Agent is enabled)
924+
if (model.GameServer.AgentEnabled)
925+
{
926+
var broadcastsIntervalSeconds = model.BroadcastsIntervalSeconds.GetValueOrDefault(500);
927+
if (broadcastsIntervalSeconds <= 0)
928+
broadcastsIntervalSeconds = 500;
929+
930+
var broadcastsConfig = new
931+
{
932+
enabled = model.BroadcastsEnabled,
933+
intervalSeconds = broadcastsIntervalSeconds,
934+
messages = (model.BroadcastMessages ?? []).Select(m => new
935+
{
936+
message = m.Message,
937+
enabled = m.Enabled
938+
})
939+
};
940+
941+
await UpsertConfigSafeAsync(gameServerId, "broadcasts",
942+
JsonSerializer.Serialize(broadcastsConfig, configJsonOptions), serverTitle, errors, cancellationToken).ConfigureAwait(false);
943+
}
895944
}
896945

897946
private async Task UpsertConfigSafeAsync(

src/XtremeIdiots.Portal.Web/ViewModels/GameServerEditViewModel.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace XtremeIdiots.Portal.Web.ViewModels;
66
/// <summary>
77
/// Composite view model for the game server edit page with tabbed configuration
88
/// </summary>
9-
public class GameServerEditViewModel
9+
public class GameServerEditViewModel : IValidatableObject
1010
{
1111
/// <summary>
1212
/// Core game server data
@@ -87,6 +87,17 @@ public class GameServerEditViewModel
8787
[Range(1, int.MaxValue, ErrorMessage = "Player cache expiration must be at least 1 second.")]
8888
public int? EventsPlayerCacheExpirationSeconds { get; set; }
8989

90+
// Broadcasts configuration (parsed from "broadcasts" config namespace)
91+
92+
[DisplayName("Enabled")]
93+
public bool BroadcastsEnabled { get; set; }
94+
95+
[DisplayName("Interval (seconds)")]
96+
[Range(1, 86400, ErrorMessage = "Broadcast interval must be between 1 and 86400 seconds.")]
97+
public int? BroadcastsIntervalSeconds { get; set; } = 500;
98+
99+
public List<BroadcastMessageViewModel> BroadcastMessages { get; set; } = [];
100+
90101
// Global defaults (for placeholder display in override fields)
91102

92103
public int GlobalModerationHateSeverityThreshold { get; set; } = GlobalSettingsViewModel.DisabledSeverityThreshold;
@@ -101,4 +112,22 @@ public class GameServerEditViewModel
101112

102113
public bool CanEditFtp { get; set; }
103114
public bool CanEditRcon { get; set; }
115+
116+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
117+
{
118+
for (var i = 0; i < BroadcastMessages.Count; i++)
119+
{
120+
if (BroadcastMessages[i].Message?.Length > 120)
121+
yield return new ValidationResult("Broadcast message cannot exceed 120 characters.", [$"BroadcastMessages[{i}].Message"]);
122+
}
123+
}
124+
}
125+
126+
public class BroadcastMessageViewModel
127+
{
128+
[DisplayName("Message")]
129+
public string Message { get; set; } = string.Empty;
130+
131+
[DisplayName("Enabled")]
132+
public bool Enabled { get; set; } = true;
104133
}

0 commit comments

Comments
 (0)