Skip to content
This repository was archived by the owner on Jul 28, 2025. It is now read-only.

Commit 0593b4f

Browse files
authored
feat: DTOSS-9159 - add file-level validation (#43)
1 parent 9191037 commit 0593b4f

File tree

8 files changed

+437
-5
lines changed

8 files changed

+437
-5
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Text.RegularExpressions;
2+
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models;
3+
4+
namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation;
5+
6+
public partial class FileValidator : IFileValidator
7+
{
8+
private readonly HeaderFieldRegexValidator _headerExtractIdValidator = new(
9+
x => x.ExtractId, "Extract ID", ExtractIdRegex(),
10+
ErrorCodes.MissingExtractId, ErrorCodes.InvalidExtractId);
11+
12+
private readonly HeaderFieldRegexValidator _headerIdRecordCountValidator = new(
13+
x => x.RecordCount, "Record count", RecordCountRegex(),
14+
ErrorCodes.MissingRecordCount, ErrorCodes.InvalidRecordCount);
15+
16+
public IEnumerable<ValidationError> Validate(ParsedFile file)
17+
{
18+
return ValidateHeaderPresence(file)
19+
.Concat(ValidateTrailerPresence(file))
20+
.Concat(ValidateExtractId(file))
21+
.Concat(ValidateRecordCount(file));
22+
}
23+
24+
private static IEnumerable<ValidationError> ValidateHeaderPresence(ParsedFile file)
25+
{
26+
if (file.FileHeader == null)
27+
{
28+
yield return new ValidationError
29+
{
30+
Code = ErrorCodes.MissingHeader,
31+
Error = "Header is missing",
32+
Scope = ValidationErrorScope.File
33+
};
34+
}
35+
}
36+
37+
private static IEnumerable<ValidationError> ValidateTrailerPresence(ParsedFile file)
38+
{
39+
if (file.FileTrailer == null)
40+
{
41+
yield return new ValidationError
42+
{
43+
Code = ErrorCodes.MissingTrailer,
44+
Error = "Trailer is missing",
45+
Scope = ValidationErrorScope.File
46+
};
47+
}
48+
}
49+
50+
private IEnumerable<ValidationError> ValidateExtractId(ParsedFile file)
51+
{
52+
if (file.FileHeader == null) yield break;
53+
54+
foreach (var error in _headerExtractIdValidator.Validate(file))
55+
{
56+
yield return error;
57+
}
58+
59+
if (file.FileTrailer != null && file.FileHeader.ExtractId != file.FileTrailer.ExtractId)
60+
{
61+
yield return new ValidationError
62+
{
63+
Field = "Extract ID",
64+
Code = ErrorCodes.InconsistentExtractId,
65+
Error = "Extract ID does not match value in header",
66+
Scope = ValidationErrorScope.Trailer
67+
};
68+
}
69+
}
70+
71+
private IEnumerable<ValidationError> ValidateRecordCount(ParsedFile file)
72+
{
73+
if (file.FileHeader == null) yield break;
74+
75+
var headerRecordCountErrors = _headerIdRecordCountValidator.Validate(file).ToList();
76+
77+
foreach (var error in headerRecordCountErrors)
78+
{
79+
yield return error;
80+
}
81+
82+
if (file.FileTrailer != null && file.FileHeader.RecordCount != file.FileTrailer.RecordCount)
83+
{
84+
yield return new ValidationError
85+
{
86+
Field = "Record count",
87+
Code = ErrorCodes.InconsistentRecordCount,
88+
Error = "Record count does not match value in header",
89+
Scope = ValidationErrorScope.Trailer
90+
};
91+
}
92+
else if (headerRecordCountErrors.Count == 0 &&
93+
file.DataRecords.Count != int.Parse(file.FileHeader.RecordCount!))
94+
{
95+
yield return new ValidationError
96+
{
97+
Code = ErrorCodes.UnexpectedRecordCount,
98+
Error = "Record count does not match value in header and trailer",
99+
Scope = ValidationErrorScope.File
100+
};
101+
}
102+
}
103+
104+
[GeneratedRegex(@"^\d{8}$")]
105+
private static partial Regex ExtractIdRegex();
106+
107+
[GeneratedRegex(@"^(?!000000)\d{6}$")]
108+
private static partial Regex RecordCountRegex();
109+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Linq.Expressions;
2+
using System.Text.RegularExpressions;
3+
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Models;
4+
5+
namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation;
6+
7+
public class HeaderFieldRegexValidator(
8+
Expression<Func<FileHeaderRecord, string?>> fieldSelector,
9+
string fieldName,
10+
Regex pattern,
11+
string errorCodeMissing,
12+
string errorCodeInvalidFormat)
13+
: IFileValidator
14+
{
15+
public IEnumerable<ValidationError> Validate(ParsedFile file)
16+
{
17+
var header = file.FileHeader!;
18+
var value = fieldSelector.Compile().Invoke(header);
19+
20+
if (value == null)
21+
{
22+
yield return new ValidationError
23+
{
24+
Scope = ValidationErrorScope.Header,
25+
Field = fieldName,
26+
Error = $"{fieldName} is missing",
27+
Code = errorCodeMissing,
28+
};
29+
yield break;
30+
}
31+
32+
if (!pattern.IsMatch(value))
33+
{
34+
yield return new ValidationError
35+
{
36+
Scope = ValidationErrorScope.Header,
37+
Field = fieldName,
38+
Error = $"{fieldName} is in an invalid format",
39+
Code = errorCodeInvalidFormat,
40+
};
41+
}
42+
}
43+
}

src/ServiceLayer.Mesh/FileTypes/NbssAppointmentEvents/Validation/ValidatorRegistry.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Runtime.InteropServices.JavaScript;
21
using System.Text.RegularExpressions;
32

43
namespace ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation;
@@ -69,7 +68,7 @@ public static IEnumerable<IRecordValidator> GetAllRecordValidators()
6968

7069
public static IEnumerable<IFileValidator> GetAllFileValidators()
7170
{
72-
return [];
71+
return [new FileValidator()];
7372
}
7473

7574
[GeneratedRegex(@"^[BCU]$", RegexOptions.Compiled)]

tests/ServiceLayer.Mesh.Tests/FileTypes/NbssAppointmentEvents/TestDataBuilder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public static ParsedFile BuildValidParsedFile(int numberOfRecords = 3)
1212
ExtractId = "00000107",
1313
TransferStartDate = "20250519",
1414
TransferStartTime = "153806",
15-
RecordCount = numberOfRecords.ToString()
15+
RecordCount = numberOfRecords.ToString("D6")
1616
};
1717

1818
var trailer = new FileTrailerRecord
@@ -21,7 +21,7 @@ public static ParsedFile BuildValidParsedFile(int numberOfRecords = 3)
2121
ExtractId = "00000107",
2222
TransferEndDate = "20250519",
2323
TransferEndTime = "153957",
24-
RecordCount = numberOfRecords.ToString()
24+
RecordCount = numberOfRecords.ToString("D6")
2525
};
2626

