Add project for automated tests

Had to remove the responsibility of providing the current version of the application from UpdateService. A new IVersionProvider can be used to get the version. This also allows for this to be mocked in tests.
This commit is contained in:
Pat Hartl 2025-03-15 21:00:00 -05:00
parent 8aff75dc2b
commit 86c906f8db
20 changed files with 337 additions and 74 deletions

View file

@ -5,7 +5,9 @@ using Microsoft.Extensions.Logging;
namespace LANCommander.Server.Services
{
public class BeaconService(ILogger<BeaconService> logger) : BaseService(logger), IHostedService, IDisposable
public class BeaconService(
ILogger<BeaconService> logger,
IVersionProvider versionProvider) : BaseService(logger), IHostedService, IDisposable
{
private Beacon _beacon;
@ -25,7 +27,7 @@ namespace LANCommander.Server.Services
{
_settings.Beacon?.Address,
_settings.Beacon?.Name,
UpdateService.GetCurrentVersion().ToString(),
versionProvider.GetCurrentVersion().ToString(),
};
_beacon.BeaconData = String.Join('|', dataParts);

View file

@ -0,0 +1,64 @@
using LANCommander.SDK;
using LANCommander.Server.Services.Factories;
using LANCommander.Server.Services.Importers;
using LANCommander.Server.Services.MediaGrabbers;
using LANCommander.Server.Services.Models;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Server.Services.Extensions;
public static class IServiceCollectionExtensions
{
public static IServiceCollection AddLANCommanderServer(this IServiceCollection services, Settings settings)
{
services.AddSingleton(new Client("", ""));
services.AddScoped<IdentityContextFactory>();
services.AddScoped<SettingService>();
services.AddScoped<ArchiveService>();
services.AddScoped<StorageLocationService>();
services.AddScoped<CategoryService>();
services.AddScoped<CollectionService>();
services.AddScoped<GameService>();
services.AddScoped<LibraryService>();
services.AddScoped<ScriptService>();
services.AddScoped<GenreService>();
services.AddScoped<PlatformService>();
services.AddScoped<KeyService>();
services.AddScoped<TagService>();
services.AddScoped<EngineService>();
services.AddScoped<CompanyService>();
services.AddScoped<IGDBService>();
services.AddScoped<ServerService>();
services.AddScoped<ServerConsoleService>();
services.AddScoped<GameSaveService>();
services.AddScoped<PlaySessionService>();
services.AddScoped<MediaService>();
services.AddScoped<RedistributableService>();
services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>();
services.AddScoped<UpdateService>();
services.AddScoped<IssueService>();
services.AddScoped<PageService>();
services.AddScoped<UserService>();
services.AddScoped<RoleService>();
services.AddScoped<UserCustomFieldService>();
services.AddScoped<AuthenticationService>();
services.AddTransient<SetupService>();
services.AddScoped(typeof(ImportService<>));
// Register importers
services.AddScoped<IImporter<Data.Models.Game>, GameImporter>();
services.AddScoped<IImporter<Data.Models.Server>, ServerImporter>();
services.AddScoped<IImporter<Data.Models.Redistributable>, RedistributableImporter>();
services.AddSingleton<ServerProcessService>();
services.AddSingleton<IPXRelayService>();
services.AddAutoMapper(typeof(MappingProfile));
services.AddFusionCache();
if (settings.Beacon.Enabled)
services.AddHostedService<BeaconService>();
return services;
}
}

View file

@ -0,0 +1,8 @@
using Semver;
namespace LANCommander.Server.Services;
public interface IVersionProvider
{
SemVersion GetCurrentVersion();
}

View file

@ -0,0 +1,75 @@
using System.Linq.Expressions;
using AutoMapper;
using LANCommander.SDK.Models;
namespace LANCommander.Server
{
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Data.Models.Action, SDK.Models.Action>();
CreateMap<Data.Models.Archive, SDK.Models.Archive>();
CreateMap<Data.Models.Company, SDK.Models.Company>();
CreateMap<Data.Models.Collection, SDK.Models.Collection>();
CreateMap<Data.Models.Engine, SDK.Models.Engine>();
CreateMap<Data.Models.GameSave, SDK.Models.GameSave>();
CreateMap<Data.Models.Genre, SDK.Models.Genre>();
CreateMap<Data.Models.Key, SDK.Models.Key>();
CreateMap<Data.Models.Media, SDK.Models.Media>();
CreateMap<Data.Models.MultiplayerMode, SDK.Models.MultiplayerMode>();
CreateMap<Data.Models.Platform, SDK.Models.Platform>();
CreateMap<Data.Models.PlaySession, SDK.Models.PlaySession>();
CreateMap<Data.Models.Redistributable, SDK.Models.Redistributable>();
CreateMap<Data.Models.Server, SDK.Models.Server>();
CreateMap<Data.Models.ServerConsole, SDK.Models.ServerConsole>();
CreateMap<Data.Models.ServerHttpPath, SDK.Models.ServerHttpPath>();
CreateMap<Data.Models.SavePath, SDK.Models.SavePath>();
CreateMap<Data.Models.Script, SDK.Models.Script>();
CreateMap<Data.Models.Tag, SDK.Models.Tag>();
CreateMap<Data.Models.User, SDK.Models.User>();
CreateMap<Data.Models.GameCustomField, SDK.Models.GameCustomField>();
CreateEntityReferenceMap<SDK.Models.Game>(g => g.Title);
CreateMap<Services.Models.Settings, SDK.Models.Settings>()
.ForMember(dest =>
dest.IPXRelayHost,
opt => opt.MapFrom(src => src.IPXRelay.Host))
.ForMember(dest =>
dest.IPXRelayPort,
opt => opt.MapFrom(src => src.IPXRelay.Port))
.ForMember(dest =>
dest.EnableUserLibraries,
opt => opt.MapFrom(src => src.Library.EnableUserLibraries));
CreateMap<Data.Models.Action, SDK.Models.Action>()
.ForMember(dest =>
dest.IsPrimaryAction,
opt => opt.MapFrom(src => src.PrimaryAction));
CreateMap<Data.Models.Game, SDK.Models.Game>()
.MaxDepth(5)
.ForMember(dest =>
dest.DependentGames,
opt => opt.MapFrom(src => src.DependentGames.Select(d => d.Id)));
CreateMap<Data.Models.Game, SDK.Models.DepotGame>()
.ForMember(dest =>
dest.Cover,
opt => opt.MapFrom(src => src.Media.Where(m => m.Type == SDK.Enums.MediaType.Cover).FirstOrDefault()));
CreateMap<Services.Models.AuthenticationProvider, SDK.Models.AuthenticationProvider>();
CreateMap<Data.Models.Game, EntityReference>()
.ForMember(dest => dest.Name,
opt => opt.MapFrom(src => src.Title));
}
private void CreateEntityReferenceMap<TEntity>(Expression<Func<TEntity, string>> nameMember)
{
CreateMap<TEntity, SDK.Models.EntityReference>()
.ForMember(dest => dest.Name, opt => opt.MapFrom(nameMember));
}
}
}

