From 3494a11c77e2179bf6ce086f20979a1a31ac0b71 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 09:12:59 +0000 Subject: [PATCH 1/7] Fix: Update organization models and improve reporting logic --- .../MappingProfiles/MappingProfiles.cs | 2 +- src/KpiGenerator/Models/Organization.cs | 12 ---------- .../Models/ProjectOrganization.cs | 20 ++-------------- src/KpiGenerator/Models/ProjectReport.cs | 2 +- src/KpiGenerator/Models/UserOrganization.cs | 17 ++++++++++++++ src/KpiGenerator/Models/UserReport.cs | 2 +- src/KpiGenerator/Program.cs | 3 +++ .../ApplicationProfileReporting.cs | 19 ++++++++++++--- .../Reportings/Complete/CompleteReporting.cs | 22 +++++++++++++++--- .../Reportings/Project/ProjectReporting.cs | 17 ++++++++++++-- .../Reportings/Resource/ResourceReporting.cs | 19 ++++++++++++--- .../Reportings/System/SystemReporting.cs | 23 +++++++++++++++---- .../Reportings/User/UserReporting.cs | 19 ++++++++++++--- src/KpiGenerator/Utils/Helpers.cs | 6 ++++- 14 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 src/KpiGenerator/Models/UserOrganization.cs diff --git a/src/KpiGenerator/MappingProfiles/MappingProfiles.cs b/src/KpiGenerator/MappingProfiles/MappingProfiles.cs index e09ef85..1b5e1b3 100644 --- a/src/KpiGenerator/MappingProfiles/MappingProfiles.cs +++ b/src/KpiGenerator/MappingProfiles/MappingProfiles.cs @@ -49,7 +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<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)); diff --git a/src/KpiGenerator/Models/Organization.cs b/src/KpiGenerator/Models/Organization.cs index 9b42922..47be044 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 index 741e0d5..cd84ccd 100644 --- a/src/KpiGenerator/Models/ProjectOrganization.cs +++ b/src/KpiGenerator/Models/ProjectOrganization.cs @@ -1,23 +1,7 @@ -using System.Text.Json.Serialization; +namespace Coscine.KpiGenerator.Models; -namespace Coscine.KpiGenerator.Models; - -public record ProjectOrganization +public record ProjectOrganization : Organization { - /// <summary> - /// Organizaiton name from GitLab project's description - /// </summary> - /// <example>RWTH Aachen University</example> - [JsonPropertyName("Name")] - public string Name { get; init; } = null!; - - /// <summary> - /// Organizaiton ROR URL from GitLab project's title - /// </summary> - /// <example>https://ror.org/04xfq0f34</example> - [JsonPropertyName("RorUrl")] - public string RorUrl { get; init; } = null!; - /// <summary> /// Determines if the organization is Responsible for a given project. /// </summary> diff --git a/src/KpiGenerator/Models/ProjectReport.cs b/src/KpiGenerator/Models/ProjectReport.cs index fcd871b..f410b5d 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/UserOrganization.cs b/src/KpiGenerator/Models/UserOrganization.cs new file mode 100644 index 0000000..a22fadc --- /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 08fd01c..0e780dd 100644 --- a/src/KpiGenerator/Models/UserReport.cs +++ b/src/KpiGenerator/Models/UserReport.cs @@ -4,7 +4,7 @@ public record UserReport { public IReadOnlyList<RelatedProject> RelatedProjects { get; set; } = null!; - public IReadOnlyList<Organization> Organizations { 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 c1f05f5..27bcc16 100644 --- a/src/KpiGenerator/Program.cs +++ b/src/KpiGenerator/Program.cs @@ -1,9 +1,12 @@ using CommandLine; 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; diff --git a/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs b/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs index 6170ead..7e00a66 100644 --- a/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs +++ b/src/KpiGenerator/Reportings/ApplicationProfile/ApplicationProfileReporting.cs @@ -15,7 +15,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 { @@ -70,14 +70,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; } diff --git a/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs b/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs index 05e489e..1e264f8 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; @@ -49,14 +52,27 @@ public class CompleteReporting 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 ddcd822..f8d7689 100644 --- a/src/KpiGenerator/Reportings/Project/ProjectReporting.cs +++ b/src/KpiGenerator/Reportings/Project/ProjectReporting.cs @@ -66,14 +66,27 @@ 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; } diff --git a/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs b/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs index 942b49e..d132007 100644 --- a/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs +++ b/src/KpiGenerator/Reportings/Resource/ResourceReporting.cs @@ -67,14 +67,27 @@ 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; } diff --git a/src/KpiGenerator/Reportings/System/SystemReporting.cs b/src/KpiGenerator/Reportings/System/SystemReporting.cs index c47dd82..29dce49 100644 --- a/src/KpiGenerator/Reportings/System/SystemReporting.cs +++ b/src/KpiGenerator/Reportings/System/SystemReporting.cs @@ -1,5 +1,4 @@ using AutoMapper; -using Coscine.ApiClient; using Coscine.ApiClient.Core.Api; using Coscine.ApiClient.Core.Client; using Coscine.KpiGenerator.Models; @@ -12,8 +11,9 @@ 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 { @@ -69,14 +69,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 +120,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 7165448..1bedbf0 100644 --- a/src/KpiGenerator/Reportings/User/UserReporting.cs +++ b/src/KpiGenerator/Reportings/User/UserReporting.cs @@ -12,7 +12,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 { @@ -67,14 +67,27 @@ 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; } diff --git a/src/KpiGenerator/Utils/Helpers.cs b/src/KpiGenerator/Utils/Helpers.cs index 4ce269a..e0e9cdf 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> -- GitLab From 7e275390637a3fe018cfb91de7dd81d52e011c73 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 12:00:04 +0000 Subject: [PATCH 2/7] Update: Add unit tests with API client integration --- .../KpiGenerator.Tests.csproj | 30 ++ .../ProjectReportingTests.cs | 294 +++++++++++++++++ .../ResourceReportingTests.cs | 312 ++++++++++++++++++ src/KpiGenerator.Tests/TestData.cs | 99 ++++++ src/KpiGenerator.sln | 6 + src/KpiGenerator/Models/ResourceReport.cs | 2 +- src/KpiGenerator/Program.cs | 18 + .../ApplicationProfileReporting.cs | 26 +- .../Reportings/Complete/CompleteReporting.cs | 49 +-- .../Reportings/Project/ProjectReporting.cs | 27 +- .../Reportings/Resource/ResourceReporting.cs | 21 +- .../Reportings/System/SystemReporting.cs | 23 +- .../Reportings/User/UserReporting.cs | 31 +- 13 files changed, 812 insertions(+), 126 deletions(-) create mode 100644 src/KpiGenerator.Tests/KpiGenerator.Tests.csproj create mode 100644 src/KpiGenerator.Tests/ProjectReportingTests.cs create mode 100644 src/KpiGenerator.Tests/ResourceReportingTests.cs create mode 100644 src/KpiGenerator.Tests/TestData.cs diff --git a/src/KpiGenerator.Tests/KpiGenerator.Tests.csproj b/src/KpiGenerator.Tests/KpiGenerator.Tests.csproj new file mode 100644 index 0000000..f0551f1 --- /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 0000000..5cd8eb9 --- /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 0000000..ebd6030 --- /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 0000000..d409651 --- /dev/null +++ b/src/KpiGenerator.Tests/TestData.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; +using Coscine.ApiClient.Core.Model; + +static class TestData +{ + public static List<ProjectAdminDto> ProjectAdminDtos => ReturnProjectAdminDtos(); + public static List<ResourceAdminDto> ResourceAdminDtos => ReturnResourceAdminDtos(); + + + 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: [], + 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: [], + 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: [], + organizations: [ + new(uri: "https://ror.org/12345", displayName: "Project One Org", responsible: true), + new(uri: "https://ror.org/54321", displayName: "Project Two Org", responsible: false) + ]), + 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: [], + organizations: [ + new(uri: "https://ror.org/12345", displayName: "Project One Org", responsible: false), + new(uri: "https://ror.org/54321", displayName: "Project Two Org", responsible: true) + ]) + ]; + } +} \ No newline at end of file diff --git a/src/KpiGenerator.sln b/src/KpiGenerator.sln index 43ff394..b970c6c 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/Models/ResourceReport.cs b/src/KpiGenerator/Models/ResourceReport.cs index 8c10812..ec79d73 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/Program.cs b/src/KpiGenerator/Program.cs index 27bcc16..c33fd06 100644 --- a/src/KpiGenerator/Program.cs +++ b/src/KpiGenerator/Program.cs @@ -1,4 +1,6 @@ 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; @@ -168,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 7e00a66..c681879 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; @@ -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) @@ -101,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) @@ -110,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 1e264f8..1ca432d 100644 --- a/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs +++ b/src/KpiGenerator/Reportings/Complete/CompleteReporting.cs @@ -11,41 +11,28 @@ 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..."); diff --git a/src/KpiGenerator/Reportings/Project/ProjectReporting.cs b/src/KpiGenerator/Reportings/Project/ProjectReporting.cs index f8d7689..ba8d846 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) @@ -90,11 +81,11 @@ public class ProjectReporting 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 d132007..3857acb 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) @@ -91,16 +86,16 @@ public class ResourceReporting 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>(); @@ -115,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 29dce49..b19a23c 100644 --- a/src/KpiGenerator/Reportings/System/SystemReporting.cs +++ b/src/KpiGenerator/Reportings/System/SystemReporting.cs @@ -1,7 +1,4 @@ -using AutoMapper; -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; @@ -17,7 +14,6 @@ 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) diff --git a/src/KpiGenerator/Reportings/User/UserReporting.cs b/src/KpiGenerator/Reportings/User/UserReporting.cs index 1bedbf0..8cb8d4e 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; @@ -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) @@ -95,17 +84,17 @@ public class UserReporting { _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>(); -- GitLab From a9d8866b02453d0a25114b71d866f81873574681 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 12:00:49 +0000 Subject: [PATCH 3/7] Update: Add test stage to GitLab CI configuration --- .gitlab-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e819ecc..a636a3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,9 @@ build-branch: build-nuget-release: extends: .build-nuget-release +test: + extends: .test + publish-gitlab-release: extends: .publish-gitlab-release -- GitLab From 93869eb7a363862e8c7cce3275009c15cd0662d8 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 12:02:30 +0000 Subject: [PATCH 4/7] Update: Add test stage to GitLab CI and modify launch arguments for KpiGenerator --- .gitlab-ci.yml | 1 + .vscode/launch.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a636a3e..9973627 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ include: stages: - build + - test - publish variables: diff --git a/.vscode/launch.json b/.vscode/launch.json index df8dcbc..6239fa2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "dotnet: build", "program": "${workspaceFolder}/src/KpiGenerator/bin/Debug/net8.0/Coscine.KpiGenerator.dll", - "args": [], + "args": ["resources"], "cwd": "${workspaceFolder}/src/KpiGenerator", "stopAtEntry": false, "console": "internalConsole", -- GitLab From 9354183dac47c19ded8b162240ce799ca2b30ddd Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 12:02:59 +0000 Subject: [PATCH 5/7] Update: Modify launch arguments in VSCode configuration for KpiGenerator --- .vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6239fa2..df8dcbc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "dotnet: build", "program": "${workspaceFolder}/src/KpiGenerator/bin/Debug/net8.0/Coscine.KpiGenerator.dll", - "args": ["resources"], + "args": [], "cwd": "${workspaceFolder}/src/KpiGenerator", "stopAtEntry": false, "console": "internalConsole", -- GitLab From fb76d3ff0884ec6a71801fa6633d6d099ea9d138 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 12:24:47 +0000 Subject: [PATCH 6/7] Update: Enhance UserReporting and TestData with new user and role data structures --- src/KpiGenerator.Tests/TestData.cs | 73 +++- src/KpiGenerator.Tests/UserReportingTests.cs | 333 ++++++++++++++++++ .../Reportings/User/UserReporting.cs | 2 +- 3 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 src/KpiGenerator.Tests/UserReportingTests.cs diff --git a/src/KpiGenerator.Tests/TestData.cs b/src/KpiGenerator.Tests/TestData.cs index d409651..3566ead 100644 --- a/src/KpiGenerator.Tests/TestData.cs +++ b/src/KpiGenerator.Tests/TestData.cs @@ -1,12 +1,59 @@ -using System.ComponentModel; 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 @@ -22,7 +69,7 @@ static class TestData 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: [], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], license: new(), usageRights: "usage rights", metadataLocalCopy: false, @@ -45,7 +92,7 @@ static class TestData resourceQuota: new(resource: new(id: Guid.Parse("cc5d1783-8ff1-4401-a2ce-d1300bd1b2a5")), usedPercentage: 0.5f, used: new(), reserved: new()), visibility: new(), keywords: ["keyword3"], - disciplines: [], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], license: new(), usageRights: "usage rights", metadataLocalCopy: false, @@ -74,11 +121,13 @@ static class TestData creator: new() { Id = Guid.NewGuid() }, projectQuota: [], visibility: new(), - disciplines: [], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], organizations: [ - new(uri: "https://ror.org/12345", displayName: "Project One Org", responsible: true), - new(uri: "https://ror.org/54321", displayName: "Project Two Org", responsible: false) - ]), + 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", @@ -89,11 +138,13 @@ static class TestData creator: new() { Id = Guid.NewGuid() }, projectQuota: [], visibility: new(), - disciplines: [], + disciplines: [new(id: Guid.NewGuid(), uri: "https://one.discipline.org", displayNameEn: "Discipline One", displayNameDe: "Disziplin Eins")], organizations: [ - new(uri: "https://ror.org/12345", displayName: "Project One Org", responsible: false), - new(uri: "https://ror.org/54321", displayName: "Project Two Org", responsible: true) - ]) + 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 0000000..5386dbd --- /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/Reportings/User/UserReporting.cs b/src/KpiGenerator/Reportings/User/UserReporting.cs index 8cb8d4e..8db2ad4 100644 --- a/src/KpiGenerator/Reportings/User/UserReporting.cs +++ b/src/KpiGenerator/Reportings/User/UserReporting.cs @@ -80,7 +80,7 @@ public class UserReporting 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>( -- GitLab From 193fce2252dba3c9b9fe09896dc60df537df6942 Mon Sep 17 00:00:00 2001 From: Petar Hristov <hristov@itc.rwth-aachen.de> Date: Fri, 31 Jan 2025 13:04:00 +0000 Subject: [PATCH 7/7] Update: Change copyright year to 2025 in KpiGenerator project file --- src/KpiGenerator/KpiGenerator.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KpiGenerator/KpiGenerator.csproj b/src/KpiGenerator/KpiGenerator.csproj index f530f38..d43fcee 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> -- GitLab