diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 0909612..ff39791 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup .NET Core uses: actions/setup-dotnet@v3.2.0 with: @@ -21,5 +21,5 @@ jobs: run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - # - name: Test - # run: dotnet test --no-restore --verbosity normal + - name: Test + run: dotnet test --no-restore --verbosity normal diff --git a/.gitignore b/.gitignore index 03b6e2d..1ecb8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Robust.Cdn/content.db* Robust.Cdn/manifest.db* *.user testData/ +/.vs/** diff --git a/Directory.Packages.props b/Directory.Packages.props index 6573608..4654292 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,5 +14,10 @@ + + + + + \ No newline at end of file diff --git a/Robust.Cdn.Tests/ControllerTestCollection.cs b/Robust.Cdn.Tests/ControllerTestCollection.cs new file mode 100644 index 0000000..8a6cb0b --- /dev/null +++ b/Robust.Cdn.Tests/ControllerTestCollection.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests; + +/// +/// Defines a test collection that uses DatabaseFixture for cleanup. +/// +[CollectionDefinition("ControllerTests")] +public sealed class ControllerTestCollection + : ICollectionFixture>, ICollectionFixture +{ +} diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs new file mode 100644 index 0000000..cedabaa --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for StatusController. +/// +/// Endpoint: GET /control/status +/// +[Trait("Category", "Integration")] +public sealed class StatusControllerTests(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + protected override string ForkName => "testfork"; + + protected override Dictionary GetConfigurationOverrides() + { + var baseDir = Database.CreateTestVersionOnDisk(ForkName, "1.0.0"); + return new() + { + ["Manifest:FileDiskPath"] = baseDir, + }; + } + + [Fact] + public async Task GetControlStatus_ReturnsOkWithVersionInfo() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync("/control/status"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("OK", content); + Assert.Contains("contentVersions", content); + Assert.Contains("version", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetControlStatus_WithExistingVersion_ReturnsNonZeroCount() + { + var client = Factory.CreateClient(); + + // The ingest job runs asynchronously at startup. Poll until we see version info. + await PollUntilCondition(() => client.GetAsync("/control/status"), async response => + { + var body = await response.Content.ReadAsStringAsync(); + var deserialized = JsonSerializer.Deserialize>(body); + return deserialized != null + && deserialized.TryGetValue("contentVersions", out var raw) + && int.TryParse(raw?.ToString(), out var count) + && count > 0; + }); + } +} + diff --git a/Robust.Cdn.Tests/DatabaseFixture.cs b/Robust.Cdn.Tests/DatabaseFixture.cs new file mode 100644 index 0000000..d8a82a2 --- /dev/null +++ b/Robust.Cdn.Tests/DatabaseFixture.cs @@ -0,0 +1,56 @@ +using System.IO.Compression; + +namespace Robust.Cdn.Tests; + +/// +/// Collection fixture that tracks all temp database files created during a test collection +/// and cleans them up when the collection is done. +/// +public sealed class DatabaseFixture : IDisposable +{ + private readonly List _tempFiles = new(); + private readonly List _tempDirs = new(); + + public string CreateTempDb() + { + var path = Path.GetTempFileName(); + _tempFiles.Add(path); + return path; + } + + /// + /// Creates a fork directory with a version directory and a client zip for the ingest job to find. + /// Returns the base path that should be used as FileDiskPath. + /// + public string CreateTestVersionOnDisk(string fork, string version, string clientZipName = "test") + { + var baseDir = Path.Combine(Path.GetTempPath(), "RobustCdnTests", Guid.NewGuid().ToString()); + var versionDir = Path.Combine(baseDir, fork, version); + Directory.CreateDirectory(versionDir); + _tempDirs.Add(baseDir); + + var zipPath = Path.Combine(versionDir, $"{clientZipName}.zip"); + using (var zipStream = File.Create(zipPath)) + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("test.txt"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("test content"); + } + + return baseDir; + } + + public void Dispose() + { + foreach (var file in _tempFiles) + { + try { File.Delete(file); } catch { } + } + + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } +} diff --git a/Robust.Cdn.Tests/Properties/launchSettings.json b/Robust.Cdn.Tests/Properties/launchSettings.json new file mode 100644 index 0000000..1d29fe6 --- /dev/null +++ b/Robust.Cdn.Tests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Robust.Cdn.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58678;http://localhost:58679" + } + } +} \ No newline at end of file diff --git a/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj b/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj new file mode 100644 index 0000000..ec8fd3f --- /dev/null +++ b/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs new file mode 100644 index 0000000..cbca5f5 --- /dev/null +++ b/Robust.Cdn.Tests/TestBase.cs @@ -0,0 +1,91 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests; + +/// +/// Base class for integration tests providing a pre-configured WebApplicationFactory +/// and automatic cleanup of temp database files after the test collection finishes. +/// +[Collection("ControllerTests")] +public abstract class TestBase : IClassFixture +{ + protected WebApplicationFactory Factory { get; } + protected DatabaseFixture Database { get; } + + protected abstract string ForkName { get; } + + protected TestBase(WebApplicationFactory factory, DatabaseFixture database) + { + Database = database; + + var config = new Dictionary(GetDefaultConfiguration()); + foreach (var (key, value) in GetConfigurationOverrides()) + { + config[key] = value; + } + + Factory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(config); + }); + }); + } + + /// + /// Provides default configuration for all tests. + /// Override to change defaults globally. + /// + private Dictionary GetDefaultConfiguration() + { + return new() + { + ["Cdn:DatabaseFileName"] = Database.CreateTempDb(), + ["Manifest:DatabaseFileName"] = Database.CreateTempDb(), + ["Manifest:FileDiskPath"] = Path.GetTempPath(), + [$"Manifest:Forks:{ForkName}:ClientZipName"] = "test", + [$"Manifest:Forks:{ForkName}:BuildsPageLinkText"] = "test", + }; + } + + /// + /// Override to add or replace specific configuration keys for a particular test class. + /// Returned values take precedence over GetDefaultConfiguration(). + /// Please do not use there, as this method is used + /// in c-tor or base class and factory won't be set up yet (config is required for factory). + /// + protected virtual Dictionary GetConfigurationOverrides() => new(); + + /// + /// Polls a request until it returns OK or the timeout is reached. + /// Useful for waiting on async jobs triggered during startup (e.g. version ingestion). + /// + protected static async Task PollUntilCondition( + Func> factory, + Func> condition, + int maxAttempts = 20, + int delayMs = 200) + { + for (var i = 0; i < maxAttempts; i++) + { + var response = await factory(); + if (await condition(response)) + return response; + + await Task.Delay(delayMs); + } + + throw new TimeoutException($"Request did not return OK after {maxAttempts * delayMs}ms"); + } + + /// + /// Polls a GET request until it returns OK. + /// + protected static Task PollUntilOk(Func> factory, int maxAttempts = 20, int delayMs = 200) + { + return PollUntilCondition(factory, response => Task.FromResult(response.StatusCode == HttpStatusCode.OK), maxAttempts, delayMs); + } +} + diff --git a/Robust.Cdn.sln b/Robust.Cdn.sln deleted file mode 100644 index 5c42122..0000000 --- a/Robust.Cdn.sln +++ /dev/null @@ -1,41 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn", "Robust.Cdn\Robust.Cdn.csproj", "{4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution Files", "{D1E362E7-E406-42E1-A58D-F3318A12ACB6}" - ProjectSection(SolutionItems) = preProject - .gitignore = .gitignore - .gitattributes = .gitattributes - .editorconfig = .editorconfig - LICENSE.txt = LICENSE.txt - Dockerfile = Dockerfile - .dockerignore = .dockerignore - Directory.Packages.props = Directory.Packages.props - RELEASE-NOTES.md = RELEASE-NOTES.md - README.md = README.md - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn.Lib", "Robust.Cdn.Lib\Robust.Cdn.Lib.csproj", "{9B66C804-46C1-4D91-A028-8D12E25827CA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn.Downloader", "Robust.Cdn.Downloader\Robust.Cdn.Downloader.csproj", "{937F7101-5979-4A47-ABF4-36405B839D4A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Release|Any CPU.Build.0 = Release|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Release|Any CPU.Build.0 = Release|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Robust.Cdn.slnx b/Robust.Cdn.slnx new file mode 100644 index 0000000..2538b03 --- /dev/null +++ b/Robust.Cdn.slnx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Robust.Cdn/Program.cs b/Robust.Cdn/Program.cs index 53815b5..1b03d8a 100644 --- a/Robust.Cdn/Program.cs +++ b/Robust.Cdn/Program.cs @@ -14,59 +14,7 @@ // Add services to the container. -builder.Services.Configure(builder.Configuration.GetSection(CdnOptions.Position)); -builder.Services.Configure(builder.Configuration.GetSection(ManifestOptions.Position)); - -builder.Services.AddControllersWithViews(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(services => services.GetRequiredService()); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddQuartz(q => -{ - q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); - q.AddJob(j => - { - j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); - }); - q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); - q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); - q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => - { - schedule.RepeatForever().WithIntervalInHours(24); - })); - q.ScheduleJob(t => - t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); -}); - -builder.Services.AddQuartzHostedService(q => -{ - q.WaitForJobsToComplete = true; -}); - -const string userAgent = "Robust.Cdn"; - -builder.Services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => -{ - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); -}); -builder.Services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => -{ - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); -}); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddHttpContextAccessor(); - -/* -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -*/ +SetupDependencies(builder.Services, builder.Configuration); var app = builder.Build(); @@ -83,7 +31,31 @@ app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools); { - using var initScope = app.Services.CreateScope(); + if (!await TryInit(app.Services)) + return 1; +} +/* +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ +app.UseSwagger(); +app.UseSwaggerUI(); +} +*/ + +// app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +await app.RunAsync(); + +return 0; + +static async Task TryInit(IServiceProvider sp) +{ + using var initScope = sp.CreateScope(); var services = initScope.ServiceProvider; var logFactory = services.GetRequiredService(); var loggerStartup = logFactory.CreateLogger("Robust.Cdn.Program"); @@ -94,13 +66,13 @@ if (string.IsNullOrEmpty(manifestOptions.FileDiskPath)) { loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!"); - return 1; + return false; } if (manifestOptions.Forks.Count == 0) { loggerStartup.LogCritical("No forks defined in Manifest configuration!"); - return 1; + return false; } loggerStartup.LogDebug("Running migrations!"); @@ -109,7 +81,7 @@ var success = Migrator.Migrate(services, loggerMigrator, db.Connection, "Robust.Cdn.Migrations"); success &= Migrator.Migrate(services, loggerMigrator, manifestDb.Connection, "Robust.Cdn.ManifestMigrations"); if (!success) - return 1; + return false; loggerStartup.LogDebug("Done running migrations!"); @@ -122,22 +94,66 @@ { await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork)); } + + return true; } -/* -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) + +static void SetupDependencies(IServiceCollection services, IConfiguration configuration) { - app.UseSwagger(); - app.UseSwaggerUI(); -} -*/ + services.Configure(configuration.GetSection(CdnOptions.Position)); + services.Configure(configuration.GetSection(ManifestOptions.Position)); + + services.AddControllersWithViews(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(s => s.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(TimeProvider.System); + services.AddQuartz(q => + { + q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); + q.AddJob(j => + { + j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); + }); + q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); + q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); + q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => + { + schedule.RepeatForever().WithIntervalInHours(24); + })); + q.ScheduleJob(t => + t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); + }); -// app.UseHttpsRedirection(); + services.AddQuartzHostedService(q => + { + q.WaitForJobsToComplete = true; + }); -app.UseAuthorization(); + const string userAgent = "Robust.Cdn"; -app.MapControllers(); + services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); + services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); -await app.RunAsync(); + services.AddScoped(); + services.AddScoped(); + services.AddHttpContextAccessor(); -return 0; + /* + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + */ +} + +// required for visibility of entry-point to test framework +public partial class Program { }