View file

@ -16,6 +16,7 @@ namespace LANCommander.Server.Services
public sealed class UpdateService(
ILogger<UpdateService> logger,
IHostApplicationLifetime applicationLifetime,
IVersionProvider versionProvider,
ServerService serverService,
ServerProcessService serverProcessService) : BaseService(logger)
{
@ -30,13 +31,6 @@ namespace LANCommander.Server.Services
_gitHub = new GitHubClient(new ProductHeaderValue(_repository));
}
public static SemVersion GetCurrentVersion()
{
var productVersion = FileVersionInfo.GetVersionInfo(Assembly.GetEntryAssembly().Location).ProductVersion;
return SemVersion.Parse(productVersion);
}
public async Task<IEnumerable<LauncherArtifact>> GetLauncherArtifactsAsync()
{
if (!_settings.Launcher.HostUpdates)
@ -47,7 +41,7 @@ namespace LANCommander.Server.Services
public IEnumerable<LauncherArtifact> GetLauncherArtifactsFromLocalFiles()
{
var currentVersion = GetCurrentVersion();
var currentVersion = versionProvider.GetCurrentVersion();
var downloadedLaunchers = Directory.GetFiles(_settings.Launcher.StoragePath, $"LANCommander.Launcher.*v{currentVersion.WithoutMetadata()}.zip");
foreach (var downloadedLauncher in downloadedLaunchers)
@ -56,7 +50,7 @@ namespace LANCommander.Server.Services
public async IAsyncEnumerable<LauncherArtifact> GetLauncherArtifactsFromGitHubAsync()
{
var currentVersion = GetCurrentVersion();
var currentVersion = versionProvider.GetCurrentVersion();
if (!String.IsNullOrWhiteSpace(_settings.Launcher.VersionOverride))
currentVersion = SemVersion.Parse(_settings.Launcher.VersionOverride, SemVersionStyles.AllowV);
@ -183,14 +177,14 @@ namespace LANCommander.Server.Services
if (SemVersion.TryParse(version, SemVersionStyles.AllowV, out SemVersion semVersion))
return semVersion;
else
return GetCurrentVersion();
return versionProvider.GetCurrentVersion();
}
public async Task<bool> UpdateAvailableAsync()
{
var latestVersion = await GetLatestVersionAsync();
var sortOrder = GetCurrentVersion().ComparePrecedenceTo(latestVersion);
var sortOrder = versionProvider.GetCurrentVersion().ComparePrecedenceTo(latestVersion);
return sortOrder < 0;
}

View file

@ -0,0 +1,41 @@
using System.Data.Common;
using LANCommander.Server.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Server.Tests;
public class ApplicationFactory<TProgram>
: WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(IDbContextOptionsConfiguration<DatabaseContext>));
services.Remove(dbContextDescriptor);
var dbConnectionDescriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbConnection));
services.Remove(dbConnectionDescriptor);
services.AddDbContextFactory<DatabaseContext>(optionsBuilder =>
{
optionsBuilder.UseInMemoryDatabase("Test");
});
services.AddDbContext<DatabaseContext>(optionsBuilder =>
{
optionsBuilder.UseInMemoryDatabase("Test");
});
});
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Shouldly;
namespace LANCommander.Server.Tests.Client;
public class AuthenticationTests :
IClassFixture<WebApplicationFactory<Program>>,
IClassFixture<ClientFixture>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly SDK.Client _client;
public AuthenticationTests(
WebApplicationFactory<Program> factory,
ClientFixture clientFixture)
{
_factory = factory;
_client = clientFixture.Client;
}
[Theory]
[InlineData("http://localhost:1337")] // Good users
[InlineData("https://localhost:1337")] // Uninformed users
[InlineData("localhost")] // Lazy users
[InlineData("localhost:1337")] // Forgivable users
[InlineData("localhost:1338")] // PEBKAC users
public async Task CanReachServer(string serverAddress)
{
var app = _factory.CreateClient();
await _client.ChangeServerAddressAsync(serverAddress);
_client.GetServerAddress().ShouldBe("http://localhost:1337");
}
}

