Skip to content

Commit

Permalink
Set UpdatedEntity on an import notification when a linked movement is…
Browse files Browse the repository at this point in the history
… being updated (#97)

* Signal on import notification when a related entity has changed by adding an audit entry, which will set UpdatedEntity field on save

* Formatting

* Formatting

* Show UpdatedEntity changing when a link already exists

* Don't create audit when related data changes, only save via Update

* Formatting

* Add class constraint

* Remove unused method

* Ignore SonarQube warning

* Remove related data service and replace with direct DB context update call
  • Loading branch information
tjpeel-ee authored Feb 4, 2025
1 parent d134fff commit 63593cf
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 83 deletions.
2 changes: 2 additions & 0 deletions Btms.Backend.Data/IMongoCollectionSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IMongoCollectionSet<T> : IQueryable<T> where T : IDataEntity
Task Insert(T item, CancellationToken cancellationToken = default);

Task Update(T item, CancellationToken cancellationToken = default);

Task Update(List<T> items, CancellationToken cancellationToken = default);

Task Update(T item, string etag, CancellationToken cancellationToken = default);

Expand Down
8 changes: 8 additions & 0 deletions Btms.Backend.Data/InMemory/MemoryCollectionSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ public Task Update(T item, CancellationToken cancellationToken = default)
return Update(item, item._Etag, cancellationToken);
}

public async Task Update(List<T> items, CancellationToken cancellationToken = default)
{
foreach (var item in items)
{
await Update(item, cancellationToken);
}
}

[SuppressMessage("SonarLint", "S2955",
Justification =
"IEquatable<T> would need to be implemented on every data entity just to stop sonar complaining about a null check. Nope.")]
Expand Down
27 changes: 18 additions & 9 deletions Btms.Backend.Data/Mongo/MongoCollectionSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
namespace Btms.Backend.Data.Mongo;

