Password must be at least 6 characters.
+ Password must be at least 12 characters with uppercase, lowercase, number, and symbol.
@@ -153,7 +153,7 @@
public string Email { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
- [System.ComponentModel.DataAnnotations.MinLength(6, ErrorMessage = "Password must be at least 6 characters")]
+ [System.ComponentModel.DataAnnotations.MinLength(12, ErrorMessage = "Password must be at least 12 characters")]
public string Password { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
diff --git a/src/TrueCV.Web/Components/Pages/Check.razor b/src/TrueCV.Web/Components/Pages/Check.razor
index 3c5abcf..a947bb8 100644
--- a/src/TrueCV.Web/Components/Pages/Check.razor
+++ b/src/TrueCV.Web/Components/Pages/Check.razor
@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject ILogger
Logger
Upload CV - TrueCV
@@ -145,6 +146,10 @@
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
+ // Magic bytes for file type validation
+ private static readonly byte[] PdfMagicBytes = [0x25, 0x50, 0x44, 0x46]; // %PDF
+ private static readonly byte[] DocxMagicBytes = [0x50, 0x4B, 0x03, 0x04]; // PK.. (ZIP signature)
+
private void HandleDragEnter()
{
_isDragging = true;
@@ -186,10 +191,15 @@
_errorMessage = null;
}
+ private CancellationTokenSource? _progressCts;
+
private async Task UploadFile()
{
if (_selectedFile is null) return;
+ _progressCts = new CancellationTokenSource();
+ Task? progressTask = null;
+
try
{
_isUploading = true;
@@ -207,35 +217,81 @@
}
// Simulate progress for better UX
- var progressTask = SimulateProgress();
+ progressTask = SimulateProgress(_progressCts.Token);
await using var stream = _selectedFile.OpenReadStream(MaxFileSize);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
+ // Validate file content (magic bytes)
+ if (!await ValidateFileContentAsync(memoryStream, _selectedFile.Name))
+ {
+ _errorMessage = "Invalid file content. The file appears to be corrupted or not a valid PDF/DOCX.";
+ return;
+ }
+
var checkId = await CVCheckService.CreateCheckAsync(userId, memoryStream, _selectedFile.Name);
_uploadProgress = 100;
+ await InvokeAsync(StateHasChanged);
await Task.Delay(500); // Brief pause to show completion
NavigationManager.NavigateTo($"/report/{checkId}");
}
catch (Exception ex)
{
- _errorMessage = $"An error occurred while uploading: {ex.Message}";
+ Logger.LogError(ex, "Error uploading CV");
+ _errorMessage = "An error occurred while uploading. Please try again.";
+ }
+ finally
+ {
_isUploading = false;
+ _progressCts?.Cancel();
+ if (progressTask is not null)
+ {
+ try { await progressTask; } catch (OperationCanceledException) { }
+ }
+ _progressCts?.Dispose();
+ _progressCts = null;
}
}
- private async Task SimulateProgress()
+ private async Task SimulateProgress(CancellationToken cancellationToken)
{
- while (_uploadProgress < 90 && _isUploading)
+ try
{
- await Task.Delay(200);
- _uploadProgress += 10;
- StateHasChanged();
+ while (_uploadProgress < 90 && _isUploading && !cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(200, cancellationToken);
+ _uploadProgress += 10;
+ await InvokeAsync(StateHasChanged);
+ }
}
+ catch (OperationCanceledException)
+ {
+ // Expected when upload completes
+ }
+ }
+
+ private async Task ValidateFileContentAsync(MemoryStream stream, string fileName)
+ {
+ var extension = Path.GetExtension(fileName).ToLowerInvariant();
+ var header = new byte[4];
+
+ stream.Position = 0;
+ var bytesRead = await stream.ReadAsync(header.AsMemory(0, 4));
+ stream.Position = 0;
+
+ if (bytesRead < 4)
+ return false;
+
+ return extension switch
+ {
+ ".pdf" => header.AsSpan().StartsWith(PdfMagicBytes),
+ ".docx" => header.AsSpan().StartsWith(DocxMagicBytes),
+ _ => false
+ };
}
private bool IsValidFileType(string fileName)
diff --git a/src/TrueCV.Web/Components/Pages/Dashboard.razor b/src/TrueCV.Web/Components/Pages/Dashboard.razor
index 571c84e..0568085 100644
--- a/src/TrueCV.Web/Components/Pages/Dashboard.razor
+++ b/src/TrueCV.Web/Components/Pages/Dashboard.razor
@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject ILogger Logger
Dashboard - TrueCV
@@ -255,7 +256,8 @@
}
catch (Exception ex)
{
- _errorMessage = $"An error occurred while loading checks: {ex.Message}";
+ Logger.LogError(ex, "Error loading CV checks");
+ _errorMessage = "An error occurred while loading checks. Please try again.";
}
finally
{
diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor
index 0d34a3c..019c3ba 100644
--- a/src/TrueCV.Web/Components/Pages/Report.razor
+++ b/src/TrueCV.Web/Components/Pages/Report.razor
@@ -5,6 +5,7 @@
@inject ICVCheckService CVCheckService
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
+@inject ILogger Logger
Verification Report - TrueCV
@@ -509,7 +510,8 @@
}
catch (Exception ex)
{
- _errorMessage = $"An error occurred: {ex.Message}";
+ Logger.LogError(ex, "Error loading report data");
+ _errorMessage = "An error occurred while loading the report. Please try again.";
}
finally
{
diff --git a/src/TrueCV.Web/Components/Shared/CVUploader.razor b/src/TrueCV.Web/Components/Shared/CVUploader.razor
deleted file mode 100644
index 78abedf..0000000
--- a/src/TrueCV.Web/Components/Shared/CVUploader.razor
+++ /dev/null
@@ -1,190 +0,0 @@
-@using Microsoft.AspNetCore.Components.Forms
-
-
-
-
-
-
-
- @if (!string.IsNullOrEmpty(_errorMessage))
- {
-
- }
-
-
-
-
-@code {
- private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
- private static readonly string[] AllowedExtensions = [".pdf", ".docx"];
-
- private bool _isDragOver;
- private string? _selectedFileName;
- private string? _errorMessage;
-
- [Parameter]
- public EventCallback OnFileSelected { get; set; }
-
- private void HandleDragEnter()
- {
- _isDragOver = true;
- }
-
- private void HandleDragLeave()
- {
- _isDragOver = false;
- }
-
- private void HandleDrop()
- {
- _isDragOver = false;
- }
-
- private async Task HandleFileSelected(InputFileChangeEventArgs e)
- {
- _errorMessage = null;
- _selectedFileName = null;
-
- var file = e.File;
- if (file is null)
- {
- return;
- }
-
- var extension = Path.GetExtension(file.Name).ToLowerInvariant();
- if (!AllowedExtensions.Contains(extension))
- {
- _errorMessage = "Invalid file type. Please upload a .pdf or .docx file.";
- return;
- }
-
- if (file.Size > MaxFileSizeBytes)
- {
- _errorMessage = "File size exceeds 10MB limit. Please upload a smaller file.";
- return;
- }
-
- _selectedFileName = file.Name;
- await OnFileSelected.InvokeAsync(file);
- }
-}
diff --git a/src/TrueCV.Web/Components/_Imports.razor b/src/TrueCV.Web/Components/_Imports.razor
index 77d0176..fe16548 100644
--- a/src/TrueCV.Web/Components/_Imports.razor
+++ b/src/TrueCV.Web/Components/_Imports.razor
@@ -8,6 +8,7 @@
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.Extensions.Logging
@using Microsoft.JSInterop
@using TrueCV.Web
@using TrueCV.Web.Components
diff --git a/src/TrueCV.Web/Program.cs b/src/TrueCV.Web/Program.cs
index 2dfe1bd..4a4a906 100644
--- a/src/TrueCV.Web/Program.cs
+++ b/src/TrueCV.Web/Program.cs
@@ -32,15 +32,19 @@ try
// Add Infrastructure services (DbContext, Hangfire, HttpClients, Services)
builder.Services.AddInfrastructure(builder.Configuration);
- // Add Identity
+ // Add Identity with secure password requirements
builder.Services.AddIdentity>(options =>
{
- options.Password.RequireDigit = false;
- options.Password.RequireLowercase = false;
- options.Password.RequireUppercase = false;
- options.Password.RequireNonAlphanumeric = false;
- options.Password.RequiredLength = 6;
+ options.Password.RequireDigit = true;
+ options.Password.RequireLowercase = true;
+ options.Password.RequireUppercase = true;
+ options.Password.RequireNonAlphanumeric = true;
+ options.Password.RequiredLength = 12;
+ options.Password.RequiredUniqueChars = 4;
options.SignIn.RequireConfirmedAccount = false;
+ options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
+ options.Lockout.MaxFailedAccessAttempts = 5;
+ options.Lockout.AllowedForNewUsers = true;
})
.AddEntityFrameworkStores()
.AddDefaultTokenProviders();
@@ -62,6 +66,26 @@ try
var app = builder.Build();
+ // Seed default admin user
+ using (var scope = app.Services.CreateScope())
+ {
+ var userManager = scope.ServiceProvider.GetRequiredService>();
+ var defaultEmail = "admin@truecv.local";
+ var defaultPassword = "TrueCV_Admin123!";
+
+ if (await userManager.FindByEmailAsync(defaultEmail) == null)
+ {
+ var adminUser = new ApplicationUser
+ {
+ UserName = defaultEmail,
+ Email = defaultEmail,
+ EmailConfirmed = true
+ };
+ await userManager.CreateAsync(adminUser, defaultPassword);
+ Log.Information("Created default admin user: {Email}", defaultEmail);
+ }
+ }
+
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -98,6 +122,44 @@ try
});
}
+ // Login endpoint
+ app.MapPost("/account/perform-login", async (
+ HttpContext context,
+ SignInManager signInManager) =>
+ {
+ var form = await context.Request.ReadFormAsync();
+ var email = form["email"].ToString();
+ var password = form["password"].ToString();
+ var rememberMe = form["rememberMe"].ToString() == "true";
+ var returnUrl = form["returnUrl"].ToString();
+
+ Log.Information("Login attempt for {Email}", email);
+
+ // Validate returnUrl is local to prevent open redirect attacks
+ if (string.IsNullOrEmpty(returnUrl) || !Uri.IsWellFormedUriString(returnUrl, UriKind.Relative) || returnUrl.StartsWith("//"))
+ {
+ returnUrl = "/dashboard";
+ }
+
+ var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
+
+ if (result.Succeeded)
+ {
+ Log.Information("User {Email} logged in successfully", email);
+ return Results.LocalRedirect(returnUrl);
+ }
+ else if (result.IsLockedOut)
+ {
+ Log.Warning("User {Email} account is locked out", email);
+ return Results.Redirect("/account/login?error=Account+locked.+Try+again+later.");
+ }
+ else
+ {
+ Log.Warning("Failed login attempt for {Email}", email);
+ return Results.Redirect("/account/login?error=Invalid+email+or+password.");
+ }
+ });
+
// Logout endpoint
app.MapPost("/account/logout", async (SignInManager signInManager) =>
{
diff --git a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
index 5f9150e..a1f53cc 100644
--- a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
+++ b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs
@@ -18,6 +18,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
private readonly Mock _fileStorageServiceMock;
private readonly Mock _cvParserServiceMock;
private readonly Mock _companyVerifierServiceMock;
+ private readonly Mock _educationVerifierServiceMock;
private readonly Mock _timelineAnalyserServiceMock;
private readonly Mock> _loggerMock;
private readonly ProcessCVCheckJob _sut;
@@ -37,6 +38,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_fileStorageServiceMock = new Mock();
_cvParserServiceMock = new Mock();
_companyVerifierServiceMock = new Mock();
+ _educationVerifierServiceMock = new Mock();
_timelineAnalyserServiceMock = new Mock();
_loggerMock = new Mock>();
@@ -45,6 +47,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_fileStorageServiceMock.Object,
_cvParserServiceMock.Object,
_companyVerifierServiceMock.Object,
+ _educationVerifierServiceMock.Object,
_timelineAnalyserServiceMock.Object,
_loggerMock.Object);
}
@@ -159,7 +162,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
// Assert
_cvParserServiceMock.Verify(
- x => x.ParseAsync(It.IsAny(), "resume.pdf"),
+ x => x.ParseAsync(It.IsAny(), "resume.pdf", It.IsAny()),
Times.Once);
_dbContext.ChangeTracker.Clear();
@@ -843,7 +846,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
x => x.DownloadAsync(It.IsAny()),
Times.Never);
_cvParserServiceMock.Verify(
- x => x.ParseAsync(It.IsAny(), It.IsAny()),
+ x => x.ParseAsync(It.IsAny(), It.IsAny(), It.IsAny()),
Times.Never);
}
@@ -1007,6 +1010,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
private void SetupDefaultMocks(
CVData? cvData = null,
List? verificationResults = null,
+ List? educationResults = null,
TimelineAnalysisResult? timelineResult = null)
{
cvData ??= CreateTestCVData();
@@ -1017,7 +1021,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
.ReturnsAsync(new MemoryStream());
_cvParserServiceMock
- .Setup(x => x.ParseAsync(It.IsAny(), It.IsAny()))
+ .Setup(x => x.ParseAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(cvData);
if (verificationResults != null)
@@ -1040,6 +1044,12 @@ public sealed class ProcessCVCheckJobTests : IDisposable
.ReturnsAsync(CreateDefaultVerificationResult());
}
+ _educationVerifierServiceMock
+ .Setup(x => x.VerifyAll(
+ It.IsAny>(),
+ It.IsAny?>()))
+ .Returns(educationResults ?? []);
+
_timelineAnalyserServiceMock
.Setup(x => x.Analyse(It.IsAny>()))
.Returns(timelineResult);
diff --git a/tests/TrueCV.Tests/Services/EducationVerifierServiceTests.cs b/tests/TrueCV.Tests/Services/EducationVerifierServiceTests.cs
new file mode 100644
index 0000000..56f6bbf
--- /dev/null
+++ b/tests/TrueCV.Tests/Services/EducationVerifierServiceTests.cs
@@ -0,0 +1,418 @@
+using FluentAssertions;
+using TrueCV.Application.Models;
+using TrueCV.Infrastructure.Services;
+
+namespace TrueCV.Tests.Services;
+
+public sealed class EducationVerifierServiceTests
+{
+ private readonly EducationVerifierService _sut = new();
+
+ #region Diploma Mill Detection
+
+ [Theory]
+ [InlineData("Belford University")]
+ [InlineData("Ashwood University")]
+ [InlineData("Rochville University")]
+ [InlineData("St Regis University")]
+ public void Verify_DiplomaMillInstitution_ReturnsDiplomaMill(string institution)
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = institution,
+ Qualification = "PhD",
+ Subject = "Business",
+ StartDate = new DateOnly(2020, 1, 1),
+ EndDate = new DateOnly(2020, 6, 1)
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.Status.Should().Be("DiplomaMill");
+ result.IsDiplomaMill.Should().BeTrue();
+ result.IsSuspicious.Should().BeTrue();
+ result.IsVerified.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Verify_DiplomaMillInstitution_IncludesVerificationNotes()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "Belford University",
+ Qualification = "MBA"
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.VerificationNotes.Should().Contain("diploma mill blacklist");
+ }
+
+ #endregion
+
+ #region Suspicious Pattern Detection
+
+ [Theory]
+ [InlineData("Global Online University")]
+ [InlineData("Premier University of Excellence")]
+ [InlineData("Executive Virtual University")]
+ public void Verify_SuspiciousPatternInstitution_ReturnsSuspicious(string institution)
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = institution
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.Status.Should().Be("Suspicious");
+ result.IsSuspicious.Should().BeTrue();
+ result.IsDiplomaMill.Should().BeFalse();
+ result.IsVerified.Should().BeFalse();
+ }
+
+ #endregion
+
+ #region UK Institution Recognition
+
+ [Theory]
+ [InlineData("University of Cambridge", "University of Cambridge")]
+ [InlineData("Cambridge", "University of Cambridge")]
+ [InlineData("University of Oxford", "University of Oxford")]
+ [InlineData("Oxford", "University of Oxford")]
+ [InlineData("Imperial College London", "Imperial College London")]
+ [InlineData("UCL", "UCL")] // UCL is directly in the recognised list
+ [InlineData("LSE", "LSE")] // LSE is directly in the recognised list
+ public void Verify_RecognisedUKInstitution_ReturnsRecognised(string input, string expectedMatch)
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = input,
+ Qualification = "BSc",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.Status.Should().Be("Recognised");
+ result.IsVerified.Should().BeTrue();
+ result.IsDiplomaMill.Should().BeFalse();
+ result.IsSuspicious.Should().BeFalse();
+ result.MatchedInstitution.Should().Be(expectedMatch);
+ }
+
+ [Fact]
+ public void Verify_RecognisedInstitution_IncludesVerificationNotes()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Manchester"
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.VerificationNotes.Should().Contain("Verified UK higher education institution");
+ }
+
+ [Fact]
+ public void Verify_RecognisedInstitutionVariation_NotesMatchedName()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "Cambridge"
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.VerificationNotes.Should().Contain("Matched to official name");
+ result.MatchedInstitution.Should().Be("University of Cambridge");
+ }
+
+ #endregion
+
+ #region Unknown Institutions
+
+ [Fact]
+ public void Verify_UnknownInstitution_ReturnsUnknown()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Ljubljana",
+ Qualification = "BSc"
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.Status.Should().Be("Unknown");
+ result.IsVerified.Should().BeFalse();
+ result.IsDiplomaMill.Should().BeFalse();
+ result.IsSuspicious.Should().BeFalse();
+ result.VerificationNotes.Should().Contain("international institution");
+ }
+
+ #endregion
+
+ #region Date Plausibility
+
+ [Fact]
+ public void Verify_PlausibleDates_ReturnsPlausible()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.DatesArePlausible.Should().BeTrue();
+ result.DatePlausibilityNotes.Should().BeNull();
+ }
+
+ [Fact]
+ public void Verify_TooShortCourseDuration_ReturnsImplausible()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2020, 1, 1),
+ EndDate = new DateOnly(2020, 6, 1) // 6 months
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.DatesArePlausible.Should().BeFalse();
+ result.DatePlausibilityNotes.Should().Contain("unusually short");
+ }
+
+ [Fact]
+ public void Verify_TooLongCourseDuration_ReturnsImplausible()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2010, 1, 1),
+ EndDate = new DateOnly(2020, 1, 1) // 10 years
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.DatesArePlausible.Should().BeFalse();
+ result.DatePlausibilityNotes.Should().Contain("unusually long");
+ }
+
+ [Fact]
+ public void Verify_EndDateBeforeStartDate_ReturnsImplausible()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2021, 1, 1),
+ EndDate = new DateOnly(2020, 1, 1)
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.DatesArePlausible.Should().BeFalse();
+ result.DatePlausibilityNotes.Should().Contain("before or equal to start date");
+ }
+
+ [Fact]
+ public void Verify_NoDates_AssumesPlausible()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol"
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.DatesArePlausible.Should().BeTrue();
+ }
+
+ #endregion
+
+ #region VerifyAll
+
+ [Fact]
+ public void VerifyAll_MultipleEducations_ReturnsResultsForEach()
+ {
+ // Arrange
+ var educations = new List
+ {
+ new() { Institution = "University of Cambridge" },
+ new() { Institution = "Belford University" },
+ new() { Institution = "Unknown Foreign University" }
+ };
+
+ // Act
+ var results = _sut.VerifyAll(educations);
+
+ // Assert
+ results.Should().HaveCount(3);
+ results[0].Status.Should().Be("Recognised");
+ results[1].Status.Should().Be("DiplomaMill");
+ results[2].Status.Should().Be("Unknown");
+ }
+
+ [Fact]
+ public void VerifyAll_OverlappingEducation_NotesOverlap()
+ {
+ // Arrange
+ var educations = new List
+ {
+ new()
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ },
+ new()
+ {
+ Institution = "University of Bath",
+ StartDate = new DateOnly(2020, 9, 1),
+ EndDate = new DateOnly(2023, 6, 1)
+ }
+ };
+
+ // Act
+ var results = _sut.VerifyAll(educations);
+
+ // Assert
+ results[0].DatePlausibilityNotes.Should().Contain("Overlaps with");
+ results[1].DatePlausibilityNotes.Should().Contain("Overlaps with");
+ }
+
+ [Fact]
+ public void VerifyAll_EmploymentBeforeGraduation_ChecksTimeline()
+ {
+ // Arrange
+ var educations = new List
+ {
+ new()
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ }
+ };
+
+ var employment = new List
+ {
+ new()
+ {
+ CompanyName = "Tech Corp",
+ JobTitle = "Senior Developer",
+ StartDate = new DateOnly(2018, 1, 1) // Started before education started
+ }
+ };
+
+ // Act
+ var results = _sut.VerifyAll(educations, employment);
+
+ // Assert
+ results[0].DatesArePlausible.Should().BeFalse();
+ results[0].DatePlausibilityNotes.Should().Contain("months before claimed graduation");
+ }
+
+ [Fact]
+ public void VerifyAll_InternshipBeforeGraduation_AllowsTimeline()
+ {
+ // Arrange
+ var educations = new List
+ {
+ new()
+ {
+ Institution = "University of Bristol",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ }
+ };
+
+ var employment = new List
+ {
+ new()
+ {
+ CompanyName = "Tech Corp",
+ JobTitle = "Software Intern",
+ StartDate = new DateOnly(2019, 6, 1)
+ }
+ };
+
+ // Act
+ var results = _sut.VerifyAll(educations, employment);
+
+ // Assert
+ // Should be plausible because it's an internship
+ results[0].DatesArePlausible.Should().BeTrue();
+ }
+
+ #endregion
+
+ #region Data Preservation
+
+ [Fact]
+ public void Verify_PreservesAllClaimedData()
+ {
+ // Arrange
+ var education = new EducationEntry
+ {
+ Institution = "University of Bristol",
+ Qualification = "BSc Computer Science",
+ Subject = "Computer Science",
+ Grade = "First Class Honours",
+ StartDate = new DateOnly(2018, 9, 1),
+ EndDate = new DateOnly(2021, 6, 1)
+ };
+
+ // Act
+ var result = _sut.Verify(education);
+
+ // Assert
+ result.ClaimedInstitution.Should().Be("University of Bristol");
+ result.ClaimedQualification.Should().Be("BSc Computer Science");
+ result.ClaimedSubject.Should().Be("Computer Science");
+ result.ClaimedStartDate.Should().Be(new DateOnly(2018, 9, 1));
+ result.ClaimedEndDate.Should().Be(new DateOnly(2021, 6, 1));
+ }
+
+ #endregion
+}