View file

@ -0,0 +1,16 @@
namespace LANCommander.Server.Tests;
public class ClientFixture : IDisposable
{
public SDK.Client Client { get; set; }
public ClientFixture()
{
Client = new SDK.Client("", "C:\\Games");
}
public void Dispose()
{
Client.Disconnect();
}
}

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="System.Text.Encodings.Web" Version="9.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Features" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.9.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Server\LANCommander.Server.csproj" />
<ProjectReference Include="..\LANCommander.Server.Services\LANCommander.Server.Services.csproj" />
</ItemGroup>
</Project>

View file

@ -1,4 +1,5 @@
using LANCommander.SDK.Models;
using LANCommander.Server.Providers;
using LANCommander.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -11,12 +12,15 @@ namespace LANCommander.Server.Controllers.Api
[ApiController]
public class LauncherController : BaseApiController
{
private readonly IVersionProvider _versionProvider;
private readonly UpdateService UpdateService;
public LauncherController(
ILogger<LauncherController> logger,
IVersionProvider versionProvider,
UpdateService updateService) : base(logger)
{
_versionProvider = versionProvider;
UpdateService = updateService;
}
@ -24,7 +28,7 @@ namespace LANCommander.Server.Controllers.Api
[HttpGet("Download")]
public async Task<IActionResult> DownloadAsync()
{
var version = UpdateService.GetCurrentVersion();
var version = _versionProvider.GetCurrentVersion();
var settings = SettingService.GetSettings();
var fileName = $"LANCommander.Launcher-Windows-x64-v{version}.zip";
var path = Path.Combine(settings.Launcher.StoragePath, fileName);
@ -55,7 +59,7 @@ namespace LANCommander.Server.Controllers.Api
if (SemVersion.TryParse(launcherVersionString, SemVersionStyles.Any, out var launcherVersion))
{
var currentVersion = UpdateService.GetCurrentVersion();
var currentVersion = _versionProvider.GetCurrentVersion();
if (launcherVersion.ComparePrecedenceTo(currentVersion) < 0)
{

View file

@ -55,6 +55,10 @@
<ComponentScssFiles Include="**/*/*.scss" Exclude="node_modules/**;wwwroot/**;Styles/**" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="LANCommander.Server.Tests" />
</ItemGroup>
<ItemGroup>
<ComponentScssFiles Remove="node_modules\**" />
<TypeScriptCompile Remove="node_modules\**" />

View file

@ -6,9 +6,10 @@ namespace LANCommander.Server
{
public async Task InvokeAsync(HttpContext context)
{
var versionProvider = context.RequestServices.GetService<IVersionProvider>();
var headers = context.Response.Headers;
headers.Append("X-API-Version", UpdateService.GetCurrentVersion().ToString());
headers.Append("X-API-Version", versionProvider?.GetCurrentVersion().ToString());
await next(context);
}

View file

@ -86,3 +86,6 @@ app.GenerateThumbnails();
app.Run();
public partial class Program
{
}

View file

@ -0,0 +1,16 @@
using System.Diagnostics;
using System.Reflection;
using LANCommander.Server.Services;
using Semver;
namespace LANCommander.Server.Providers;
public class VersionProvider : IVersionProvider
{
public SemVersion GetCurrentVersion()
{
var version = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion;
return SemVersion.Parse(version);
}
}

View file

@ -1,8 +1,6 @@
using LANCommander.SDK;
using LANCommander.Server.Providers;
using LANCommander.Server.Services;
using LANCommander.Server.Services.Factories;
using LANCommander.Server.Services.Importers;
using LANCommander.Server.Services.MediaGrabbers;
using LANCommander.Server.Services.Extensions;
using LANCommander.Server.Services.Models;
using Serilog;
@ -13,60 +11,15 @@ public static class Services
public static WebApplicationBuilder AddLANCommanderServices(this WebApplicationBuilder builder, Settings settings)
{
Log.Debug("Registering services");
builder.Services.AddSingleton(new Client("", ""));
builder.Services.AddScoped<IdentityContextFactory>();
builder.Services.AddScoped<SettingService>();
builder.Services.AddScoped<ArchiveService>();
builder.Services.AddScoped<StorageLocationService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<CollectionService>();
builder.Services.AddScoped<GameService>();
builder.Services.AddScoped<LibraryService>();
builder.Services.AddScoped<ScriptService>();
builder.Services.AddScoped<GenreService>();
builder.Services.AddScoped<PlatformService>();
builder.Services.AddScoped<KeyService>();
builder.Services.AddScoped<TagService>();
builder.Services.AddScoped<EngineService>();
builder.Services.AddScoped<CompanyService>();
builder.Services.AddScoped<IGDBService>();
builder.Services.AddScoped<ServerService>();
builder.Services.AddScoped<ServerConsoleService>();
builder.Services.AddScoped<GameSaveService>();
builder.Services.AddScoped<PlaySessionService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<RedistributableService>();
builder.Services.AddScoped<IMediaGrabberService, SteamGridDBMediaGrabber>();
builder.Services.AddScoped<UpdateService>();
builder.Services.AddScoped<IssueService>();
builder.Services.AddScoped<PageService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<UserCustomFieldService>();
builder.Services.AddScoped<AuthenticationService>();
builder.Services.AddTransient<SetupService>();
builder.Services.AddScoped(typeof(ImportService<>));
// Register importers
builder.Services.AddScoped<IImporter<Data.Models.Game>, GameImporter>();
builder.Services.AddScoped<IImporter<Data.Models.Server>, ServerImporter>();
builder.Services.AddScoped<IImporter<Data.Models.Redistributable>, RedistributableImporter>();
builder.Services.AddSingleton<ServerProcessService>();
builder.Services.AddSingleton<IPXRelayService>();
builder.Services.AddAutoMapper(typeof(LANCommanderMappingProfile));
builder.Services.AddFusionCache();
builder.Services.AddLANCommanderServer(settings);
builder.Services.AddSingleton<IVersionProvider, VersionProvider>();
builder.Services.AddAntDesign();
builder.Services.AddHttpClient();
builder.Services.AddHttpContextAccessor();
if (settings.Beacon.Enabled)
{
Log.Debug("The beacons have been lit! LANCommander calls for players!");
builder.Services.AddHostedService<BeaconService>();
}
return builder;
}
}

View file

@ -7,6 +7,7 @@
@inject PageService PageService
@inject UpdateService UpdateService
@inject StorageLocationService StorageLocationService
@inject IVersionProvider VersionProvider
@inject IMessageService MessageService
@inject IFusionCache FusionCache
@inject ILogger<Index> Logger
@ -125,7 +126,7 @@
await Task.Yield();
await InvokeAsync(StateHasChanged);
var currentVersion = UpdateService.GetCurrentVersion();
var currentVersion = VersionProvider.GetCurrentVersion();
var currentRelease = await UpdateService.GetReleaseAsync(currentVersion);
if (currentRelease != null)

View file

@ -4,6 +4,7 @@
@using Semver
@inject IMessageService MessageService
@inject UpdateService UpdateService
@inject IVersionProvider VersionProvider
@inject ILogger<Updates> Logger
@attribute [Authorize(Roles = RoleService.AdministratorRoleName)]
@ -93,7 +94,7 @@
await InvokeAsync(StateHasChanged);
await Task.Yield();
CurrentVersion = UpdateService.GetCurrentVersion();
CurrentVersion = VersionProvider.GetCurrentVersion();
UpdateAvailable = await UpdateService.UpdateAvailableAsync();
LatestVersion = await UpdateService.GetLatestVersionAsync();
Releases = await UpdateService.GetReleasesAsync(5);

View file

@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inject IVersionProvider VersionProvider
<Layout Class="layout">
<Content Style="padding: 24px;">
@ -6,6 +7,6 @@
</Content>
<Footer Style="text-align: center; padding-top: 0;">
LANCommander v@(UpdateService.GetCurrentVersion().ToString())
LANCommander v@(VersionProvider.GetCurrentVersion().ToString())
</Footer>
</Layout>

View file

@ -1,4 +1,6 @@
@inherits LayoutComponentBase
@using LANCommander.Server.Providers
@inherits LayoutComponentBase
@inject IVersionProvider VersionProvider
@inject NavigationManager NavigationManager
<Layout Class="layout">
@ -17,7 +19,7 @@
</ErrorHandler>
<Footer>
LANCommander v@(UpdateService.GetCurrentVersion().WithoutMetadata().ToString())
LANCommander v@(VersionProvider.GetCurrentVersion().WithoutMetadata().ToString())
</Footer>
</Flex>
</Layout>

View file

@ -40,6 +40,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LANCommander.Server.Data.My
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LANCommander.Server.Data.PostgreSQL", "LANCommander.Server.Data.PostgreSQL\LANCommander.Server.Data.PostgreSQL.csproj", "{225A5DA1-9719-4EAA-A27E-C6C65D7324D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LANCommander.Server.Tests", "LANCommander.Server.Tests\LANCommander.Server.Tests.csproj", "{C6B4CEDF-761B-48FC-B6FE-5A66EF2412BE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -110,6 +112,10 @@ Global
{225A5DA1-9719-4EAA-A27E-C6C65D7324D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{225A5DA1-9719-4EAA-A27E-C6C65D7324D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{225A5DA1-9719-4EAA-A27E-C6C65D7324D1}.Release|Any CPU.Build.0 = Release|Any CPU
{C6B4CEDF-761B-48FC-B6FE-5A66EF2412BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6B4CEDF-761B-48FC-B6FE-5A66EF2412BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6B4CEDF-761B-48FC-B6FE-5A66EF2412BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6B4CEDF-761B-48FC-B6FE-5A66EF2412BE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE