feat(audit)!: ScadaBridge C3 — swap to canonical ZB.MOM.WW.Audit.AuditEvent across seams/emitters/DTO/redactor wiring; transitional 24-col storage shim (Task 2.5)
This commit is contained in:
+23
-23
@@ -1,14 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
|
||||
/// Verifies that <see cref="AuditEvent"/> maps to the AuditLog table with the
|
||||
/// Verifies that <see cref="AuditLogRow"/> maps to the AuditLog table with the
|
||||
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
|
||||
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
|
||||
/// database round-trips required.
|
||||
@@ -34,7 +34,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
|
||||
// AuditLog table — the clustered key must include the partition column
|
||||
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("AuditLog", entity!.GetTableName());
|
||||
@@ -43,7 +43,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
Assert.NotNull(pk);
|
||||
|
||||
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
|
||||
Assert.Equal(new[] { nameof(AuditLogRow.EventId), nameof(AuditLogRow.OccurredAtUtc) }, pkPropertyNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,7 +52,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
// EventId remains globally unique (the idempotency key for
|
||||
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
|
||||
// is independent of the composite PK.
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var eventIdIndex = entity!.GetIndexes()
|
||||
@@ -62,20 +62,20 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
Assert.True(eventIdIndex!.IsUnique);
|
||||
|
||||
var indexedProperty = Assert.Single(eventIdIndex.Properties);
|
||||
Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
|
||||
Assert.Equal(nameof(AuditLogRow.EventId), indexedProperty.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_HasExpectedPropertyCount()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var properties = entity!.GetProperties()
|
||||
.Where(p => !p.IsShadowProperty())
|
||||
.ToList();
|
||||
|
||||
// AuditEvent record exposes 24 init-only properties (alog.md §4 plus the
|
||||
// AuditLogRow record exposes 24 init-only properties (alog.md §4 plus the
|
||||
// additive ExecutionId universal correlation column, its ParentExecutionId
|
||||
// sibling, and the SourceNode-stamping column).
|
||||
Assert.Equal(24, properties.Count);
|
||||
@@ -84,7 +84,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
[Fact]
|
||||
public void Configure_ExpectedIndexes_WithCorrectNames()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var indexNames = entity!.GetIndexes()
|
||||
@@ -115,13 +115,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuditEvent.Channel))]
|
||||
[InlineData(nameof(AuditEvent.Kind))]
|
||||
[InlineData(nameof(AuditEvent.Status))]
|
||||
[InlineData(nameof(AuditEvent.ForwardState))]
|
||||
[InlineData(nameof(AuditLogRow.Channel))]
|
||||
[InlineData(nameof(AuditLogRow.Kind))]
|
||||
[InlineData(nameof(AuditLogRow.Status))]
|
||||
[InlineData(nameof(AuditLogRow.ForwardState))]
|
||||
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(propertyName);
|
||||
@@ -136,7 +136,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
[Fact]
|
||||
public async Task Configure_UtcConverter_HydratesOccurredAtUtcAsKindUtc()
|
||||
{
|
||||
// Insert an AuditEvent with an Unspecified-Kind DateTime, then re-read
|
||||
// Insert an AuditLogRow with an Unspecified-Kind DateTime, then re-read
|
||||
// it in a fresh context. The UtcConverter on the OccurredAtUtc /
|
||||
// IngestedAtUtc columns must re-tag the round-tripped value as
|
||||
// DateTimeKind.Utc. Without the converter the SQLite (and on production
|
||||
@@ -147,10 +147,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
var eventId = Guid.NewGuid();
|
||||
var siteId = "test-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
var evt = new AuditEvent
|
||||
var evt = new AuditLogRow
|
||||
{
|
||||
EventId = eventId,
|
||||
// The AuditEvent record's init-setter (Commons-019 resolution)
|
||||
// The AuditLogRow record's init-setter (Commons-019 resolution)
|
||||
// re-tags Unspecified values as Utc on assignment, so the value EF
|
||||
// ultimately writes already has Kind=Utc. The converter's job is
|
||||
// to keep the Kind tag on the READ path, which the assertions
|
||||
@@ -163,14 +163,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
SourceSiteId = siteId,
|
||||
};
|
||||
|
||||
_context.Set<AuditEvent>().Add(evt);
|
||||
_context.Set<AuditLogRow>().Add(evt);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Detach the tracked entity and re-read in a fresh query so we exercise
|
||||
// the actual hydrate path, not the change-tracker cache.
|
||||
_context.ChangeTracker.Clear();
|
||||
|
||||
var loaded = await _context.Set<AuditEvent>()
|
||||
var loaded = await _context.Set<AuditLogRow>()
|
||||
.AsNoTracking()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.SingleAsync();
|
||||
@@ -192,14 +192,14 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
// future config refactor accidentally removing the HasConversion calls.
|
||||
// The converter type itself is internal to the configuration, so we
|
||||
// just assert SOME converter is present on each *Utc DateTime column.
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var occurredAt = entity!.FindProperty(nameof(AuditEvent.OccurredAtUtc));
|
||||
var occurredAt = entity!.FindProperty(nameof(AuditLogRow.OccurredAtUtc));
|
||||
Assert.NotNull(occurredAt);
|
||||
Assert.NotNull(occurredAt!.GetValueConverter());
|
||||
|
||||
var ingestedAt = entity.FindProperty(nameof(AuditEvent.IngestedAtUtc));
|
||||
var ingestedAt = entity.FindProperty(nameof(AuditLogRow.IngestedAtUtc));
|
||||
Assert.NotNull(ingestedAt);
|
||||
Assert.NotNull(ingestedAt!.GetValueConverter());
|
||||
}
|
||||
@@ -207,7 +207,7 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||
[Fact]
|
||||
public void Configure_FilteredIndexes_HaveExpectedFilters()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||
var entity = _context.Model.FindEntityType(typeof(AuditLogRow));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var correlationIdx = entity!.GetIndexes()
|
||||
|
||||
+55
-36
@@ -1,9 +1,10 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
|
||||
using Xunit;
|
||||
@@ -42,7 +43,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// Re-read in a fresh context so we exercise the persisted row, not the
|
||||
// (already-bypassed) change tracker.
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<AuditEvent>()
|
||||
var loaded = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -66,7 +67,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
await repo.InsertIfNotExistsAsync(evt);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<AuditEvent>()
|
||||
var loaded = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -92,7 +93,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
await repo.InsertIfNotExistsAsync(evt);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<AuditEvent>()
|
||||
var loaded = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -114,11 +115,20 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
await repo.InsertIfNotExistsAsync(first);
|
||||
|
||||
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
|
||||
var second = first with { ErrorMessage = "second-should-be-ignored" };
|
||||
// C3 (Task 2.5): ErrorMessage rides in DetailsJson on the canonical record, so rebuild
|
||||
// a sibling row carrying the same EventId via the factory (rather than a top-level `with`).
|
||||
var second = ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
eventId: first.EventId,
|
||||
occurredAtUtc: occurredAt,
|
||||
sourceSiteId: siteId,
|
||||
errorMessage: "second-should-be-ignored");
|
||||
await repo.InsertIfNotExistsAsync(second);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var loaded = await readContext.Set<AuditEvent>()
|
||||
var loaded = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -170,7 +180,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
|
||||
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.AsRow().Channel));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -196,8 +206,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Contains(r.Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
|
||||
Assert.DoesNotContain(rows, r => r.Channel == AuditChannel.DbOutbound);
|
||||
Assert.All(rows, r => Assert.Contains(r.AsRow().Channel, new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }));
|
||||
Assert.DoesNotContain(rows, r => r.AsRow().Channel == AuditChannel.DbOutbound);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -222,8 +232,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Contains(r.Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
|
||||
Assert.DoesNotContain(rows, r => r.Status == AuditStatus.Delivered);
|
||||
Assert.All(rows, r => Assert.Contains(r.AsRow().Status, new[] { AuditStatus.Failed, AuditStatus.Parked }));
|
||||
Assert.DoesNotContain(rows, r => r.AsRow().Status == AuditStatus.Delivered);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -247,8 +257,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Contains(r.SourceSiteId, new[] { siteA, siteB }));
|
||||
Assert.DoesNotContain(rows, r => r.SourceSiteId == siteC);
|
||||
Assert.All(rows, r => Assert.Contains(r.AsRow().SourceSiteId, new[] { siteA, siteB }));
|
||||
Assert.DoesNotContain(rows, r => r.AsRow().SourceSiteId == siteC);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -294,7 +304,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
|
||||
Assert.All(rows, r => Assert.Equal(siteId, r.AsRow().SourceSiteId));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -407,7 +417,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
|
||||
Assert.All(rows, r => Assert.Equal(executionId, r.AsRow().ExecutionId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -436,7 +446,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogPaging(PageSize: 10));
|
||||
|
||||
Assert.Equal(2, rows.Count);
|
||||
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.ParentExecutionId));
|
||||
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.AsRow().ParentExecutionId));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -494,7 +504,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||
new AuditLogPaging(
|
||||
PageSize: 2,
|
||||
AfterOccurredAtUtc: cursor.OccurredAtUtc,
|
||||
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
|
||||
AfterEventId: cursor.EventId));
|
||||
|
||||
Assert.Equal(2, page2.Count);
|
||||
@@ -506,7 +516,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
|
||||
new AuditLogPaging(
|
||||
PageSize: 2,
|
||||
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
|
||||
AfterOccurredAtUtc: cursor2.AsRow().OccurredAtUtc,
|
||||
AfterEventId: cursor2.EventId));
|
||||
|
||||
Assert.Single(page3);
|
||||
@@ -541,7 +551,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
});
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var count = await readContext.Set<AuditEvent>()
|
||||
var count = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.CountAsync();
|
||||
|
||||
@@ -587,7 +597,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
filter,
|
||||
new AuditLogPaging(
|
||||
PageSize: 2,
|
||||
AfterOccurredAtUtc: cursor.OccurredAtUtc,
|
||||
AfterOccurredAtUtc: cursor.AsRow().OccurredAtUtc,
|
||||
AfterEventId: cursor.EventId));
|
||||
|
||||
Assert.Equal(2, page2.Count);
|
||||
@@ -647,7 +657,7 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
await repo.SwitchOutPartitionAsync(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var remaining = await readContext.Set<AuditEvent>()
|
||||
var remaining = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -697,11 +707,20 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// (UX_AuditLog_EventId is the index that enables idempotency; if the
|
||||
// rebuild left it broken, this insert would silently produce a duplicate
|
||||
// row and the count assertion below would catch it).
|
||||
var dup = preExisting with { ErrorMessage = "second-should-be-ignored-after-switch" };
|
||||
// C3 (Task 2.5): rebuild a sibling row with the same EventId via the factory
|
||||
// (ErrorMessage rides in DetailsJson, so a top-level `with` no longer applies).
|
||||
var dup = ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
eventId: preExisting.EventId,
|
||||
occurredAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
|
||||
sourceSiteId: siteId,
|
||||
errorMessage: "second-should-be-ignored-after-switch");
|
||||
await repo.InsertIfNotExistsAsync(dup);
|
||||
|
||||
await using var readContext = CreateContext();
|
||||
var rows = await readContext.Set<AuditEvent>()
|
||||
var rows = await readContext.Set<AuditLogRow>()
|
||||
.Where(e => e.SourceSiteId == siteId)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -1089,6 +1108,9 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
private static string NewSiteId() =>
|
||||
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
// C3 (Task 2.5): build the canonical ZB.MOM.WW.Audit.AuditEvent via the shared
|
||||
// factory; the repository's transitional shim decomposes it into the 24-column
|
||||
// AuditLogRow on INSERT and recomposes the canonical record on QUERY.
|
||||
private static AuditEvent NewEvent(
|
||||
string siteId,
|
||||
DateTime occurredAtUtc,
|
||||
@@ -1099,17 +1121,14 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
Guid? executionId = null,
|
||||
Guid? parentExecutionId = null,
|
||||
string? sourceNode = null) =>
|
||||
new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = occurredAtUtc,
|
||||
Channel = channel,
|
||||
Kind = kind,
|
||||
Status = status,
|
||||
SourceSiteId = siteId,
|
||||
SourceNode = sourceNode,
|
||||
ErrorMessage = errorMessage,
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
};
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: channel,
|
||||
kind: kind,
|
||||
status: status,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceNode: sourceNode,
|
||||
sourceSiteId: siteId,
|
||||
executionId: executionId,
|
||||
parentExecutionId: parentExecutionId,
|
||||
errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user