using Artemis.Core.DryIoc; using Artemis.Core.Services; using Artemis.Core.DryIoc.Factories; using Artemis.Storage; using DryIoc; using DryIoc.Microsoft.DependencyInjection; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Artemis.Core.Tests.Infrastructure.Services; /// /// A DryIoc container with the Artemis.Core services registered the way the app registers them, but backed by a fresh /// database in a temporary data folder and with logging going to the test output. /// /// The database is a copy of one that is migrated once per test run, and the temporary folders are removed when the /// test run ends rather than after every test. Both would otherwise make up most of the time service tests take. /// /// /// Services are created when they are first resolved, nothing is initialized or started. Avoid anything that works /// on or directly, such as loading plugins /// or starting the web server, those still point at your real Artemis folders. /// /// public sealed class ServiceHarness : IDisposable { private static readonly string RunFolder = CreateRunFolder(); private static readonly Lazy TemplateDatabase = new(CreateTemplateDatabase); private readonly List _profileServices = []; private bool _disposed; /// Optionally replaces or adds registrations before anything is resolved public ServiceHarness(Action? configure = null) { DataFolder = Path.Combine(RunFolder, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(DataFolder); File.Copy(TemplateDatabase.Value, Path.Combine(DataFolder, "artemis.db")); // Same rules as ArtemisBootstrapper Container = new Container(rules => rules .WithMicrosoftDependencyInjectionRules() .WithConcreteTypeDynamicRegistrations() .WithoutThrowOnRegisteringDisposableTransient()); Container.RegisterCore(); // The real registrations use the database and log folder in your Artemis data folder Container.RegisterDelegate(CreateDbContext, Reuse.Transient, ifAlreadyRegistered: IfAlreadyRegistered.Replace); Container.Register(Reuse.Singleton, ifAlreadyRegistered: IfAlreadyRegistered.Replace); // Remember profile services so their profiles can be disposed, see Dispose Container.RegisterInitializer((service, _) => _profileServices.Add(service)); configure?.Invoke(Container); } /// /// Gets the temporary data folder containing the database, removed when the test run ends /// public string DataFolder { get; } /// /// Gets the container the services are registered in /// public IContainer Container { get; } /// /// Resolves a service, including protected services like the settings service which the container only hands to /// classes outside the plugins folder. /// public T Resolve() where T : notnull { return Container.New>().Service; } /// /// Creates a database context on the database of this harness, for checking what a service stored /// public ArtemisDbContext CreateDbContext() { return new ArtemisDbContext {DataFolder = DataFolder}; } /// public void Dispose() { if (_disposed) return; _disposed = true; // Profiles subscribe to static events of Core's stores, dispose them like the profile service does when it // deactivates a profile. Otherwise every profile of every test stays in memory and receives the store events of // later tests foreach (ProfileConfiguration configuration in _profileServices.SelectMany(s => s.ProfileCategories).SelectMany(c => c.ProfileConfigurations)) { if (configuration.Profile is {Disposed: false} profile) profile.Dispose(); configuration.Dispose(); } Container.Dispose(); } private static string CreateRunFolder() { string root = Path.Combine(Path.GetTempPath(), "Artemis.Core.Tests"); // Remove what interrupted test runs left behind, leaving recent folders alone since other test runs may be using them if (Directory.Exists(root)) { foreach (DirectoryInfo folder in new DirectoryInfo(root).GetDirectories().Where(d => d.LastWriteTimeUtc < DateTime.UtcNow.AddHours(-1))) TryDelete(folder.FullName); } string runFolder = Path.Combine(root, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(runFolder); AppDomain.CurrentDomain.ProcessExit += (_, _) => { // SQLite keeps the database files open in its connection pool SqliteConnection.ClearAllPools(); TryDelete(runFolder); }; return runFolder; } private static string CreateTemplateDatabase() { // StorageManager only migrates the first database of a process, so migrate the template ourselves string folder = Path.Combine(RunFolder, "template"); Directory.CreateDirectory(folder); using (ArtemisDbContext context = new() {DataFolder = folder}) context.Database.Migrate(); // Close the template so it can be copied SqliteConnection.ClearAllPools(); return Path.Combine(folder, "artemis.db"); } private static void TryDelete(string folder) { try { Directory.Delete(folder, true); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { // Left for the next test run, it is in the temp folder } } } /// /// Receives a service through its constructor, the container hands protected services to it since it lives in the test /// assembly rather than in the plugins folder. /// internal sealed class ServiceResolver(T service) { public T Service { get; } = service; }