diff --git a/F1Server.Tests/WebApi/Controllers/HealthControllerTests.cs b/F1Server.Tests/WebApi/Controllers/HealthControllerTests.cs
new file mode 100644
index 0000000..d604a47
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/HealthControllerTests.cs
@@ -0,0 +1,46 @@
+using F1Server.WebApi.Controllers;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// Contains unit tests verifying the health check of the
+///
+[TestClass]
+public class HealthControllerTests
+{
+ #region Methods
+
+ ///
+ /// Verifies that an initialized database together with an available SignalR hub reports a healthy server
+ ///
+ [TestMethod]
+ public void HealthControllerGetWithHubReturnsOk()
+ {
+ using (var hub = new RecordingHubContext())
+ {
+ var controller = new HealthController(NullLogger.Instance, hub);
+
+ var result = controller.Get();
+
+ Assert.IsInstanceOfType(result, "An initialized database and an available hub should report a healthy server!");
+ }
+ }
+
+ ///
+ /// Verifies that a missing SignalR hub reports an unhealthy server
+ ///
+ [TestMethod]
+ public void HealthControllerGetWithoutHubReturnsBadRequest()
+ {
+ var controller = new HealthController(NullLogger.Instance, null!);
+
+ var result = controller.Get();
+
+ Assert.IsInstanceOfType(result, "A missing hub should report an unhealthy server!");
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file
diff --git a/F1Server.Tests/WebApi/Controllers/LiveSessionControllerTests.cs b/F1Server.Tests/WebApi/Controllers/LiveSessionControllerTests.cs
new file mode 100644
index 0000000..f8629ee
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/LiveSessionControllerTests.cs
@@ -0,0 +1,90 @@
+using F1Server.Data;
+using F1Server.WebApi.Controllers;
+using F1Server.WebApi.Core;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// Contains unit tests verifying that the starts the live session broadcast
+///
+[TestClass]
+public class LiveSessionControllerTests
+{
+ #region Constants
+
+ ///
+ /// Maximum time in milliseconds a test waits for a broadcast of the timer
+ ///
+ private const int BroadcastTimeout = 10000;
+
+ #endregion // Constants
+
+ #region Methods
+
+ ///
+ /// Verifies that the first request starts the timer, which broadcasts the live session state and data
+ ///
+ [TestMethod]
+ public void LiveSessionControllerGetBroadcastsLiveSession()
+ {
+ var appData = new F1ServerApplicationData
+ {
+ IsLiveSession = true,
+ LiveSessionId = 460200,
+ LiveSessionData = new LiveSessionData
+ {
+ DbId = 460200
+ }
+ };
+
+ using (var hub = new RecordingHubContext())
+ {
+ using (var timerManager = new TimerManager())
+ {
+ var controller = new LiveSessionController(appData, timerManager, hub, NullLogger.Instance);
+
+ var result = controller.Get();
+
+ Assert.IsInstanceOfType(result, "The live session request should be answered with Ok!");
+ Assert.IsTrue(timerManager.IsTimerStarted, "The first request should start the broadcast timer!");
+
+ var liveSessionState = hub.WaitForMessage("IsLiveSession", BroadcastTimeout);
+
+ Assert.IsNotNull(liveSessionState, "The live session state should be broadcast!");
+ Assert.IsTrue((bool?)liveSessionState[0], "The broadcast should report an active live session!");
+ Assert.AreEqual(460200L, liveSessionState[1], "The broadcast should contain the live session id!");
+ Assert.IsNotNull(hub.WaitForMessage("LiveSessionDataUpdated", BroadcastTimeout), "The live session data should be broadcast!");
+ }
+ }
+ }
+
+ ///
+ /// Verifies that a request with an already started timer keeps the running broadcast
+ ///
+ [TestMethod]
+ public void LiveSessionControllerGetWithStartedTimerKeepsTimer()
+ {
+ using (var hub = new RecordingHubContext())
+ {
+ using (var timerManager = new TimerManager())
+ {
+ timerManager.PrepareTimer(() =>
+ {
+ });
+
+ var timerStarted = timerManager.TimerStarted;
+ var controller = new LiveSessionController(new F1ServerApplicationData(), timerManager, hub, NullLogger.Instance);
+
+ var result = controller.Get();
+
+ Assert.IsInstanceOfType(result, "The live session request should be answered with Ok!");
+ Assert.AreEqual(timerStarted, timerManager.TimerStarted, "An already started timer must not be restarted!");
+ }
+ }
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file
diff --git a/F1Server.Tests/WebApi/Controllers/LiveSessionDataControllerTests.cs b/F1Server.Tests/WebApi/Controllers/LiveSessionDataControllerTests.cs
new file mode 100644
index 0000000..8719b89
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/LiveSessionDataControllerTests.cs
@@ -0,0 +1,134 @@
+using F1Server.Core.Enumerations;
+using F1Server.Data;
+using F1Server.WebApi.Controllers;
+
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// Contains unit tests verifying that the maps the live session data into its view data
+///
+[TestClass]
+public class LiveSessionDataControllerTests
+{
+ #region Static methods
+
+ ///
+ /// Creates live session data with one driver
+ ///
+ /// Live session data
+ private static LiveSessionData CreateLiveSessionData()
+ {
+ var liveSessionData = new LiveSessionData
+ {
+ DbId = 460100,
+ SessionGameId = 460101,
+ CurrentCarsOnTrack = 1,
+ SessionType = SessionType.Qualifying1,
+ AirTemperature = 24,
+ TrackTemperature = 38,
+ FastestSector1 = 30250,
+ FastestSector1Driver = 3,
+ FastestLap = 91000,
+ FastestLapDriver = 3,
+ TimeTable = [3]
+ };
+
+ liveSessionData.Drivers.Add(new LiveDriverData
+ {
+ ArrayIndex = 3,
+ DriverName = "Live Test Driver",
+ CarNumber = 44,
+ TeamName = "Live Test Team",
+ CarPosition = 1,
+ CurrentDriverStatus = DriverStatus.FlyingLap,
+ FastestLapTime = 91000,
+ LapsDriven = 5,
+ CurrentUsedTyre = VisualTyreCompound.Soft
+ });
+
+ return liveSessionData;
+ }
+
+ #endregion // Static methods
+
+ #region Methods
+
+ ///
+ /// Verifies that the session values of the live session data are mapped into the view data
+ ///
+ [TestMethod]
+ public void LiveSessionDataControllerGetMapsSessionData()
+ {
+ var appData = new F1ServerApplicationData
+ {
+ LiveSessionData = CreateLiveSessionData()
+ };
+
+ var controller = new LiveSessionDataController(NullLogger.Instance, appData);
+
+ var viewData = controller.Get();
+
+ Assert.AreEqual(460100, viewData.DbId, "The database id of the session should be mapped!");
+ Assert.AreEqual(460101UL, viewData.SessionGameId, "The game id of the session should be mapped!");
+ Assert.AreEqual(SessionType.Qualifying1, viewData.SessionType, "The session type should be mapped!");
+ Assert.AreEqual(1, viewData.CurrentCarsOnTrack, "The cars on track should be mapped!");
+ Assert.AreEqual(24, viewData.AirTemperature, "The air temperature should be mapped!");
+ Assert.AreEqual(38, viewData.TrackTemperature, "The track temperature should be mapped!");
+ Assert.AreEqual(30250U, viewData.FastestSector1, "The fastest sector 1 should be mapped!");
+ Assert.AreEqual(91000U, viewData.FastestLap, "The fastest lap should be mapped!");
+ Assert.AreEqual(3, viewData.FastestLapDriver, "The driver of the fastest lap should be mapped!");
+ Assert.AreSequenceEqual(new List
+ {
+ 3
+ },
+ viewData.TimeTable,
+ "The time table should be mapped!");
+ }
+
+ ///
+ /// Verifies that the drivers of the live session data are mapped into the view data
+ ///
+ [TestMethod]
+ public void LiveSessionDataControllerGetMapsDrivers()
+ {
+ var appData = new F1ServerApplicationData
+ {
+ LiveSessionData = CreateLiveSessionData()
+ };
+
+ var controller = new LiveSessionDataController(NullLogger.Instance, appData);
+
+ var viewData = controller.Get();
+
+ Assert.HasCount(1, viewData.Drivers, "Every driver of the live session should be mapped!");
+
+ var driver = viewData.Drivers[0];
+
+ Assert.AreEqual(3, driver.ArrayIndex, "The array index of the driver should be mapped!");
+ Assert.AreEqual("Live Test Driver", driver.DriverName, "The name of the driver should be mapped!");
+ Assert.AreEqual(44, driver.CarNumber, "The car number should be mapped!");
+ Assert.AreEqual("Live Test Team", driver.TeamName, "The team name should be mapped!");
+ Assert.AreEqual(DriverStatus.FlyingLap, driver.CurrentDriverStatus, "The driver status should be mapped!");
+ Assert.AreEqual(91000U, driver.FastestLapTime, "The fastest lap time of the driver should be mapped!");
+ Assert.AreEqual(5, driver.LapsDriven, "The driven laps should be mapped!");
+ Assert.AreEqual(VisualTyreCompound.Soft, driver.CurrentUsedTyre, "The used tyre should be mapped!");
+ }
+
+ ///
+ /// Verifies that missing live session data returns empty view data
+ ///
+ [TestMethod]
+ public void LiveSessionDataControllerGetWithoutLiveSessionReturnsEmptyViewData()
+ {
+ var controller = new LiveSessionDataController(NullLogger.Instance, new F1ServerApplicationData());
+
+ var viewData = controller.Get();
+
+ Assert.AreEqual(0, viewData.DbId, "Without a live session no session id should be returned!");
+ Assert.IsEmpty(viewData.Drivers, "Without a live session no drivers should be returned!");
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file
diff --git a/F1Server.Tests/WebApi/Controllers/RecordingHubContext.cs b/F1Server.Tests/WebApi/Controllers/RecordingHubContext.cs
new file mode 100644
index 0000000..fe8d159
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/RecordingHubContext.cs
@@ -0,0 +1,160 @@
+using System.Collections.Concurrent;
+
+using F1Server.WebApi.Hubs;
+
+using Microsoft.AspNetCore.SignalR;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// SignalR hub context recording every message sent to its clients to test the broadcasts of the controllers
+///
+internal sealed class RecordingHubContext : IHubContext, IHubClients, IClientProxy, IGroupManager, IDisposable
+{
+ #region Fields
+
+ ///
+ /// Names of the sent hub methods with their arguments
+ ///
+ private readonly ConcurrentQueue<(string Method, object?[] Arguments)> _messages = new();
+
+ ///
+ /// Signaled whenever a message was sent
+ ///
+ private readonly AutoResetEvent _messageSent = new(false);
+
+ #endregion // Fields
+
+ #region Methods
+
+ ///
+ /// Waits until a message of the given hub method was sent
+ ///
+ /// Name of the hub method
+ /// Maximum waiting time in milliseconds
+ /// Arguments of the first sent message of the hub method, or null if none was sent within the timeout
+ public object?[]? WaitForMessage(string method, int timeout)
+ {
+ var deadline = DateTime.UtcNow.AddMilliseconds(timeout);
+
+ object?[]? arguments = null;
+
+ while (arguments is null && DateTime.UtcNow < deadline)
+ {
+ arguments = _messages.FirstOrDefault(m => m.Method == method).Arguments;
+
+ if (arguments is null)
+ {
+ _messageSent.WaitOne(TimeSpan.FromMilliseconds(100));
+ }
+ }
+
+ return arguments;
+ }
+
+ #endregion // Methods
+
+ #region IHubContext
+
+ ///
+ IHubClients IHubContext.Clients => this;
+
+ ///
+ IGroupManager IHubContext.Groups => this;
+
+ #endregion // IHubContext
+
+ #region IHubClients
+
+ ///
+ public IClientProxy All => this;
+
+ ///
+ public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy Client(string connectionId)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy Clients(IReadOnlyList connectionIds)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy Group(string groupName)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy Groups(IReadOnlyList groupNames)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy User(string userId)
+ {
+ return this;
+ }
+
+ ///
+ public IClientProxy Users(IReadOnlyList userIds)
+ {
+ return this;
+ }
+
+ #endregion // IHubClients
+
+ #region IClientProxy
+
+ ///
+ public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
+ {
+ _messages.Enqueue((method, args));
+ _messageSent.Set();
+
+ return Task.CompletedTask;
+ }
+
+ #endregion // IClientProxy
+
+ #region IGroupManager
+
+ ///
+ public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ #endregion // IGroupManager
+
+ #region IDisposable
+
+ ///
+ public void Dispose()
+ {
+ _messageSent.Dispose();
+ }
+
+ #endregion // IDisposable
+}
\ No newline at end of file
diff --git a/F1Server.Tests/WebApi/Controllers/ServerHealthControllerTests.cs b/F1Server.Tests/WebApi/Controllers/ServerHealthControllerTests.cs
new file mode 100644
index 0000000..a79bee9
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/ServerHealthControllerTests.cs
@@ -0,0 +1,148 @@
+using System.Diagnostics;
+
+using F1Server.Core.Observability;
+using F1Server.Data;
+using F1Server.Tests.Processors;
+using F1Server.WebApi.Controllers;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// Contains unit tests verifying the server feature checks of the
+///
+[TestClass]
+public class ServerHealthControllerTests
+{
+ #region Static methods
+
+ ///
+ /// Creates application data of a receiving server with a ready telemetry writer
+ ///
+ /// Application data
+ private static F1ServerApplicationData CreateReceivingAppData()
+ {
+ return new F1ServerApplicationData
+ {
+ IsReceiving = true,
+ TelemetryWriter = new RecordingTelemetryWriter()
+ };
+ }
+
+ ///
+ /// Creates a server health controller
+ ///
+ /// Application data
+ /// Server health controller
+ private static ServerHealthController CreateController(F1ServerApplicationData appData)
+ {
+ return new ServerHealthController(appData, NullLogger.Instance);
+ }
+
+ #endregion // Static methods
+
+ #region Methods
+
+ ///
+ /// Verifies that a receiving server without configured observability features reports a healthy server
+ ///
+ [TestMethod]
+ public void ServerHealthControllerGetReceivingServerReturnsOk()
+ {
+ var result = CreateController(CreateReceivingAppData()).Get();
+
+ Assert.IsInstanceOfType(result, "A receiving server with a ready telemetry writer should report a healthy server!");
+ }
+
+ ///
+ /// Verifies that a server that is not receiving data reports an unhealthy server
+ ///
+ [TestMethod]
+ public void ServerHealthControllerGetNotReceivingServerReturnsBadRequest()
+ {
+ var appData = CreateReceivingAppData();
+
+ appData.IsReceiving = false;
+
+ var result = CreateController(appData).Get();
+
+ Assert.IsInstanceOfType(result, "A server that is not receiving data should report an unhealthy server!");
+ }
+
+ ///
+ /// Verifies that a missing telemetry writer reports an unhealthy server
+ ///
+ [TestMethod]
+ public void ServerHealthControllerGetWithoutTelemetryWriterReturnsBadRequest()
+ {
+ var appData = CreateReceivingAppData();
+
+ appData.TelemetryWriter = null;
+
+ var result = CreateController(appData).Get();
+
+ Assert.IsInstanceOfType(result, "A missing telemetry writer should report an unhealthy server!");
+ }
+
+ ///
+ /// Verifies that configured but uninitialized metrics report an unhealthy server
+ ///
+ [TestMethod]
+ public void ServerHealthControllerGetConfiguredMetricsWithoutInstanceReturnsBadRequest()
+ {
+ var appData = CreateReceivingAppData();
+
+ appData.IsMetricsConfigured = true;
+
+ var result = CreateController(appData).Get();
+
+ Assert.IsInstanceOfType(result, "Configured but uninitialized metrics should report an unhealthy server!");
+ }
+
+ ///
+ /// Verifies that configured logging reports a healthy server only with a logger factory
+ ///
+ /// Is a logger factory available?
+ /// Expected health state
+ [TestMethod]
+ [DataRow(true, true)]
+ [DataRow(false, false)]
+ public void ServerHealthControllerGetConfiguredLoggingRequiresLoggerFactory(bool hasLoggerFactory, bool expectedHealthy)
+ {
+ var appData = CreateReceivingAppData();
+
+ appData.IsLoggingConfigured = true;
+ appData.LoggerFactory = hasLoggerFactory ? NullLoggerFactory.Instance : null;
+
+ var result = CreateController(appData).Get();
+
+ Assert.AreEqual(expectedHealthy, result is OkResult, "The health state with configured logging is not as expected!");
+ }
+
+ ///
+ /// Verifies that configured tracing with an attached activity listener reports a healthy server
+ ///
+ [TestMethod]
+ public void ServerHealthControllerGetConfiguredTracingWithListenerReturnsOk()
+ {
+ var appData = CreateReceivingAppData();
+
+ appData.IsTracingConfigured = true;
+
+ using var listener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == AppActivity.SrvSource.Name,
+ Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllData
+ };
+
+ ActivitySource.AddActivityListener(listener);
+
+ var result = CreateController(appData).Get();
+
+ Assert.IsInstanceOfType(result, "Configured tracing with an attached listener should report a healthy server!");
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file
diff --git a/F1Server.Tests/WebApi/Controllers/StatisticsControllerTests.cs b/F1Server.Tests/WebApi/Controllers/StatisticsControllerTests.cs
new file mode 100644
index 0000000..4c0edc4
--- /dev/null
+++ b/F1Server.Tests/WebApi/Controllers/StatisticsControllerTests.cs
@@ -0,0 +1,45 @@
+using F1Server.Data;
+using F1Server.WebApi.Controllers;
+
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace F1Server.Tests.WebApi.Controllers;
+
+///
+/// Contains unit tests verifying that the returns the telemetry statistics of the application
+///
+[TestClass]
+public class StatisticsControllerTests
+{
+ #region Methods
+
+ ///
+ /// Verifies that the statistics of the application data are returned
+ ///
+ [TestMethod]
+ public void StatisticsControllerGetReturnsApplicationStatistics()
+ {
+ var appData = new F1ServerApplicationData();
+ var controller = new StatisticsController(appData, NullLogger.Instance);
+
+ var statistics = controller.Get();
+
+ Assert.AreSame(appData.Statistics, statistics, "The statistics of the application data should be returned!");
+ }
+
+ ///
+ /// Verifies that missing application data returns empty statistics instead of failing
+ ///
+ [TestMethod]
+ public void StatisticsControllerGetWithoutApplicationDataReturnsEmptyStatistics()
+ {
+ var controller = new StatisticsController(null!, NullLogger.Instance);
+
+ var statistics = controller.Get();
+
+ Assert.IsNotNull(statistics, "Missing application data should still return statistics!");
+ Assert.AreEqual(0, statistics.PacketsReceivedTotal, "Statistics without application data should not contain received packets!");
+ }
+
+ #endregion // Methods
+}
\ No newline at end of file