diff --git a/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs b/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs index 3296378..464b39e 100644 --- a/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs +++ b/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs @@ -1,9 +1,11 @@ using AutoMapper; +using System.Text.Json; using ZDatabase.Exceptions; using ZDatabase.Repositories.Audit.Interfaces; using ZSecurity.Services; using ZWebAPI.Interfaces; using ZWebAPI.Models; +using ZWebAPI.Models.Audit; using ZWebAPI.Models.Audit.OperationHistory; using ZWebAPI.Models.Audit.ServiceHistory; using ZWebAPI.Services; @@ -254,6 +256,202 @@ public async Task ListEntityOperationsHistoryAsync_Fail_ThrowsForAnUnknownServic await act.Should().ThrowAsync>(); } + /// + /// Test the ListEntityServicesHistoryAsync should match the service name with LIKE. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersTheServiceNameWithLike() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.Name, "\"Second\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(2L); + } + + /// + /// Test the ListEntityServicesHistoryAsync should match the author exactly. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersTheAuthor() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.ChangedByID, "1")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ChangedByName).Should().Equal("Author"); + } + + /// + /// Test the ListEntityServicesHistoryAsync should treat both ends of the date range as inclusive. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersAnInclusiveDateRange() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + // The bounds are the two rows' own instants, so an exclusive comparison would return nothing. + IListParameters parameters = Filters( + (AuditFilters.ChangedOnFrom, "\"2024-01-01T00:00:00\""), + (AuditFilters.ChangedOnTo, "\"2024-02-01T00:00:00\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 1L, 2L }); + } + + /// + /// Test the ListEntityServicesHistoryAsync should narrow a date range to one end. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersFromADateAlone() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.ChangedOnFrom, "\"2024-01-15T00:00:00\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(2L); + } + + /// + /// Test the ListEntityServicesHistoryAsync should combine the filters rather than pick one. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_CombinesEveryFilter() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + // Each filter alone matches row 2; the author does not, so together they match nothing. + IListParameters parameters = Filters( + (AuditFilters.Name, "\"Second\""), + (AuditFilters.ChangedByID, "1")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Should().BeEmpty(); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should list what a service touched beyond the + /// audited record unless asked otherwise. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_KeepsTheRelatedRowsByDefault() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, new ListParametersModel()); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 10L, 20L }); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should narrow the operations to the audited + /// record when asked. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_NarrowsToTheAuditedRecordWhenAsked() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + IListParameters parameters = Filters((AuditFilters.OnlyCurrentEntity, "true")); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(10L); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should leave the operations alone when the + /// filter is present but off. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_KeepsTheRelatedRowsWhenTheFilterIsOff() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + IListParameters parameters = Filters((AuditFilters.OnlyCurrentEntity, "false")); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, parameters); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 10L, 20L }); + } + + private static ListParametersModel Filters(params (string Key, string Json)[] filters) + { + ListParametersModel parameters = new(); + + foreach ((string key, string json) in filters) + { + // A filter reaches the server as a JsonElement, and the conversion behind + // GetFilterValue reads it as one. Building the dictionary any other way tests a + // path no request takes. + parameters.Filters![key] = JsonDocument.Parse(json).RootElement; + } + + return parameters; + } + private static void SeedServicesHistory(DbContextFake context) { UsersFake author = new() { ID = 1, Name = "Author" }; diff --git a/ZWebAPI/Models/Audit/AuditFilters.cs b/ZWebAPI/Models/Audit/AuditFilters.cs new file mode 100644 index 0000000..4a7489e --- /dev/null +++ b/ZWebAPI/Models/Audit/AuditFilters.cs @@ -0,0 +1,49 @@ +namespace ZWebAPI.Models.Audit +{ + /// + /// Names of the filters + /// understands in an . + /// + /// + /// A filter arrives as a dictionary key, so the client and the server agree on a string and + /// nothing checks that they still do. These constants are that string, named once, so a + /// consumer can reference them instead of spelling them again. + /// Lookups are case insensitive, which is what lets a camelCase form control reach a + /// PascalCase property. + /// + public static class AuditFilters + { + /// + /// Identifier of the user who ran the service. Matched exactly. + /// + public const string ChangedByID = nameof(ChangedByID); + + /// + /// Earliest instant to include, compared against the service history's ChangedOn and + /// inclusive of it. UTC, because that is how the instant is stored. + /// + public const string ChangedOnFrom = nameof(ChangedOnFrom); + + /// + /// Latest instant to include, compared against the service history's ChangedOn and + /// inclusive of it. UTC, because that is how the instant is stored. + /// + public const string ChangedOnTo = nameof(ChangedOnTo); + + /// + /// Name of the service that ran, in its IService\Method form. Matched with LIKE. + /// + public const string Name = nameof(Name); + + /// + /// Restricts the operations listed for a service history to the record being audited. + /// + /// + /// A service usually writes to more than one table, and by default every operation it + /// performed is listed -- which is what shows the related rows a change also touched. + /// Set this to narrow the list to the audited record alone. It has no effect on the + /// services list, which is already restricted to the services that touched the record. + /// + public const string OnlyCurrentEntity = nameof(OnlyCurrentEntity); + } +} diff --git a/ZWebAPI/Services/AuditServiceDefault.cs b/ZWebAPI/Services/AuditServiceDefault.cs index 16fa69d..fc57114 100644 --- a/ZWebAPI/Services/AuditServiceDefault.cs +++ b/ZWebAPI/Services/AuditServiceDefault.cs @@ -12,8 +12,10 @@ using ZSecurity.Attributes; using ZSecurity.Helpers; using ZSecurity.Services; +using ZWebAPI.Enums; using ZWebAPI.ExtensionMethods; using ZWebAPI.Interfaces; +using ZWebAPI.Models.Audit; using ZWebAPI.Models.Audit.OperationHistory; using ZWebAPI.Models.Audit.ServiceHistory; using ZWebAPI.Services.Interfaces; @@ -105,6 +107,10 @@ public async Task> ListEntityServicesHistor where TEntity : AuditableEntity { return (await servicesHistoryRepository.ListServicesAsync(entityID)) + .TryFilter(parameters, x => x.Name, AuditFilters.Name, FilterTypes.Like) + .TryFilter(parameters, x => x.ChangedByID, AuditFilters.ChangedByID, FilterTypes.Equals) + .TryFilter(parameters, x => x.ChangedOn, AuditFilters.ChangedOnFrom, FilterTypes.GreatherThanOrEqual) + .TryFilter(parameters, x => x.ChangedOn, AuditFilters.ChangedOnTo, FilterTypes.LessThanOrEqual) .GetRange(parameters) .ProjectTo(mapper.ConfigurationProvider); } @@ -127,11 +133,20 @@ public async Task> ListEntityOperationsHi // The returned operations must contain the entity identifier and the table name. EntityEntry entry = dbContext.Entry(entity); - if (!await operations.AnyAsync(x => EF.Functions.Like(x.TableName ?? string.Empty, entry.Metadata.GetTableName() ?? string.Empty) && x.EntityID == entityID)) + string tableName = entry.Metadata.GetTableName() ?? string.Empty; + + if (!await operations.AnyAsync(x => EF.Functions.Like(x.TableName ?? string.Empty, tableName) && x.EntityID == entityID)) { return Enumerable.Empty().AsQueryable(); } + // A service writes to more than one table, and listing all of it is what shows the rows + // a change also touched. Narrowed only when asked. + if (parameters.GetFilterValue(AuditFilters.OnlyCurrentEntity) == true) + { + operations = operations.Where(x => EF.Functions.Like(x.TableName ?? string.Empty, tableName) && x.EntityID == entityID); + } + return operations .GetRange(parameters) .ProjectTo(mapper.ConfigurationProvider); diff --git a/ZWebAPI/Services/Interfaces/IAuditService.cs b/ZWebAPI/Services/Interfaces/IAuditService.cs index 8854f52..edacd01 100644 --- a/ZWebAPI/Services/Interfaces/IAuditService.cs +++ b/ZWebAPI/Services/Interfaces/IAuditService.cs @@ -34,7 +34,7 @@ Task AddOperationHistoryAsync(EntityEntry entityEntry) /// The type of the entity. /// The entity identifier. /// The service history identifier. - /// The parameters. + /// The parameters. Recognises . /// Query with all operations history from the service history identifier Task> ListEntityOperationsHistoryAsync(long entityID, long serviceHistoryID, IListParameters parameters) where TEntity : AuditableEntity; @@ -44,7 +44,12 @@ Task> ListEntityOperationsHistoryAsync /// The type of the entity. /// The entity identifier. - /// The parameters. + /// + /// The parameters. Recognises , + /// , + /// and + /// . + /// /// Query with all services history from the entity identifier. Task> ListEntityServicesHistoryAsync(long entityID, IListParameters parameters) where TEntity : AuditableEntity;