2727
var dataRecords = Enumerable.Range(1, numberOfRecords)
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using ServiceLayer.Mesh.FileTypes.NbssAppointmentEvents.Validation;
2+
3+
namespace ServiceLayer.Mesh.Tests.FileTypes.NbssAppointmentEvents.Validation;
4+
5+
public class FileValidatorTests : ValidationTestBase
6+
{
7+
[Fact]
8+
public void Validate_HeaderMissing_ReturnsValidationError()
9+
{
10+
// Arrange
11+
var file = ValidParsedFile;
12+
file.FileHeader = null;
13+
14+
// Act
15+
var validationErrors = Validate(file);
16+
17+
// Assert
18+
validationErrors.ShouldContainValidationError(
19+
null,
20+
"Header is missing",
21+
ErrorCodes.MissingHeader,
22+
ValidationErrorScope.File
23+
);
24+
}
25+
26+
[Fact]
27+
public void Validate_TrailerMissing_ReturnsValidationError()
28+
{
29+
// Arrange
30+
var file = ValidParsedFile;
31+
file.FileTrailer = null;
32+
33+
// Act
34+
var validationErrors = Validate(file);
35+
36+
// Assert
37+
validationErrors.ShouldContainValidationError(
38+
null,
39+
"Trailer is missing",
40+
ErrorCodes.MissingTrailer,
41+
ValidationErrorScope.File
42+
);
43+
}
44+
45+
[Fact]
46+
public void Validate_HeaderExtractIdMissing_ReturnsValidationError()
47+
{
48+
// Arrange
49+
var file = ValidParsedFile;
50+
file.FileHeader!.ExtractId = null;
51+
52+
// Act
53+
var validationErrors = Validate(file);
54+
55+
// Assert
56+
validationErrors.ShouldContainValidationError(
57+
"Extract ID",
58+
"Extract ID is missing",
59+
ErrorCodes.MissingExtractId,
60+
ValidationErrorScope.Header
61+
);
62+
}
63+
64+
[Theory]
65+
[InlineData("1")] // Missing leading zeroes
66+
[InlineData("100000000")] // Too large
67+
[InlineData("")] // Blank
68+
[InlineData("asdf")] // NaN
69+
public void Validate_HeaderExtractIdInvalidFormat_ReturnsValidationError(string value)
70+
{
71+
// Arrange
72+
var file = ValidParsedFile;
73+
file.FileHeader!.ExtractId = value;
74+
75+
// Act
76+
var validationErrors = Validate(file).ToList();
77+
78+
// Assert
79+
validationErrors.ShouldContainValidationError(
80+
"Extract ID",
81+
"Extract ID is in an invalid format",
82+
ErrorCodes.InvalidExtractId,
83+
ValidationErrorScope.Header
84+
);
85+
}
86+
87+
[Theory]
88+
[InlineData("00000000")]
89+
[InlineData("00000001")]
90+
[InlineData("99999999")]
91+
public void Validate_HeaderExtractIdValidFormat_NoValidationErrorsReturned(string value)
92+
{
93+
// Arrange
94+
var file = ValidParsedFile;
95+
file.FileHeader!.ExtractId = value;
96+
file.FileTrailer!.ExtractId = value;
97+
98+
// Act
99+
var validationErrors = Validate(file).ToList();
100+
101+
// Assert
102+
Assert.Empty(validationErrors);
103+
}
104+
105+
[Fact]
106+
public void Validate_HeaderRecordCountMissing_ReturnsValidationError()
107+
{
108+
// Arrange
109+
var file = ValidParsedFile;
110+
file.FileHeader!.RecordCount = null;
111+
112+
// Act
113+
var validationErrors = Validate(file);
114+
115+
// Assert
116+
validationErrors.ShouldContainValidationError(
117+
"Record count",
118+
"Record count is missing",
119+
ErrorCodes.MissingRecordCount,
120+
ValidationErrorScope.Header
121+
);
122+
}
123+
124+
[Theory]
125+
[InlineData("1")] // Missing leading zeroes
126+
[InlineData("000000")] // All zeroes
127+
[InlineData("1000000")] // Too large
128+
[InlineData("")] // Blank
129+
[InlineData("asdf")] // NaN
130+
public void Validate_HeaderRecordCountInvalidFormat_ReturnsValidationError(string value)
131+
{
132+
// Arrange
133+
var file = ValidParsedFile;
134+
file.FileHeader!.RecordCount = value;
135+
136+
// Act
137+
var validationErrors = Validate(file).ToList();
138+
139+
// Assert
140+
validationErrors.ShouldContainValidationError(
141+
"Record count",
142+
"Record count is in an invalid format",
143+
ErrorCodes.InvalidRecordCount,
144+
ValidationErrorScope.Header
145+
);
146+
}
147+
148+
[Theory]
149+
[InlineData(null)]
150+
[InlineData("")]
151+
[InlineData("00000108")]
152+
public void Validate_TrailerExtractIdMismatch_ReturnsValidationError(string? value)
153+
{
154+
// Arrange
155+
var file = ValidParsedFile;
156+
file.FileTrailer!.ExtractId = value;
157+
158+
// Act
159+
var validationErrors = Validate(file).ToList();
160+
161+
// Assert
162+
validationErrors.ShouldContainValidationError(
163+
"Extract ID",
164+
"Extract ID does not match value in header",
165+
ErrorCodes.InconsistentExtractId,
166+
ValidationErrorScope.Trailer
167+
);
168+
}
169+
170+
[Theory]
171+
[InlineData(null)]
172+
[InlineData("")]
173+
[InlineData("000002")]
174+
[InlineData("000004")]
175+
public void Validate_TrailerRecordCountMismatch_ReturnsValidationError(string? value)
176+
{
177+
// Arrange
178+
var file = ValidParsedFile;
179+
file.FileTrailer!.RecordCount = value;
180+
181+
// Act
182+
var validationErrors = Validate(file).ToList();
183+
184+
// Assert
185+
validationErrors.ShouldContainValidationError(
186+
"Record count",
187+
"Record count does not match value in header",
188+
ErrorCodes.InconsistentRecordCount,
189+
ValidationErrorScope.Trailer
190+
);
191+
}
192+
193+
[Fact]
194+
public void Validate_UnexpectedRecordCount_ReturnsValidationError()
195+
{
196+
// Arrange
197+
var file = ValidParsedFile;
198+
file.DataRecords.RemoveAt(0);
199+
200+
// Act
201+
var validationErrors = Validate(file).ToList();
202+
203+
// Assert
204+
validationErrors.ShouldContainValidationError(
205+
null,
206+
"Record count does not match value in header and trailer",
207+
ErrorCodes.UnexpectedRecordCount,
208+
ValidationErrorScope.File
209+
);
210+
}
211+
}

0 commit comments

Comments
 (0)