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 { }