public class MongoCollectionSet<T>(MongoDbContext dbContext, string collectionName = null!)
: IMongoCollectionSet<T> where T : IDataEntity
: IMongoCollectionSet<T> where T : class, IDataEntity
{
private readonly IMongoCollection<T> collection = string.IsNullOrEmpty(collectionName)
private readonly IMongoCollection<T> _collection = string.IsNullOrEmpty(collectionName)
? dbContext.Database.GetCollection<T>(typeof(T).Name)
: dbContext.Database.GetCollection<T>(collectionName);

private readonly List<T> _entitiesToInsert = [];
private readonly List<(T Item, string Etag)> _entitiesToUpdate = [];

private IMongoQueryable<T> EntityQueryable => collection.AsQueryable();
private IMongoQueryable<T> EntityQueryable => _collection.AsQueryable();

public IEnumerator<T> GetEnumerator()
{
Expand Down Expand Up @@ -50,9 +50,9 @@ public async Task PersistAsync(CancellationToken cancellationToken)
foreach (var item in _entitiesToInsert)
{
item._Etag = BsonObjectIdGenerator.Instance.GenerateId(null, null).ToString()!;
item.Created = DateTime.UtcNow;
item.UpdatedEntity = DateTime.UtcNow;
await collection.InsertOneAsync(dbContext.ActiveTransaction?.Session, item, cancellationToken: cancellationToken);
item.Created = item.UpdatedEntity = DateTime.UtcNow;

await _collection.InsertOneAsync(dbContext.ActiveTransaction?.Session, item, cancellationToken: cancellationToken);
}

_entitiesToInsert.Clear();
Expand All @@ -68,11 +68,12 @@ public async Task PersistAsync(CancellationToken cancellationToken)

item.Item._Etag = BsonObjectIdGenerator.Instance.GenerateId(null, null).ToString()!;
item.Item.UpdatedEntity = DateTime.UtcNow;

var session = dbContext.ActiveTransaction?.Session;
var updateResult = session is not null
? await collection.ReplaceOneAsync(session, filter, item.Item,
? await _collection.ReplaceOneAsync(session, filter, item.Item,
cancellationToken: cancellationToken)
: await collection.ReplaceOneAsync(filter, item.Item,
: await _collection.ReplaceOneAsync(filter, item.Item,
cancellationToken: cancellationToken);

if (updateResult.ModifiedCount == 0)
Expand All @@ -96,6 +97,14 @@ public async Task Update(T item, CancellationToken cancellationToken = default)
await Update(item, item._Etag, cancellationToken);
}

public async Task Update(List<T> items, CancellationToken cancellationToken = default)
{
foreach (var item in items)
{
await Update(item, cancellationToken);
}
}

public Task Update(T item, string etag, CancellationToken cancellationToken = default)
{
if (_entitiesToInsert.Exists(x => x.Id == item.Id))
Expand All @@ -111,6 +120,6 @@ public Task Update(T item, string etag, CancellationToken cancellationToken = d

public IAggregateFluent<T> Aggregate()
{
return collection.Aggregate();
return _collection.Aggregate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"serviceHeader": {
"sourceSystem": "CDSSIM",
"destinationSystem": "ALVS",
"correlationId": "000",
"serviceCallTimestamp": "2024-10-04T13:58:09.5864286Z"
},
"header": {
"entryReference": "CHEDAGB20241041389",
"entryVersionNumber": 3,
"previousVersionNumber": 2,
"declarationUCR": "SimCHEDA.GB.2024.1041389",
"declarationPartNumber": null,
"declarationType": null,
"arrivalDateTime": null,
"submitterTURN": null,
"declarantId": null,
"declarantName": "CDS_Simulator",
"dispatchCountryCode": null,
"goodsLocationCode": "BELBELGVM",
"masterUCR": "SimCHEDA.GB.2024.1041389"
},
"items": [
{
"clearanceRequestReference": null,
"itemNumber": 1,
"customsProcedureCode": null,
"taricCommodityCode": null,
"goodsDescription": null,
"consigneeId": null,
"consigneeName": null,
"itemNetMass": null,
"itemSupplementaryUnits": null,
"itemThirdQuantity": null,
"itemOriginCountryCode": null,
"documents": [
{
"documentCode": "C640",
"documentReference": "GBCHD2024.1041389",
"documentStatus": "P",
"documentControl": null,
"documentQuantity": 3
}
],
"check": [
{
"checkCode": "H2019",
"departmentCode": "GB"
}
]
}
]
}
1 change: 1 addition & 0 deletions Btms.Backend.IntegrationTests/Fixtures/Linking/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The intention is that this data follows the SmokeTest data to provide additional updates that can be applied after the smoke test data has been imported.
48 changes: 45 additions & 3 deletions Btms.Backend.IntegrationTests/LinkingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ await Client.MakeSyncClearanceRequest(new SyncClearanceRequestsCommand
public async Task SyncClearanceRequests_WithReferencedNotifications_ShouldLink()
{
// Arrange
await base.ClearDb();
await ClearDb();

// Act
await Client.MakeSyncNotificationsRequest(new SyncNotificationsCommand
Expand All @@ -60,7 +60,7 @@ await Client.MakeSyncClearanceRequest(new SyncClearanceRequestsCommand
public async Task SyncNotifications_WithNoReferencedMovements_ShouldNotLink()
{
// Arrange
await base.ClearDb();
await ClearDb();

// Act
await Client.MakeSyncNotificationsRequest(new SyncNotificationsCommand
Expand All @@ -81,7 +81,7 @@ await Client.MakeSyncNotificationsRequest(new SyncNotificationsCommand
public async Task SyncNotifications_WithReferencedMovements_ShouldLink()
{
// Arrange
await base.ClearDb();
await ClearDb();

// Act
await Client.MakeSyncClearanceRequest(new SyncClearanceRequestsCommand
Expand All @@ -101,4 +101,46 @@ await Client.MakeSyncNotificationsRequest(new SyncNotificationsCommand
.Any(x => x.Value is { Links: not null })
.Should().Be(true);
}

[Fact]
public async Task ImportNotification_ResourceUpdated_UpdatedFieldOnResource_ShouldNotChange()
{
await ClearDb();

// Import notifications
await Client.MakeSyncNotificationsRequest(new SyncNotificationsCommand
{
SyncPeriod = SyncPeriod.All, RootFolder = "SmokeTest"
});

var document = Client.AsJsonApiClient().GetById("CHEDA.GB.2024.1041389", "api/import-notifications");
var updated = DateTime.Parse((document.Data.Attributes?["updated"]!).ToString()!);
var updatedEntity = DateTime.Parse((document.Data.Attributes?["updatedEntity"]!).ToString()!);

// Import clearance requests and initial linking will take place
await Client.MakeSyncClearanceRequest(new SyncClearanceRequestsCommand
{
SyncPeriod = SyncPeriod.All, RootFolder = "SmokeTest"
});

document = Client.AsJsonApiClient().GetById("CHEDA.GB.2024.1041389", "api/import-notifications");
var updatedPostLink = DateTime.Parse((document.Data.Attributes?["updated"]!).ToString()!);
var updatedEntityPostLink = DateTime.Parse((document.Data.Attributes?["updatedEntity"]!).ToString()!);

updated.Should().Be(updatedPostLink);
updatedEntity.Should().BeBefore(updatedEntityPostLink);

// Import new clearance version, link will already exist, but UpdateEntity will still change
await Client.MakeSyncClearanceRequest(new SyncClearanceRequestsCommand
{
SyncPeriod = SyncPeriod.All, RootFolder = "Linking"
});

document = Client.AsJsonApiClient().GetById("CHEDA.GB.2024.1041389", "api/import-notifications");
var updatedPostMovementUpdate = DateTime.Parse((document.Data.Attributes?["updated"]!).ToString()!);
var updatedEntityPostMovementUpdate = DateTime.Parse((document.Data.Attributes?["updatedEntity"]!).ToString()!);

updatedPostLink.Should().Be(updatedPostMovementUpdate);
updatedEntityPostLink.Should().BeBefore(updatedEntityPostMovementUpdate);
}
}
88 changes: 33 additions & 55 deletions Btms.Consumers.Tests/ClearanceRequestConsumerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Btms.Backend.Data.InMemory;
using Btms.Backend.Data;
using Btms.Business.Builders;
using Btms.Business.Pipelines.PreProcessing;
using Btms.Business.Services.Decisions;
Expand All @@ -21,6 +21,13 @@ namespace Btms.Consumers.Tests;

public class ClearanceRequestConsumerTests
{
private readonly ILinkingService _mockLinkingService = Substitute.For<ILinkingService>();
private readonly IDecisionService _decisionService = Substitute.For<IDecisionService>();
private readonly IMatchingService _matchingService = Substitute.For<IMatchingService>();
private readonly IValidationService _validationService = Substitute.For<IValidationService>();
private readonly IMongoDbContext _mongoDbContext = Substitute.For<IMongoDbContext>();
private readonly IPreProcessor<AlvsClearanceRequest, Movement> _preProcessor = Substitute.For<IPreProcessor<AlvsClearanceRequest, Movement>>();

[Theory]
[InlineData(PreProcessingOutcome.New)]
[InlineData(PreProcessingOutcome.Skipped)]
Expand All @@ -32,91 +39,62 @@ public async Task WhenPreProcessingSucceeds_AndLastAuditEntryIsLinked_ThenLinkSh
var clearanceRequest = CreateAlvsClearanceRequest();
var mbFactory = new MovementBuilderFactory(NullLogger<MovementBuilder>.Instance);
var mb = mbFactory.From(AlvsClearanceRequestMapper.Map(clearanceRequest));

mb.Update(AuditEntry.CreateLinked("Test", 1));

var movement = mb.Build();

var mockLinkingService = Substitute.For<ILinkingService>();
var decisionService = Substitute.For<IDecisionService>();
var matchingService = Substitute.For<IMatchingService>();
var validationService = Substitute.For<IValidationService>();
var preProcessor = Substitute.For<IPreProcessor<AlvsClearanceRequest, Model.Movement>>();

preProcessor.Process(Arg.Any<PreProcessingContext<AlvsClearanceRequest>>())
_preProcessor.Process(Arg.Any<PreProcessingContext<AlvsClearanceRequest>>())
.Returns(Task.FromResult(new PreProcessingResult<Movement>(outcome, movement, null)));

var consumer =
new AlvsClearanceRequestConsumer(preProcessor, mockLinkingService, matchingService, decisionService, validationService, NullLogger<AlvsClearanceRequestConsumer>.Instance, null!)
{
Context = new ConsumerContext
{
Headers = new Dictionary<string, object>
{
{ "messageId", clearanceRequest.Header!.EntryReference! }
}
}
};
var consumer = CreateSubject(clearanceRequest.Header!.EntryReference!);

// ACT
await consumer.OnHandle(clearanceRequest, CancellationToken.None);

// ASSERT
consumer.Context.IsLinked().Should().BeFalse();

await mockLinkingService.DidNotReceive().Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>());
await _mockLinkingService.DidNotReceive().Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task WhenPreProcessingSucceeds_AndLastAuditEntryIsCreated_ThenLinkShouldBeRun()
{
// ARRANGE
var mbFactory = new MovementBuilderFactory(NullLogger<MovementBuilder>.Instance);
var clearanceRequest = CreateAlvsClearanceRequest();

var mbFactory = new MovementBuilderFactory(NullLogger<MovementBuilder>.Instance);
var mb = mbFactory.From(AlvsClearanceRequestMapper.Map(clearanceRequest));

mb.Update(mb.CreateAuditEntry("Test", CreatedBySystem.Cds));

var movement = mb.Build();

var mockLinkingService = Substitute.For<ILinkingService>();
var decisionService = Substitute.For<IDecisionService>();
var matchingService = Substitute.For<IMatchingService>();
var validationService = Substitute.For<IValidationService>();
var preProcessor = Substitute.For<IPreProcessor<AlvsClearanceRequest, Model.Movement>>();

mockLinkingService.Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new LinkResult(LinkOutcome.Linked)));

preProcessor.Process(Arg.Any<PreProcessingContext<AlvsClearanceRequest>>())
_preProcessor.Process(Arg.Any<PreProcessingContext<AlvsClearanceRequest>>())
.Returns(Task.FromResult(new PreProcessingResult<Movement>(PreProcessingOutcome.New, movement, null)));

var consumer =
new AlvsClearanceRequestConsumer(preProcessor, mockLinkingService, matchingService, decisionService, validationService, NullLogger<AlvsClearanceRequestConsumer>.Instance, new MemoryMongoDbContext())
{
Context = new ConsumerContext
{
Headers = new Dictionary<string, object>
{
{ "messageId", clearanceRequest.Header!.EntryReference! }
}
}
};
_mockLinkingService.Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new LinkResult(LinkOutcome.Linked)));
var consumer = CreateSubject(clearanceRequest.Header!.EntryReference!);

// ACT
await consumer.OnHandle(clearanceRequest, CancellationToken.None);

// ASSERT
consumer.Context.IsPreProcessed().Should().BeTrue();
consumer.Context.IsLinked().Should().BeTrue();

await mockLinkingService.Received().Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>());
await _mockLinkingService.Received().Link(Arg.Any<LinkContext>(), Arg.Any<CancellationToken>());
}

private static AlvsClearanceRequest CreateAlvsClearanceRequest()
{
return ClearanceRequestBuilder.Default()
.WithValidDocumentReferenceNumbers().Build();
}
}

private AlvsClearanceRequestConsumer CreateSubject(string messageId)
{
return new AlvsClearanceRequestConsumer(_preProcessor, _mockLinkingService, _matchingService, _decisionService,
_validationService, _mongoDbContext, NullLogger<AlvsClearanceRequestConsumer>.Instance)
{
Context = new ConsumerContext
{
Headers = new Dictionary<string, object>
{
{ "messageId", messageId }
}
}
};
}
}
Loading

0 comments on commit 63593cf

Please sign in to comment.