diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e819ecc763496f81ce8511e028ca3c47078704ca..9973627f3925e55a43e17a852fd066ec6ef0b750 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ include: stages: - build + - test - publish variables: @@ -17,6 +18,9 @@ build-branch: build-nuget-release: extends: .build-nuget-release +test: + extends: .test + publish-gitlab-release: extends: .publish-gitlab-release diff --git a/src/KpiGenerator.Tests/KpiGenerator.Tests.csproj b/src/KpiGenerator.Tests/KpiGenerator.Tests.csproj new file mode 100644 index 0000000000000000000000000000000000000000..f0551f1dfd963abf40463911f4ab67aecf8e0d52 --- /dev/null +++ b/src/KpiGenerator.Tests/KpiGenerator.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="NSubstitute" Version="5.3.0" /> + <PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\KpiGenerator\KpiGenerator.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/KpiGenerator.Tests/ProjectReportingTests.cs b/src/KpiGenerator.Tests/ProjectReportingTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..5cd8eb95c982434bb0d5ad81c627b0fc0461d222 --- /dev/null +++ b/src/KpiGenerator.Tests/ProjectReportingTests.cs @@ -0,0 +1,294 @@ +using AutoMapper; +using Coscine.ApiClient.Core.Api; +using Coscine.ApiClient.Core.Model; +using Coscine.KpiGenerator.MappingProfiles; +using Coscine.KpiGenerator.Models; +using Coscine.KpiGenerator.Models.ConfigurationModels; +using Coscine.KpiGenerator.Reportings.Project; +using Coscine.KpiGenerator.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.Extensions; +using static Coscine.KpiGenerator.Models.ConfigurationModels.ReportingConfiguration; +using static KPIGenerator.Utils.CommandLineOptions; + +namespace KpiGenerator.Tests; + +[TestFixture] +public class ProjectReportingTests +{ + private IMapper _mapper = null!; + private ILogger<ProjectReporting> _logger = null!; + private IStorageService _gitlabStorageService = null!; + private IStorageService _localStorageService = null!; + private IOptionsMonitor<KpiConfiguration> _kpiConfiguration = null!; + private IOptionsMonitor<ReportingConfiguration> _reportingConfiguration = null!; + + private IAdminApi _adminApi = null!; + private IProjectQuotaApi _projectQuotaApi = null!; + + private ProjectReporting _projectReporting = null!; // System Under Test + + [SetUp] + public void SetUp() + { + // NSubstitute Mocks + _logger = Substitute.For<ILogger<ProjectReporting>>(); + _gitlabStorageService = Substitute.For<IStorageService>(); + _localStorageService = Substitute.For<IStorageService>(); + + // Mock IOptionsMonitor + var kpiConfig = new KpiConfiguration + { + ProjectKpi = new("project_reporting.json") + }; + _kpiConfiguration = Substitute.For<IOptionsMonitor<KpiConfiguration>>(); + _kpiConfiguration.CurrentValue.Returns(kpiConfig); + + // Create a real mapper + _mapper = new MapperConfiguration(cfg => + { + cfg.AddProfile<MappingProfiles>(); + }).CreateMapper(); + + // Mock ReportingConfiguration + var reportingConfig = new ReportingConfiguration + { + Endpoint = "https://some-endpoint/api", + ApiKey = "dummy-api-key", + // Possibly fill Organization as needed + Organization = new OrganizationConfiguration + { + OtherOrganization = new() + { + Name = "Other", + RorUrl = "https://ror.org/_other", + } + } + }; + _reportingConfiguration = Substitute.For<IOptionsMonitor<ReportingConfiguration>>(); + _reportingConfiguration.CurrentValue.Returns(reportingConfig); + + _adminApi = Substitute.For<IAdminApi>(); + _projectQuotaApi = Substitute.For<ProjectQuotaApi>(); + } + + #region GenerateReportingAsync Tests + + [Test] + public async Task GenerateReportingAsync_ReturnsGeneralAndPerOrgFiles_WhenProjectsExist() + { + // Arrange + var projects = TestData.ProjectAdminDtos; + + _adminApi + .GetAllProjectsAsync( + includeDeleted: Arg.Any<bool>(), + includeQuotas: Arg.Any<bool>(), + includePublicationRequests: Arg.Any<bool>(), + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test projects data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: projects, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _projectReporting = new ProjectReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + + // Act + var result = await _projectReporting.GenerateReportingAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count(), Is.EqualTo(3), "Expected 3 reporting files."); + + var generalFile = result.FirstOrDefault(r => r.Path.Contains("general", StringComparison.OrdinalIgnoreCase) + || r.Path.EndsWith("project_reporting.json")); + Assert.That(generalFile, Is.Not.Null, "General file should exist."); + + var orgFile1 = result.FirstOrDefault(r => r.Path.Contains("12345")); + Assert.That(orgFile1, Is.Not.Null, "Per-organization file for ror.org/12345 should exist."); + + var orgFile2 = result.FirstOrDefault(r => r.Path.Contains("54321")); + Assert.That(orgFile2, Is.Not.Null, "Per-organization file for ror.org/54321 should exist."); + } + + [Test] + public async Task GenerateReportingAsync_ReturnsOnlyGeneralFile_WhenNoProjects() + { + // Arrange + _adminApi + .GetAllProjectsAsync() + .Returns(ci => + { + // No projects, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _projectReporting = new ProjectReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + + // Act + var result = await _projectReporting.GenerateReportingAsync(); + + // Assert + // We expect 1 file: the "general" file (with an empty list of projects). + Assert.That(result.Count(), Is.EqualTo(1)); + var file = result.First(); + Assert.That(file.Path, Does.Contain("project_reporting.json").Or.Contain("general")); + } + + #endregion + + #region RunAsync Tests + + [Test] + public async Task RunAsync_WhenGitlabPublishSucceeds_ShouldReturnTrue() + { + // Arrange + var options = new ProjectReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "foo", Content = new MemoryStream() } + }; + + // We want to ensure that GenerateReportingAsync returns some test objects + _projectReporting = Substitute.ForPartsOf<ProjectReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + _projectReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => success + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Local storage shouldn't be called if GitLab is successful + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); // default + + // Act + var result = await _projectReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if GitLab publish succeeds."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("Project Reporting", reportingFiles); + + // Verify Local storage was never called + await _localStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + } + + [Test] + public async Task RunAsync_WhenGitlabPublishFails_ShouldFallbackToLocalStorage() + { + // Arrange + var options = new ProjectReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "bar", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _projectReporting = Substitute.ForPartsOf<ProjectReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + _projectReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => fails + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); + + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _projectReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds after GitLab fails."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("Project Reporting", reportingFiles); + + // Verify fallback to local was called + await _localStorageService.Received(1) + .PublishAsync("Project Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenInDummyMode_ShouldSkipGitlabAndPublishToLocal() + { + // Arrange + var options = new ProjectReportingOptions { DummyMode = true }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "dummy", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _projectReporting = Substitute.ForPartsOf<ProjectReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + _projectReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => should not be called + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _projectReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds in DummyMode."); + + // Verify GitLab wasn't called + await _gitlabStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + + // Verify local storage was called + await _localStorageService.Received(1) + .PublishAsync("Project Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenBothGitlabAndLocalFail_ShouldReturnFalse() + { + // Arrange + var options = new ProjectReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "fail", Content = new MemoryStream() } + }; + + // Partial mock + _projectReporting = Substitute.ForPartsOf<ProjectReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _projectQuotaApi); + _projectReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // GitLab fails + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // Local also fails + + // Act + var result = await _projectReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.False, "Expected RunAsync to return false if both GitLab and local publish fail."); + } + + #endregion +} diff --git a/src/KpiGenerator.Tests/ResourceReportingTests.cs b/src/KpiGenerator.Tests/ResourceReportingTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..ebd6030c7a728c925b138f7a6d380d89ffd8022f --- /dev/null +++ b/src/KpiGenerator.Tests/ResourceReportingTests.cs @@ -0,0 +1,312 @@ +using AutoMapper; +using Coscine.ApiClient.Core.Api; +using Coscine.ApiClient.Core.Model; +using Coscine.KpiGenerator.MappingProfiles; +using Coscine.KpiGenerator.Models; +using Coscine.KpiGenerator.Models.ConfigurationModels; +using Coscine.KpiGenerator.Reportings.Resource; +using Coscine.KpiGenerator.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.Extensions; +using static Coscine.KpiGenerator.Models.ConfigurationModels.ReportingConfiguration; +using static KPIGenerator.Utils.CommandLineOptions; + +namespace KpiGenerator.Tests; + +[TestFixture] +public class ResourceReportingTests +{ + private IMapper _mapper = null!; + private ILogger<ResourceReporting> _logger = null!; + private IStorageService _gitlabStorageService = null!; + private IStorageService _localStorageService = null!; + private IOptionsMonitor<KpiConfiguration> _kpiConfiguration = null!; + private IOptionsMonitor<ReportingConfiguration> _reportingConfiguration = null!; + + private IAdminApi _adminApi = null!; + + private ResourceReporting _resourceReporting = null!; // System Under Test + + [SetUp] + public void SetUp() + { + // NSubstitute Mocks + _logger = Substitute.For<ILogger<ResourceReporting>>(); + _gitlabStorageService = Substitute.For<IStorageService>(); + _localStorageService = Substitute.For<IStorageService>(); + + // Mock IOptionsMonitor + var kpiConfig = new KpiConfiguration + { + ResourceKpi = new("resource_reporting.json") + }; + _kpiConfiguration = Substitute.For<IOptionsMonitor<KpiConfiguration>>(); + _kpiConfiguration.CurrentValue.Returns(kpiConfig); + + // Create a real mapper + _mapper = new MapperConfiguration(cfg => + { + cfg.AddProfile<MappingProfiles>(); + }).CreateMapper(); + + // Mock ReportingConfiguration + var reportingConfig = new ReportingConfiguration + { + Endpoint = "https://some-endpoint/api", + ApiKey = "dummy-api-key", + // Possibly fill Organization as needed + Organization = new OrganizationConfiguration + { + OtherOrganization = new() + { + Name = "Other", + RorUrl = "https://ror.org/_other", + } + } + }; + _reportingConfiguration = Substitute.For<IOptionsMonitor<ReportingConfiguration>>(); + _reportingConfiguration.CurrentValue.Returns(reportingConfig); + + _adminApi = Substitute.For<IAdminApi>(); + } + + #region GenerateReportingAsync Tests + + [Test] + public async Task GenerateReportingAsync_ReturnsGeneralAndPerOrgFiles_WhenProjectsExist() + { + // Arrange + var projects = TestData.ProjectAdminDtos; + var resources = TestData.ResourceAdminDtos; + + _adminApi + .GetAllProjectsAsync( + includeDeleted: Arg.Any<bool>(), + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test projects data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: projects, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _adminApi + .GetAllResourcesAsync( + includeDeleted: Arg.Any<bool>(), + includeQuotas: Arg.Any<bool>(), + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test resources data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new ResourceAdminDtoPagedResponse(data: resources, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _resourceReporting = new ResourceReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + + // Act + var result = await _resourceReporting.GenerateReportingAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count(), Is.EqualTo(3), "Expected 3 reporting files."); + + var generalFile = result.FirstOrDefault(r => r.Path.Contains("general", StringComparison.OrdinalIgnoreCase) + || r.Path.EndsWith("resource_reporting.json")); + Assert.That(generalFile, Is.Not.Null, "General file should exist."); + + var orgFile1 = result.FirstOrDefault(r => r.Path.Contains("12345")); + Assert.That(orgFile1, Is.Not.Null, "Per-organization file for ror.org/12345 should exist."); + + var orgFile2 = result.FirstOrDefault(r => r.Path.Contains("54321")); + Assert.That(orgFile2, Is.Not.Null, "Per-organization file for ror.org/54321 should exist."); + } + + [Test] + public async Task GenerateReportingAsync_ReturnsOnlyGeneralFile_WhenNoProjects() + { + // Arrange + _adminApi + .GetAllProjectsAsync() + .Returns(ci => + { + // No projects, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _adminApi + .GetAllResourcesAsync() + .Returns(ci => + { + // No resources, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new ResourceAdminDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _resourceReporting = new ResourceReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + + // Act + var result = await _resourceReporting.GenerateReportingAsync(); + + // Assert + // We expect 1 file: the "general" file (with an empty list of projects). + Assert.That(result.Count(), Is.EqualTo(1)); + var file = result.First(); + Assert.That(file.Path, Does.Contain("resource_reporting.json").Or.Contain("general")); + } + + #endregion + + #region RunAsync Tests + + [Test] + public async Task RunAsync_WhenGitlabPublishSucceeds_ShouldReturnTrue() + { + // Arrange + var options = new ResourceReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "foo", Content = new MemoryStream() } + }; + + // We want to ensure that GenerateReportingAsync returns some test objects + _resourceReporting = Substitute.ForPartsOf<ResourceReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + _resourceReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => success + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Local storage shouldn't be called if GitLab is successful + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); // default + + // Act + var result = await _resourceReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if GitLab publish succeeds."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("Resource Reporting", reportingFiles); + + // Verify Local storage was never called + await _localStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + } + + [Test] + public async Task RunAsync_WhenGitlabPublishFails_ShouldFallbackToLocalStorage() + { + // Arrange + var options = new ResourceReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "bar", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _resourceReporting = Substitute.ForPartsOf<ResourceReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + _resourceReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => fails + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); + + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _resourceReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds after GitLab fails."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("Resource Reporting", reportingFiles); + + // Verify fallback to local was called + await _localStorageService.Received(1) + .PublishAsync("Resource Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenInDummyMode_ShouldSkipGitlabAndPublishToLocal() + { + // Arrange + var options = new ResourceReportingOptions { DummyMode = true }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "dummy", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _resourceReporting = Substitute.ForPartsOf<ResourceReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + _resourceReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => should not be called + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _resourceReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds in DummyMode."); + + // Verify GitLab wasn't called + await _gitlabStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + + // Verify local storage was called + await _localStorageService.Received(1) + .PublishAsync("Resource Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenBothGitlabAndLocalFail_ShouldReturnFalse() + { + // Arrange + var options = new ResourceReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "fail", Content = new MemoryStream() } + }; + + // Partial mock + _resourceReporting = Substitute.ForPartsOf<ResourceReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi); + _resourceReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // GitLab fails + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // Local also fails + + // Act + var result = await _resourceReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.False, "Expected RunAsync to return false if both GitLab and local publish fail."); + } + + #endregion +} diff --git a/src/KpiGenerator.Tests/TestData.cs b/src/KpiGenerator.Tests/TestData.cs new file mode 100644 index 0000000000000000000000000000000000000000..3566ead311a48847cc0ea34dc466b3ee3805a3b3 --- /dev/null +++ b/src/KpiGenerator.Tests/TestData.cs @@ -0,0 +1,150 @@ +using Coscine.ApiClient.Core.Model; + +static class TestData +{ + public static List<ProjectAdminDto> ProjectAdminDtos => ReturnProjectAdminDtos(); + public static List<ResourceAdminDto> ResourceAdminDtos => ReturnResourceAdminDtos(); + public static List<UserDto> UserDtos => ReturnUserDtos(); + public static List<RoleDto> RoleDtos => ReturnRoleDtos(); + + + private static List<RoleDto> ReturnRoleDtos() + { + return + [ + new(id: Guid.Parse("778f7bd2-128b-45aa-a093-807da3b9aecc"), + displayName: "Role One", + description: "Role One Description" + ), + new(id: Guid.Parse("6e5aadc3-72ea-4f96-827d-90c5c15d34af"), + displayName: "Role Two", + description: "Role Two Description" + ) + ]; + } + + private static List<UserDto> ReturnUserDtos() + { + return + [ + new(id: Guid.Parse("fd971cef-7c02-45df-97c4-ef757c05bc19"), + displayName: "User One", + givenName: "User", + familyName: "One", + emails: [ new(email: "one@user.com", isConfirmed: true, isPrimary: true) ], + language: new(id: Guid.NewGuid(), displayName: "English", abbreviation: "en"), + areToSAccepted: true, + latestActivity: DateTime.UtcNow.AddHours(-1), + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], + organizations: [new(uri: "https://ror.org/12345", displayName: "Organization One", readOnly: true)], + identities: [new(id: Guid.NewGuid(), displayName: "Provider One")] + ), + new(id: Guid.Parse("21c95165-8b44-46a5-b711-4d4e0c54eda2"), + displayName: "User Two", + givenName: "User", + familyName: "Two", + emails: [ new(email: "two@user.com", isConfirmed: true, isPrimary: true) ], + language: new(id: Guid.NewGuid(), displayName: "English", abbreviation: "en"), + areToSAccepted: true, + latestActivity: DateTime.UtcNow.AddHours(-3), + disciplines: [new(id: Guid.NewGuid(), uri: "https://two.discipline.org", displayNameEn: "Discipline Two", displayNameDe: "Disziplin Zwei")], + organizations: [new(uri: "https://ror.org/54321", displayName: "Organization Two", readOnly: true)], + identities: [new(id: Guid.NewGuid(), displayName: "Provider Two")] + ) + ]; + } + + private static List<ResourceAdminDto> ReturnResourceAdminDtos() + { + return + [ + new(id: Guid.Parse("a5092027-0d0b-4be6-9025-5a2397f34fce"), + name: "Resource One", + displayName: "Resource One", + description: "Resource One Description", + pid: "resource-one", + type: new(generalType: "ds", specificType: "dsweb"), + dateCreated: DateTime.UtcNow, + creator: new() { Id = Guid.NewGuid() }, + resourceQuota: new(resource: new(id: Guid.Parse("a5092027-0d0b-4be6-9025-5a2397f34fce")), usedPercentage: 0.5f, used: new(), reserved: new()), + visibility: new(), + keywords: ["keyword1", "keyword2"], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], + license: new(), + usageRights: "usage rights", + metadataLocalCopy: false, + metadataExtraction: false, + archived: false, + projects: [], + applicationProfile: new(uri: "https://example.org"), + fixedValues: [], + projectResources: [new(projectId: Guid.Parse("0fd58e44-f3cc-4aec-a865-b8f37aa17466"), resourceId: Guid.Parse("a5092027-0d0b-4be6-9025-5a2397f34fce"))], + deleted: false + ), + new(id: Guid.Parse("cc5d1783-8ff1-4401-a2ce-d1300bd1b2a5"), + name: "Resource Two", + displayName: "Resource Two", + description: "Resource Two Description", + pid: "resource-two", + type: new(generalType: "ds", specificType: "dss3"), + dateCreated: DateTime.UtcNow, + creator: new() { Id = Guid.NewGuid() }, + resourceQuota: new(resource: new(id: Guid.Parse("cc5d1783-8ff1-4401-a2ce-d1300bd1b2a5")), usedPercentage: 0.5f, used: new(), reserved: new()), + visibility: new(), + keywords: ["keyword3"], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], + license: new(), + usageRights: "usage rights", + metadataLocalCopy: false, + metadataExtraction: false, + archived: false, + projects: [], + applicationProfile: new(uri: "https://example.org"), + fixedValues: [], + projectResources: [new(projectId: Guid.Parse("0fd58e44-f3cc-4aec-a865-b8f37aa17466"), resourceId: Guid.Parse("a5092027-0d0b-4be6-9025-5a2397f34fce"))], + deleted: false + ) + ]; + } + + private static List<ProjectAdminDto> ReturnProjectAdminDtos() + { + return + [ + new(id: Guid.Parse("ce1af24e-2719-425c-947d-d7538f06fed0"), + name: "Project One", + displayName: "Project One", + description: "Project One Description", + pid: "project-one", + creationDate: DateTime.UtcNow, + slug: "project-one", + creator: new() { Id = Guid.NewGuid() }, + projectQuota: [], + visibility: new(), + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], + organizations: [ + new(uri: "https://ror.org/12345", displayName: "Organization One", responsible: true), + new(uri: "https://ror.org/54321", displayName: "Organization Two", responsible: false) + ], + projectRoles: [ new(projectId: Guid.Parse("ce1af24e-2719-425c-947d-d7538f06fed0"), userId: Guid.Parse("fd971cef-7c02-45df-97c4-ef757c05bc19"), roleId: Guid.Parse("778f7bd2-128b-45aa-a093-807da3b9aecc")) ] + ), + new(id: Guid.Parse("0fd58e44-f3cc-4aec-a865-b8f37aa17466"), + name: "Project Two", + displayName: "Project Two", + description: "Project Two Description", + pid: "project-two", + creationDate: DateTime.UtcNow, + slug: "project-two", + creator: new() { Id = Guid.NewGuid() }, + projectQuota: [], + visibility: new(), + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], + organizations: [ + new(uri: "https://ror.org/12345", displayName: "Organization One", responsible: false), + new(uri: "https://ror.org/54321", displayName: "Organization Two", responsible: true) + ], + projectRoles: [ new(projectId: Guid.Parse("0fd58e44-f3cc-4aec-a865-b8f37aa17466"), userId: Guid.Parse("21c95165-8b44-46a5-b711-4d4e0c54eda2"), roleId: Guid.Parse("6e5aadc3-72ea-4f96-827d-90c5c15d34af")) ] + ) + ]; + } +} \ No newline at end of file diff --git a/src/KpiGenerator.Tests/UserReportingTests.cs b/src/KpiGenerator.Tests/UserReportingTests.cs new file mode 100644 index 0000000000000000000000000000000000000000..5386dbd1b25154449f4d568d8308f4e094bb2fef --- /dev/null +++ b/src/KpiGenerator.Tests/UserReportingTests.cs @@ -0,0 +1,333 @@ +using AutoMapper; +using Coscine.ApiClient.Core.Api; +using Coscine.ApiClient.Core.Model; +using Coscine.KpiGenerator.MappingProfiles; +using Coscine.KpiGenerator.Models; +using Coscine.KpiGenerator.Models.ConfigurationModels; +using Coscine.KpiGenerator.Reportings.User; +using Coscine.KpiGenerator.Utils; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.Extensions; +using static Coscine.KpiGenerator.Models.ConfigurationModels.ReportingConfiguration; +using static KPIGenerator.Utils.CommandLineOptions; + +namespace KpiGenerator.Tests; + +[TestFixture] +public class UserReportingTests +{ + private IMapper _mapper = null!; + private ILogger<UserReporting> _logger = null!; + private IStorageService _gitlabStorageService = null!; + private IStorageService _localStorageService = null!; + private IOptionsMonitor<KpiConfiguration> _kpiConfiguration = null!; + private IOptionsMonitor<ReportingConfiguration> _reportingConfiguration = null!; + + private IAdminApi _adminApi = null!; + private IRoleApi _roleApi = null!; + + private UserReporting _userReporting = null!; // System Under Test + + [SetUp] + public void SetUp() + { + // NSubstitute Mocks + _logger = Substitute.For<ILogger<UserReporting>>(); + _gitlabStorageService = Substitute.For<IStorageService>(); + _localStorageService = Substitute.For<IStorageService>(); + + // Mock IOptionsMonitor + var kpiConfig = new KpiConfiguration + { + UserKpi = new("user_reporting.json") + }; + _kpiConfiguration = Substitute.For<IOptionsMonitor<KpiConfiguration>>(); + _kpiConfiguration.CurrentValue.Returns(kpiConfig); + + // Create a real mapper + _mapper = new MapperConfiguration(cfg => + { + cfg.AddProfile<MappingProfiles>(); + }).CreateMapper(); + + // Mock ReportingConfiguration + var reportingConfig = new ReportingConfiguration + { + Endpoint = "https://some-endpoint/api", + ApiKey = "dummy-api-key", + // Possibly fill Organization as needed + Organization = new OrganizationConfiguration + { + OtherOrganization = new() + { + Name = "Other", + RorUrl = "https://ror.org/_other", + } + } + }; + _reportingConfiguration = Substitute.For<IOptionsMonitor<ReportingConfiguration>>(); + _reportingConfiguration.CurrentValue.Returns(reportingConfig); + + _adminApi = Substitute.For<IAdminApi>(); + _roleApi = Substitute.For<IRoleApi>(); + } + + #region GenerateReportingAsync Tests + + [Test] + public async Task GenerateReportingAsync_ReturnsGeneralAndPerOrgFiles_WhenProjectsExist() + { + // Arrange + var projects = TestData.ProjectAdminDtos; + var users = TestData.UserDtos; + var roles = TestData.RoleDtos; + + _adminApi + .GetAllProjectsAsync( + includeDeleted: Arg.Any<bool>(), + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test projects data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: projects, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _adminApi + .GetAllUsersAsync( + tosAccepted: Arg.Any<bool>(), + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test users data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new UserDtoPagedResponse(data: users, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _roleApi + .GetRolesAsync( + pageNumber: Arg.Any<int>(), + pageSize: Arg.Any<int>() + ) + .Returns(ci => + { + // Return the test roles data, single page + var pagination = new Pagination(currentPage: 1, pageSize: 2, totalCount: 2, totalPages: 1); + return Task.FromResult(new RoleDtoPagedResponse(data: roles, pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _userReporting = new UserReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + + // Act + var result = await _userReporting.GenerateReportingAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count(), Is.EqualTo(3), "Expected 3 reporting files."); + + var generalFile = result.FirstOrDefault(r => r.Path.Contains("general", StringComparison.OrdinalIgnoreCase) + || r.Path.EndsWith("user_reporting.json")); + Assert.That(generalFile, Is.Not.Null, "General file should exist."); + + var orgFile1 = result.FirstOrDefault(r => r.Path.Contains("12345")); + Assert.That(orgFile1, Is.Not.Null, "Per-organization file for ror.org/12345 should exist."); + + var orgFile2 = result.FirstOrDefault(r => r.Path.Contains("54321")); + Assert.That(orgFile2, Is.Not.Null, "Per-organization file for ror.org/54321 should exist."); + } + + [Test] + public async Task GenerateReportingAsync_ReturnsOnlyGeneralFile_WhenNoProjects() + { + // Arrange + _adminApi + .GetAllProjectsAsync() + .Returns(ci => + { + // No projects, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new ProjectAdminDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _adminApi + .GetAllUsersAsync() + .Returns(ci => + { + // No users, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new UserDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _roleApi + .GetRolesAsync() + .Returns(ci => + { + // No roles, empty data + var pagination = new Pagination(currentPage: 1, pageSize: 0, totalCount: 0, totalPages: 1); + return Task.FromResult(new RoleDtoPagedResponse(data: [], pagination: pagination, statusCode: 200, traceId: "dummy-trace-id")); + }); + _userReporting = new UserReporting(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + + // Act + var result = await _userReporting.GenerateReportingAsync(); + + // Assert + // We expect 1 file: the "general" file (with an empty list of projects). + Assert.That(result.Count(), Is.EqualTo(1)); + var file = result.First(); + Assert.That(file.Path, Does.Contain("user_reporting.json").Or.Contain("general")); + } + + #endregion + + #region RunAsync Tests + + [Test] + public async Task RunAsync_WhenGitlabPublishSucceeds_ShouldReturnTrue() + { + // Arrange + var options = new UserReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "foo", Content = new MemoryStream() } + }; + + // We want to ensure that GenerateReportingAsync returns some test objects + _userReporting = Substitute.ForPartsOf<UserReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + _userReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => success + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Local storage shouldn't be called if GitLab is successful + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); // default + + // Act + var result = await _userReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if GitLab publish succeeds."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("User Reporting", reportingFiles); + + // Verify Local storage was never called + await _localStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + } + + [Test] + public async Task RunAsync_WhenGitlabPublishFails_ShouldFallbackToLocalStorage() + { + // Arrange + var options = new UserReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "bar", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _userReporting = Substitute.ForPartsOf<UserReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + _userReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => fails + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); + + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _userReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds after GitLab fails."); + + // Verify GitLab was called + await _gitlabStorageService.Received(1) + .PublishAsync("User Reporting", reportingFiles); + + // Verify fallback to local was called + await _localStorageService.Received(1) + .PublishAsync("User Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenInDummyMode_ShouldSkipGitlabAndPublishToLocal() + { + // Arrange + var options = new UserReportingOptions { DummyMode = true }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "dummy", Content = new MemoryStream() } + }; + + // Partial mock to override GenerateReportingAsync + _userReporting = Substitute.ForPartsOf<UserReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + _userReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + // GitLab publish => should not be called + // Local publish => success + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(true)); + + // Act + var result = await _userReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.True, "Expected RunAsync to return true if local storage publish succeeds in DummyMode."); + + // Verify GitLab wasn't called + await _gitlabStorageService.DidNotReceiveWithAnyArgs() + .PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()); + + // Verify local storage was called + await _localStorageService.Received(1) + .PublishAsync("User Reporting", reportingFiles); + } + + [Test] + public async Task RunAsync_WhenBothGitlabAndLocalFail_ShouldReturnFalse() + { + // Arrange + var options = new UserReportingOptions { DummyMode = false }; + var reportingFiles = new List<ReportingFileObject> + { + new() { Path = "fail", Content = new MemoryStream() } + }; + + // Partial mock + _userReporting = Substitute.ForPartsOf<UserReporting>(_mapper, _logger, _gitlabStorageService, _localStorageService, _kpiConfiguration, _reportingConfiguration, _adminApi, _roleApi); + _userReporting + .Configure() + .GenerateReportingAsync() + .Returns(Task.FromResult((IEnumerable<ReportingFileObject>)reportingFiles)); + + _gitlabStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // GitLab fails + _localStorageService.PublishAsync(Arg.Any<string>(), Arg.Any<IEnumerable<ReportingFileObject>>()) + .Returns(Task.FromResult(false)); // Local also fails + + // Act + var result = await _userReporting.RunAsync(options); + + // Assert + Assert.That(result, Is.False, "Expected RunAsync to return false if both GitLab and local publish fail."); + } + + #endregion +} diff --git a/src/KpiGenerator.sln b/src/KpiGenerator.sln index 43ff3942319cc241b3601c9cdfa131cf11266b2a..b970c6c0b9445c82b2333319c28b21d7a4b4705f 100644 --- a/src/KpiGenerator.sln +++ b/src/KpiGenerator.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.2.32526.322 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KpiGenerator", "KpiGenerator\KpiGenerator.csproj", "{2B402E93-467B-49C1-8350-9277BDEDA9C3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KpiGenerator.Tests", "KpiGenerator.Tests\KpiGenerator.Tests.csproj", "{6A8D781C-E8B5-428F-8DDB-84F8A88BB832}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {2B402E93-467B-49C1-8350-9277BDEDA9C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {2B402E93-467B-49C1-8350-9277BDEDA9C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B402E93-467B-49C1-8350-9277BDEDA9C3}.Release|Any CPU.Build.0 = Release|Any CPU + {6A8D781C-E8B5-428F-8DDB-84F8A88BB832}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A8D781C-E8B5-428F-8DDB-84F8A88BB832}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A8D781C-E8B5-428F-8DDB-84F8A88BB832}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A8D781C-E8B5-428F-8DDB-84F8A88BB832}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/KpiGenerator/KpiGenerator.csproj b/src/KpiGenerator/KpiGenerator.csproj index 31c784e9ac847b336e51904544675f97012f90a9..d43fceec1d6c47b0f16d54c7b696449360868801 100644 --- a/src/KpiGenerator/KpiGenerator.csproj +++ b/src/KpiGenerator/KpiGenerator.csproj @@ -13,7 +13,7 @@ <PropertyGroup> <Authors>RWTH Aachen University</Authors> <Company>IT Center, RWTH Aachen University</Company> - <Copyright>©2024 IT Center, RWTH Aachen University</Copyright> + <Copyright>©2025 IT Center, RWTH Aachen University</Copyright> <Description>KPI Generator is a part of the Coscine group.</Description> </PropertyGroup> @@ -21,7 +21,7 @@ <PackageReference Include="AutoMapper" Version="12.0.1" /> <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" /> <PackageReference Include="CommandLineParser" Version="2.9.1" /> - <PackageReference Include="Coscine.ApiClient" Version="1.9.3" /> + <PackageReference Include="Coscine.ApiClient" Version="1.9.4" /> <PackageReference Include="dotNetRdf.Core" Version="3.1.1" /> <PackageReference Include="GitLabApiClient" Version="1.8.1-beta.5" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" /> diff --git a/src/KpiGenerator/MappingProfiles/MappingProfiles.cs b/src/KpiGenerator/MappingProfiles/MappingProfiles.cs index ca15d23ecc330164c5e96a37dedbb779a04441bd..1b5e1b3a23b7e8ef664bf29dc2eb7514be117820 100644 --- a/src/KpiGenerator/MappingProfiles/MappingProfiles.cs +++ b/src/KpiGenerator/MappingProfiles/MappingProfiles.cs @@ -36,7 +36,6 @@ public class MappingProfiles : Profile CreateMap<UserDto, UserReport>() .ForMember(ur => ur.Disciplines, opt => opt.MapFrom(dto => dto.Disciplines)) - .ForMember(ur => ur.Institutes, opt => opt.MapFrom(dto => dto.Institutes)) .ForMember(ur => ur.LatestActivity, opt => opt.MapFrom(dto => dto.LatestActivity)) .ForMember(ur => ur.LoginProviders, opt => opt.MapFrom(dto => dto.Identities)) .ForMember(ur => ur.Organizations, opt => opt.MapFrom(dto => dto.Organizations)) @@ -50,12 +49,7 @@ public class MappingProfiles : Profile .ForMember(lp => lp.Id, opt => opt.MapFrom(dto => dto.Id)) .ForMember(lp => lp.DisplayName, opt => opt.MapFrom(dto => dto.DisplayName)); - CreateMap<UserInstituteDto, Organization>() - .ForMember(o => o.ReadOnly, opt => opt.MapFrom(dto => dto.ReadOnly)) - .ForMember(o => o.Name, opt => opt.MapFrom(dto => dto.DisplayName)) - .ForMember(o => o.RorUrl, opt => opt.MapFrom(dto => dto.Uri)); - - CreateMap<UserOrganizationDto, Organization>() + CreateMap<UserOrganizationDto, UserOrganization>() .ForMember(o => o.ReadOnly, opt => opt.MapFrom(dto => dto.ReadOnly)) .ForMember(o => o.Name, opt => opt.MapFrom(dto => dto.DisplayName)) .ForMember(o => o.RorUrl, opt => opt.MapFrom(dto => dto.Uri)); @@ -63,6 +57,11 @@ public class MappingProfiles : Profile CreateMap<OrganizationDto, Organization>() .ForMember(o => o.Name, opt => opt.MapFrom(dto => dto.DisplayName)) .ForMember(o => o.RorUrl, opt => opt.MapFrom(dto => dto.Uri)); + + CreateMap<ProjectOrganizationDto, ProjectOrganization>() + .ForMember(po => po.Name, opt => opt.MapFrom(dto => dto.DisplayName)) + .ForMember(po => po.RorUrl, opt => opt.MapFrom(dto => dto.Uri)) + .ForMember(po => po.Responsible, opt => opt.MapFrom(dto => dto.Responsible)); CreateMap<VisibilityDto, ProjectVisibility>() .ForMember(pv => pv.Id, opt => opt.MapFrom(dto => dto.Id)) diff --git a/src/KpiGenerator/Models/Organization.cs b/src/KpiGenerator/Models/Organization.cs index 9b42922a25329b215beec2bf74f8b3bd627b1509..47be044c3545ed13f1556ea95503b8546d2b6b7f 100644 --- a/src/KpiGenerator/Models/Organization.cs +++ b/src/KpiGenerator/Models/Organization.cs @@ -17,16 +17,4 @@ public record Organization /// <example>https://ror.org/04xfq0f34</example> [JsonPropertyName("RorUrl")] public string RorUrl { get; init; } = null!; - - /// <summary> - /// Determines if the organization's details can be modified. - /// </summary> - /// <value><c>true</c> if the organization is read-only; otherwise, <c>false</c>.</value> - /// <remarks> - /// This property defaults to <c>true</c> and can be manually set to <c>false</c>. - /// For entries set by Shibboleth, this property is automatically set to <c>true</c>. - /// </remarks> - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - [JsonPropertyName("ReadOnly")] - public bool? ReadOnly { get; init; } } \ No newline at end of file diff --git a/src/KpiGenerator/Models/ProjectOrganization.cs b/src/KpiGenerator/Models/ProjectOrganization.cs new file mode 100644 index 0000000000000000000000000000000000000000..cd84ccd3ea99c6de4346ece61397f37b94ea2a59 --- /dev/null +++ b/src/KpiGenerator/Models/ProjectOrganization.cs @@ -0,0 +1,9 @@ +namespace Coscine.KpiGenerator.Models; + +public record ProjectOrganization : Organization +{ + /// <summary> + /// Determines if the organization is Responsible for a given project. + /// </summary> + public bool Responsible { get; set; } +} \ No newline at end of file diff --git a/src/KpiGenerator/Models/ProjectReport.cs b/src/KpiGenerator/Models/ProjectReport.cs index fcd871b85fb60dad6c55d5d5a02cbeae64165c75..f410b5d95b39986ccdbcbb7552705eb1aacd011f 100644 --- a/src/KpiGenerator/Models/ProjectReport.cs +++ b/src/KpiGenerator/Models/ProjectReport.cs @@ -14,7 +14,7 @@ public record ProjectReport public IReadOnlyList<PublicationRequestReport> PublicationRequests { get; set; } = null!; [JsonPropertyName("Organizations")] - public IReadOnlyList<Organization> Organizations { get; init; } = null!; + public IReadOnlyList<ProjectOrganization> Organizations { get; init; } = null!; [JsonPropertyName("Disciplines")] public IReadOnlyList<Discipline> Disciplines { get; init; } = null!; diff --git a/src/KpiGenerator/Models/ResourceReport.cs b/src/KpiGenerator/Models/ResourceReport.cs index 8c1081218238d1c5b4e65a5ff4b70cba7ab2f0c4..ec79d732105fc3e9633cf198e52b100cbe7d597b 100644 --- a/src/KpiGenerator/Models/ResourceReport.cs +++ b/src/KpiGenerator/Models/ResourceReport.cs @@ -26,7 +26,7 @@ public record ResourceReport public string RelatedProjectId { get; init; } = null!; [JsonPropertyName("Organizations")] - public IReadOnlyList<Organization> Organizations { get; set; } = []; + public IReadOnlyList<ProjectOrganization> Organizations { get; set; } = []; [JsonPropertyName("Disciplines")] public IReadOnlyList<Discipline> Disciplines { get; init; } = null!; diff --git a/src/KpiGenerator/Models/UserOrganization.cs b/src/KpiGenerator/Models/UserOrganization.cs new file mode 100644 index 0000000000000000000000000000000000000000..a22fadc85406b77bf603236afc4173a993cd66d3 --- /dev/null +++ b/src/KpiGenerator/Models/UserOrganization.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Coscine.KpiGenerator.Models; + +public record UserOrganization : Organization { + /// <summary> + /// Determines if the organization's details can be modified. + /// </summary> + /// <value><c>true</c> if the organization is read-only; otherwise, <c>false</c>.</value> + /// <remarks> + /// This property defaults to <c>true</c> and can be manually set to <c>false</c>. + /// For entries set by Shibboleth, this property is automatically set to <c>true</c>. + /// </remarks> + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("ReadOnly")] + public bool? ReadOnly { get; init; } +} \ No newline at end of file diff --git a/src/KpiGenerator/Models/UserReport.cs b/src/KpiGenerator/Models/UserReport.cs index e21ff764cc94dc2b6abded1eceaee39255b98763..0e780dd1f45f253197071119bdc735ea7bdd220f 100644 --- a/src/KpiGenerator/Models/UserReport.cs +++ b/src/KpiGenerator/Models/UserReport.cs @@ -4,9 +4,7 @@ public record UserReport { public IReadOnlyList<RelatedProject> RelatedProjects { get; set; } = null!; - public IReadOnlyList<Organization> Organizations { get; init; } = null!; - - public IReadOnlyList<Organization> Institutes { get; init; } = null!; + public IReadOnlyList<UserOrganization> Organizations { get; init; } = null!; public IReadOnlyList<Discipline> Disciplines { get; init; } = null!; diff --git a/src/KpiGenerator/Program.cs b/src/KpiGenerator/Program.cs index c1f05f50b2e040150549ff2ffa2b04665c1f2ad0..c33fd06b0b850383196ee54bd199f360180a0845 100644 --- a/src/KpiGenerator/Program.cs +++ b/src/KpiGenerator/Program.cs @@ -1,9 +1,14 @@ using CommandLine; +using Coscine.ApiClient.Core.Api; +using Coscine.ApiClient.Core.Client; using Coscine.KpiGenerator.Logging; using Coscine.KpiGenerator.Models.ConfigurationModels; +using Coscine.KpiGenerator.Reportings.ApplicationProfile; using Coscine.KpiGenerator.Reportings.Complete; using Coscine.KpiGenerator.Reportings.Project; using Coscine.KpiGenerator.Reportings.Resource; +using Coscine.KpiGenerator.Reportings.System; +using Coscine.KpiGenerator.Reportings.User; using Coscine.KpiGenerator.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -165,6 +170,22 @@ public class Program // Register the HTTP client services.AddHttpClient(); + // Add the API clients + var apiConfiguration = new Configuration() + { + BasePath = $"{reportingConfiguration.Endpoint.TrimEnd('/')}/coscine", + ApiKeyPrefix = { { "Authorization", "Bearer" } }, + ApiKey = { { "Authorization", reportingConfiguration.ApiKey } }, + Timeout = 300000 // 5 minutes + }; + services.AddSingleton<IAdminApi>(new AdminApi(apiConfiguration)); + services.AddSingleton<IApplicationProfileApi>(new ApplicationProfileApi(apiConfiguration)); + services.AddSingleton<IProjectApi>(new ProjectApi(apiConfiguration)); + services.AddSingleton<IProjectQuotaApi>(new ProjectQuotaApi(apiConfiguration)); + services.AddSingleton<IProjectResourceQuotaApi>(new ProjectResourceQuotaApi(apiConfiguration)); + services.AddSingleton<IRoleApi>(new RoleApi(apiConfiguration)); + services.AddSingleton<IUserApi>(new UserApi(apiConfiguration)); + // Add services for reporting services.AddTransient<CompleteReporting>(); services.AddTransient<ProjectReporting>(); diff --git a/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs b/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs index 6170eada32f3e7d8f0a45122bcdee30b79daa493..c6818795c24fccac82c32e8dba5907b5079506df 100644 --- a/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs +++ b/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs @@ -1,7 +1,6 @@ using AutoMapper; using Coscine.ApiClient; using Coscine.ApiClient.Core.Api; -using Coscine.ApiClient.Core.Client; using Coscine.ApiClient.Core.Model; using Coscine.KpiGenerator.Models; using Coscine.KpiGenerator.Models.ConfigurationModels; @@ -15,7 +14,7 @@ using VDS.RDF.Nodes; using VDS.RDF.Parsing; using static KPIGenerator.Utils.CommandLineOptions; -namespace Coscine.KpiGenerator.Reportings.Resource; +namespace Coscine.KpiGenerator.Reportings.ApplicationProfile; public class ApplicationProfileReporting { @@ -25,11 +24,7 @@ public class ApplicationProfileReporting private readonly IStorageService _localStorageService; private readonly KpiConfiguration _kpiConfiguration; private readonly ReportingConfiguration _reportingConfiguration; - - public static string AdminToken { get; set; } = null!; - public AdminApi AdminApi { get; init; } - public ProjectApi ProjectApi { get; init; } - public ApplicationProfileApi ApplicationProfileApi { get; } + private readonly IApplicationProfileApi _applicationProfileApi; public ApplicationProfileReportingOptions Options { get; private set; } = null!; public string ReportingFileName { get; } @@ -40,7 +35,8 @@ public class ApplicationProfileReporting [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, [FromKeyedServices("local")] IStorageService localStorageService, IOptionsMonitor<KpiConfiguration> kpiConfiguration, - IOptionsMonitor<ReportingConfiguration> reportingConfiguration + IOptionsMonitor<ReportingConfiguration> reportingConfiguration, + IApplicationProfileApi applicationProfileApi ) { _mapper = mapper; @@ -51,17 +47,7 @@ public class ApplicationProfileReporting _reportingConfiguration = reportingConfiguration.CurrentValue; ReportingFileName = _kpiConfiguration.ApplicationProfileKpi.FileName; - var configuration = new Configuration() - { - BasePath = $"{_reportingConfiguration.Endpoint.TrimEnd('/')}/coscine", - ApiKeyPrefix = { { "Authorization", "Bearer" } }, - ApiKey = { { "Authorization", _reportingConfiguration.ApiKey } }, - Timeout = 300000 // 5 minutes - }; - - AdminApi = new AdminApi(configuration); - ProjectApi = new ProjectApi(configuration); - ApplicationProfileApi = new ApplicationProfileApi(configuration); + _applicationProfileApi = applicationProfileApi; } public async Task<bool> RunAsync(ApplicationProfileReportingOptions reportingOptions) @@ -70,14 +56,27 @@ public class ApplicationProfileReporting Options = reportingOptions; var reportingFiles = await GenerateReportingAsync(); + bool success = false; _logger.LogInformation("Publishing to GitLab..."); // Publish to GitLab first, if that fails, publish to local storage - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("Application Profile Reporting", reportingFiles); + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("Application Profile Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); + _logger.LogInformation("Publishing to local storage instead."); success = await _localStorageService.PublishAsync("Application Profile Reporting", reportingFiles); } + return success; } @@ -88,7 +87,7 @@ public class ApplicationProfileReporting _logger.LogDebug("Working on application profiles asynchronously..."); var applicationProfiles = PaginationHelper.GetAllAsync<ApplicationProfileDtoPagedResponse, ApplicationProfileDto>( - (pageNumber) => ApplicationProfileApi.GetApplicationProfilesAsync(pageNumber: pageNumber, pageSize: 500)); + (pageNumber) => _applicationProfileApi.GetApplicationProfilesAsync(pageNumber: pageNumber, pageSize: 500)); _logger.LogInformation("Relevant application profiles found."); await foreach (var ap in applicationProfiles) @@ -97,7 +96,7 @@ public class ApplicationProfileReporting var g = new Graph(); try { - var applicationProfile = await ApplicationProfileApi.GetApplicationProfileAsync(ap.Uri, RdfFormat.TextTurtle); + var applicationProfile = await _applicationProfileApi.GetApplicationProfileAsync(ap.Uri, RdfFormat.TextTurtle); _logger.LogDebug("Application profile retrieved. Parsing..."); StringParser.Parse(g, applicationProfile.Data.Definition.Content); _logger.LogDebug("Application profile parsed."); diff --git a/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs b/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs index 05e489e36d3618be1c349a677107046d6ad355dc..1ca432d7c95db304df374a975cb449ee0dceafb0 100644 --- a/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs +++ b/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs @@ -1,6 +1,9 @@ using Coscine.KpiGenerator.Models; +using Coscine.KpiGenerator.Reportings.ApplicationProfile; using Coscine.KpiGenerator.Reportings.Project; using Coscine.KpiGenerator.Reportings.Resource; +using Coscine.KpiGenerator.Reportings.System; +using Coscine.KpiGenerator.Reportings.User; using Coscine.KpiGenerator.Utils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -8,55 +11,55 @@ using static KPIGenerator.Utils.CommandLineOptions; namespace Coscine.KpiGenerator.Reportings.Complete; -public class CompleteReporting +public class CompleteReporting( + ProjectReporting projectReporting, + ResourceReporting resourceReporting, + UserReporting userReporting, + ApplicationProfileReporting applicationProfileReporting, + SystemReporting systemReporting, + ILogger<CompleteReporting> logger, + [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, + [FromKeyedServices("local")] IStorageService localStorageService + ) { - private readonly ProjectReporting _projectReporting; - private readonly ResourceReporting _resourceReporting; - private readonly UserReporting _userReporting; - private readonly ApplicationProfileReporting _applicationProfileReporting; - private readonly SystemReporting _systemReporting; - private readonly ILogger<CompleteReporting> _logger; - private readonly IStorageService _gitlabStorageService; - private readonly IStorageService _localStorageService; + private readonly ProjectReporting _projectReporting = projectReporting; + private readonly ResourceReporting _resourceReporting = resourceReporting; + private readonly UserReporting _userReporting = userReporting; + private readonly ApplicationProfileReporting _applicationProfileReporting = applicationProfileReporting; + private readonly SystemReporting _systemReporting = systemReporting; + private readonly ILogger<CompleteReporting> _logger = logger; + private readonly IStorageService _gitlabStorageService = gitlabStorageService; + private readonly IStorageService _localStorageService = localStorageService; public CompleteReportingOptions Options { get; private set; } = null!; - - public CompleteReporting( - ProjectReporting projectReporting, - ResourceReporting resourceReporting, - UserReporting userReporting, - ApplicationProfileReporting applicationProfileReporting, - SystemReporting systemReporting, - ILogger<CompleteReporting> logger, - [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, - [FromKeyedServices("local")] IStorageService localStorageService - ) - { - _projectReporting = projectReporting; - _resourceReporting = resourceReporting; - _userReporting = userReporting; - _applicationProfileReporting = applicationProfileReporting; - _systemReporting = systemReporting; - _logger = logger; - _gitlabStorageService = gitlabStorageService; - _localStorageService = localStorageService; - } - public async Task<bool> RunAsync(CompleteReportingOptions reportingOptions) { _logger.LogInformation("Generating Complete Reporting..."); Options = reportingOptions; var reportingFiles = await GenerateReportingAsync(); - // Publish to GitLab first, if that fails, publish to local storage + bool success = false; _logger.LogInformation("Publishing to GitLab..."); - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("Complete Reporting", reportingFiles); + // Publish to GitLab first, if that fails, publish to local storage + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("Complete Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); + _logger.LogInformation("Publishing to local storage instead."); success = await _localStorageService.PublishAsync("Complete Reporting", reportingFiles); } + return success; } diff --git a/src/KpiGenerator/Reportings/Project/ProjectReporting.cs b/src/KpiGenerator/Reportings/Project/ProjectReporting.cs index ddcd82274aa291d5cf1a2d665e7c9674bf6a9096..ba8d846a7cc1c037b190fc2a8c87bcc9d70777cf 100644 --- a/src/KpiGenerator/Reportings/Project/ProjectReporting.cs +++ b/src/KpiGenerator/Reportings/Project/ProjectReporting.cs @@ -1,7 +1,6 @@ using AutoMapper; using Coscine.ApiClient; using Coscine.ApiClient.Core.Api; -using Coscine.ApiClient.Core.Client; using Coscine.ApiClient.Core.Model; using Coscine.KpiGenerator.Models; using Coscine.KpiGenerator.Models.ConfigurationModels; @@ -22,10 +21,8 @@ public class ProjectReporting private readonly IStorageService _localStorageService; private readonly KpiConfiguration _kpiConfiguration; private readonly ReportingConfiguration _reportingConfiguration; - - public static string AdminToken { get; set; } = null!; - public AdminApi AdminApi { get; init; } - public ProjectQuotaApi ProjectQuotaApi { get; init; } + private readonly IAdminApi _adminApi; + private readonly IProjectQuotaApi _projectQuotaApi; public ProjectReportingOptions Options { get; private set; } = null!; public string ReportingFileName { get; } @@ -36,7 +33,9 @@ public class ProjectReporting [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, [FromKeyedServices("local")] IStorageService localStorageService, IOptionsMonitor<KpiConfiguration> kpiConfiguration, - IOptionsMonitor<ReportingConfiguration> reportingConfiguration + IOptionsMonitor<ReportingConfiguration> reportingConfiguration, + IAdminApi adminApi, + IProjectQuotaApi projectQuotaApi ) { _mapper = mapper; @@ -47,16 +46,8 @@ public class ProjectReporting _reportingConfiguration = reportingConfiguration.CurrentValue; ReportingFileName = _kpiConfiguration.ProjectKpi.FileName; - var configuration = new Configuration() - { - BasePath = $"{_reportingConfiguration.Endpoint.TrimEnd('/')}/coscine", - ApiKeyPrefix = { { "Authorization", "Bearer" } }, - ApiKey = { { "Authorization", _reportingConfiguration.ApiKey } }, - Timeout = 300000 // 5 minutes - }; - - AdminApi = new AdminApi(configuration); - ProjectQuotaApi = new ProjectQuotaApi(configuration); + _adminApi = adminApi; + _projectQuotaApi = projectQuotaApi; } public async Task<bool> RunAsync(ProjectReportingOptions reportingOptions) @@ -66,22 +57,35 @@ public class ProjectReporting var reportingFiles = await GenerateReportingAsync(); + bool success = false; _logger.LogInformation("Publishing to GitLab..."); // Publish to GitLab first, if that fails, publish to local storage - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("Project Reporting", reportingFiles); + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("Project Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); + _logger.LogInformation("Publishing to local storage instead."); success = await _localStorageService.PublishAsync("Project Reporting", reportingFiles); } + return success; } - public async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() + public virtual async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() { _logger.LogDebug("Working on projects asynchronously..."); var projects = PaginationHelper.GetAllAsync<ProjectAdminDtoPagedResponse, ProjectAdminDto>( - (currentPage) => AdminApi.GetAllProjectsAsync(includeDeleted: false, includeQuotas: true, includePublicationRequests: true, pageNumber: currentPage, pageSize: 50)); + (currentPage) => _adminApi.GetAllProjectsAsync(includeDeleted: false, includeQuotas: true, includePublicationRequests: true, pageNumber: currentPage, pageSize: 50)); var reportingFiles = new List<ReportingFileObject>(); var returnObjects = new List<ProjectReport>(); diff --git a/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs b/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs index 942b49eb0cde99a19dbc47aea0feae3fffdb31d1..3857acb9fce15b9c7ad2c20902bc629b330642d0 100644 --- a/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs +++ b/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs @@ -22,11 +22,7 @@ public class ResourceReporting private readonly IStorageService _localStorageService; private readonly KpiConfiguration _kpiConfiguration; private readonly ReportingConfiguration _reportingConfiguration; - - public static string AdminToken { get; set; } = null!; - public AdminApi AdminApi { get; init; } - public ProjectResourceQuotaApi ProjectResourceQuotaApi { get; init; } - public ProjectApi ProjectApi { get; init; } + private readonly IAdminApi _adminApi; public ResourceReportingOptions Options { get; private set; } = null!; public string ReportingFileName { get; } @@ -37,7 +33,8 @@ public class ResourceReporting [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, [FromKeyedServices("local")] IStorageService localStorageService, IOptionsMonitor<KpiConfiguration> kpiConfiguration, - IOptionsMonitor<ReportingConfiguration> reportingConfiguration + IOptionsMonitor<ReportingConfiguration> reportingConfiguration, + IAdminApi adminApi ) { _mapper = mapper; @@ -56,9 +53,7 @@ public class ResourceReporting Timeout = 300000 // 5 minutes }; - AdminApi = new AdminApi(configuration); - ProjectApi = new ProjectApi(configuration); - ProjectResourceQuotaApi = new ProjectResourceQuotaApi(configuration); + _adminApi = adminApi; } public async Task<bool> RunAsync(ResourceReportingOptions reportingOptions) @@ -67,27 +62,40 @@ public class ResourceReporting Options = reportingOptions; var reportingFiles = await GenerateReportingAsync(); + bool success = false; _logger.LogInformation("Publishing to GitLab..."); // Publish to GitLab first, if that fails, publish to local storage - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("Resources Reporting", reportingFiles); + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("Resource Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); - success = await _localStorageService.PublishAsync("Resources Reporting", reportingFiles); + _logger.LogInformation("Publishing to local storage instead."); + success = await _localStorageService.PublishAsync("Resource Reporting", reportingFiles); } + return success; } - public async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() + public virtual async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() { _logger.LogDebug("Getting all projects..."); var projects = await PaginationHelper.GetAllAsync<ProjectAdminDtoPagedResponse, ProjectAdminDto>( - (currentPage) => AdminApi.GetAllProjectsAsync(includeDeleted: true, pageNumber: currentPage, pageSize: 50)).ToListAsync(); + (currentPage) => _adminApi.GetAllProjectsAsync(includeDeleted: true, pageNumber: currentPage, pageSize: 50)).ToListAsync(); _logger.LogDebug("Got all projects."); _logger.LogDebug("Working on resources asynchronously..."); var resources = PaginationHelper.GetAllAsync<ResourceAdminDtoPagedResponse, ResourceAdminDto>( - (currentPage) => AdminApi.GetAllResourcesAsync(includeDeleted: false, includeQuotas: true, pageNumber: currentPage, pageSize: 50)); + (currentPage) => _adminApi.GetAllResourcesAsync(includeDeleted: false, includeQuotas: true, pageNumber: currentPage, pageSize: 50)); var reportingFiles = new List<ReportingFileObject>(); var returnObjects = new List<ResourceReport>(); @@ -102,7 +110,7 @@ public class ResourceReporting _logger.LogDebug("Processed related project {projectId}...", project?.Id); if (project != null) { - var o = _mapper.Map<List<Organization>>(project?.Organizations); + var o = _mapper.Map<List<ProjectOrganization>>(project?.Organizations); returnObject.Organizations = o ?? []; } _logger.LogDebug("Processed organizations..."); diff --git a/src/KpiGenerator/Reportings/System/SystemReporting.cs b/src/KpiGenerator/Reportings/System/SystemReporting.cs index c47dd82e32198048196caa5292f9cff4746dff58..b19a23c6b3aa32e755ad082174c58040e05ead71 100644 --- a/src/KpiGenerator/Reportings/System/SystemReporting.cs +++ b/src/KpiGenerator/Reportings/System/SystemReporting.cs @@ -1,8 +1,4 @@ -using AutoMapper; -using Coscine.ApiClient; -using Coscine.ApiClient.Core.Api; -using Coscine.ApiClient.Core.Client; -using Coscine.KpiGenerator.Models; +using Coscine.KpiGenerator.Models; using Coscine.KpiGenerator.Models.ConfigurationModels; using Coscine.KpiGenerator.Utils; using Microsoft.Extensions.DependencyInjection; @@ -12,12 +8,12 @@ using Newtonsoft.Json; using System.Net.Http.Headers; using System.Text; using static KPIGenerator.Utils.CommandLineOptions; +using HttpMethod = System.Net.Http.HttpMethod; -namespace Coscine.KpiGenerator.Reportings.Resource; +namespace Coscine.KpiGenerator.Reportings.System; public class SystemReporting { - private readonly IMapper _mapper; private readonly ILogger<SystemReporting> _logger; private readonly IStorageService _gitlabStorageService; private readonly IStorageService _localStorageService; @@ -25,15 +21,10 @@ public class SystemReporting private readonly ReportingConfiguration _reportingConfiguration; private readonly HttpClient _httpClient; - public static string AdminToken { get; set; } = null!; - public AdminApi AdminApi { get; init; } - public ProjectApi ProjectApi { get; init; } - public SystemReportingOptions Options { get; private set; } = null!; public string ReportingFileName { get; } public SystemReporting( - IMapper mapper, IHttpClientFactory httpClientFactory, ILogger<SystemReporting> logger, [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, @@ -42,7 +33,6 @@ public class SystemReporting IOptionsMonitor<ReportingConfiguration> reportingConfiguration ) { - _mapper = mapper; _logger = logger; _gitlabStorageService = gitlabStorageService; _localStorageService = localStorageService; @@ -50,17 +40,6 @@ public class SystemReporting _httpClient = httpClientFactory.CreateClient("MaintenanceClient"); _reportingConfiguration = reportingConfiguration.CurrentValue; ReportingFileName = _kpiConfiguration.SystemKpi.FileName; - - var configuration = new Configuration() - { - BasePath = $"{_reportingConfiguration.Endpoint.TrimEnd('/')}/coscine", - ApiKeyPrefix = { { "Authorization", "Bearer" } }, - ApiKey = { { "Authorization", _reportingConfiguration.ApiKey } }, - Timeout = 300000 // 5 minutes - }; - - AdminApi = new AdminApi(configuration); - ProjectApi = new ProjectApi(configuration); } public async Task<bool> RunAsync(SystemReportingOptions reportingOptions) @@ -69,14 +48,27 @@ public class SystemReporting Options = reportingOptions; var reportingFiles = await GenerateReportingAsync(); + bool success = false; _logger.LogInformation("Publishing to GitLab..."); // Publish to GitLab first, if that fails, publish to local storage - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("System Reporting", reportingFiles); + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("System Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); + _logger.LogInformation("Publishing to local storage instead."); success = await _localStorageService.PublishAsync("System Reporting", reportingFiles); } + return success; } @@ -107,7 +99,7 @@ public class SystemReporting var requestMessage = new HttpRequestMessage() { RequestUri = new Uri(_kpiConfiguration.SystemKpi.Maintenance.Url), - Method = System.Net.Http.HttpMethod.Get + Method = HttpMethod.Get, // Use global alias }; // Add Basic Authentication Headers diff --git a/src/KpiGenerator/Reportings/User/UserReporting.cs b/src/KpiGenerator/Reportings/User/UserReporting.cs index 7165448c716fe50dd2959191551dad9dd202b686..8db2ad4d1e6c4720e521f501d80a401e2d6bbda6 100644 --- a/src/KpiGenerator/Reportings/User/UserReporting.cs +++ b/src/KpiGenerator/Reportings/User/UserReporting.cs @@ -1,7 +1,6 @@ using AutoMapper; using Coscine.ApiClient; using Coscine.ApiClient.Core.Api; -using Coscine.ApiClient.Core.Client; using Coscine.ApiClient.Core.Model; using Coscine.KpiGenerator.Models; using Coscine.KpiGenerator.Models.ConfigurationModels; @@ -12,7 +11,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using static KPIGenerator.Utils.CommandLineOptions; -namespace Coscine.KpiGenerator.Reportings.Resource; +namespace Coscine.KpiGenerator.Reportings.User; public class UserReporting { @@ -22,11 +21,8 @@ public class UserReporting private readonly IStorageService _localStorageService; private readonly KpiConfiguration _kpiConfiguration; private readonly ReportingConfiguration _reportingConfiguration; - - public static string AdminToken { get; set; } = null!; - public AdminApi AdminApi { get; init; } - public ProjectApi ProjectApi { get; init; } - public RoleApi RoleApi { get; init; } + private readonly IAdminApi _adminApi; + private readonly IRoleApi _roleApi; public UserReportingOptions Options { get; private set; } = null!; public string ReportingFileName { get; } @@ -37,7 +33,9 @@ public class UserReporting [FromKeyedServices("gitlab")] IStorageService gitlabStorageService, [FromKeyedServices("local")] IStorageService localStorageService, IOptionsMonitor<KpiConfiguration> kpiConfiguration, - IOptionsMonitor<ReportingConfiguration> reportingConfiguration + IOptionsMonitor<ReportingConfiguration> reportingConfiguration, + IAdminApi adminApi, + IRoleApi roleApi ) { _mapper = mapper; @@ -48,17 +46,8 @@ public class UserReporting _reportingConfiguration = reportingConfiguration.CurrentValue; ReportingFileName = _kpiConfiguration.UserKpi.FileName; - var configuration = new Configuration() - { - BasePath = $"{_reportingConfiguration.Endpoint.TrimEnd('/')}/coscine", - ApiKeyPrefix = { { "Authorization", "Bearer" } }, - ApiKey = { { "Authorization", _reportingConfiguration.ApiKey } }, - Timeout = 300000 // 5 minutes - }; - - AdminApi = new AdminApi(configuration); - ProjectApi = new ProjectApi(configuration); - RoleApi = new RoleApi(configuration); + _adminApi = adminApi; + _roleApi = roleApi; } public async Task<bool> RunAsync(UserReportingOptions reportingOptions) @@ -67,32 +56,45 @@ public class UserReporting Options = reportingOptions; var reportingFiles = await GenerateReportingAsync(); + bool success = false; _logger.LogInformation("Publishing to GitLab..."); // Publish to GitLab first, if that fails, publish to local storage - var success = Options.DummyMode || await _gitlabStorageService.PublishAsync("User Reporting", reportingFiles); + try + { + if (!Options.DummyMode) + { + success = await _gitlabStorageService.PublishAsync("User Reporting", reportingFiles); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "GitLab publish failed. Falling back to local storage."); + } + if (!success || Options.DummyMode) { - _logger.LogInformation("Failed to publish to GitLab. Publishing to local storage instead."); + _logger.LogInformation("Publishing to local storage instead."); success = await _localStorageService.PublishAsync("User Reporting", reportingFiles); } + return success; } - public async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() + public virtual async Task<IEnumerable<ReportingFileObject>> GenerateReportingAsync() { _logger.LogDebug("Getting all projects..."); var projects = await PaginationHelper.GetAllAsync<ProjectAdminDtoPagedResponse, ProjectAdminDto>( - (currentPage) => AdminApi.GetAllProjectsAsync(includeDeleted: false, pageNumber: currentPage, pageSize: 50)).ToListAsync(); + (currentPage) => _adminApi.GetAllProjectsAsync(includeDeleted: false, pageNumber: currentPage, pageSize: 50)).ToListAsync(); _logger.LogDebug("Got all projects."); _logger.LogDebug("Getting all roles..."); var roles = await PaginationHelper.GetAllAsync<RoleDtoPagedResponse, RoleDto>( - (currentPage) => RoleApi.GetRolesAsync(pageNumber: currentPage, pageSize: 50)).ToListAsync(); + (currentPage) => _roleApi.GetRolesAsync(pageNumber: currentPage, pageSize: 50)).ToListAsync(); _logger.LogDebug("Got all roles."); _logger.LogDebug("Working on users asynchronously..."); var users = PaginationHelper.GetAllAsync<UserDtoPagedResponse, UserDto>( - (currentPage) => AdminApi.GetAllUsersAsync(tosAccepted: true, pageNumber: currentPage, pageSize: 50)); + (currentPage) => _adminApi.GetAllUsersAsync(tosAccepted: true, pageNumber: currentPage, pageSize: 50)); var reportingFiles = new List<ReportingFileObject>(); var returnObjects = new List<UserReport>(); diff --git a/src/KpiGenerator/Utils/Helpers.cs b/src/KpiGenerator/Utils/Helpers.cs index 4ce269aba4aaa8b5da199a70e00a033cffef373f..e0e9cdf4a321939df13b01d7d0642dd164d6884d 100644 --- a/src/KpiGenerator/Utils/Helpers.cs +++ b/src/KpiGenerator/Utils/Helpers.cs @@ -11,7 +11,11 @@ public static class Helpers var result = new List<Organization>(); foreach (var org in organizations.Where(o => o is not null && o.RorUrl is not null)) { - var ror = org.RorUrl; + var ror = org?.RorUrl; + if (string.IsNullOrWhiteSpace(ror) || org is null) + { + continue; + } result.Add(new Organization { RorUrl = ror.Contains('#') ? ror[..ror.IndexOf('#')] : ror, // e.g. <https://ror.org/04xfq0f34#ORG-42NHW> turns into <https://ror.org/04xfq0f34>