diff --git a/docs/results-enquiry.md b/docs/results-enquiry.md index 15ffbc28d..33d82cca8 100644 --- a/docs/results-enquiry.md +++ b/docs/results-enquiry.md @@ -336,7 +336,11 @@ enquiry up. This method is the only place that queues an enquiry. How a school sees its own enquiries is designed: AB#298325 added an Issues tab beside Requests on the Amendment request summary page (`AmendmentRequestsService.GetAmendmentRequestsAsync`, `GetSubmittedResultsEnquiriesAsync`), listing submitted enquiries with pupil-name search, enriched from -each row's journey blob for CYPMD id and qualification text. +each row's journey blob for CYPMD id, qualification text and the **Grade achieved** value (#712). +Grade achieved is the saved record's grade, never the live results feed: the revised grade for an +incorrect-grade enquiry, the grade entered for a missing qualification, and the grade held for the +reported result for a result-does-not-belong enquiry. A record that cannot supply one shows an +empty cell. Two consequences, both commented in code: diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs index 53c423ff9..7b47acb32 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/AmendmentRequestsService.cs @@ -2,6 +2,7 @@ using DfE.CheckPerformanceData.Application.CurrentUser; using DfE.CheckPerformanceData.Application.Journey; using DfE.CheckPerformanceData.Application.RequestSubmission; +using DfE.CheckPerformanceData.Application.ResultsEnquiry; using DfE.CheckPerformanceData.Application.WindowManagement; using DfE.CheckPerformanceData.Domain.Enums; using Microsoft.Extensions.Logging; @@ -59,6 +60,7 @@ public async Task GetAmendmentRequestsAsync(Guid window QualificationText = state?.SelectedQualification?.QualificationTitle ?? state?.SelectedResult?.QualificationName ?? "", + GradeText = GradeAchievedFor(state), ReferenceNumber = enquiry.ReferenceNumber }); } @@ -79,10 +81,12 @@ public async Task GetAmendmentRequestsAsync(Guid window .Where(e => e.ExerciseType is not null) .Select(e => new ExerciseDeadlineDto { - Exercise = e.ExerciseType.Value, + // The Where above filters out the display-only exercises, so the kind is known + // here; the compiler cannot see across the LINQ chain, hence the suppression. + Exercise = e.ExerciseType!.Value, EndDate = e.EndDateFor(laestab), // The clock lives in one place, so "has this closed" is asked, never computed. - IsOpen = checkingExercises.IsOpen(window.Exercises, e.ExerciseType.Value, laestab) + IsOpen = checkingExercises.IsOpen(window.Exercises, e.ExerciseType!.Value, laestab) }) .ToList(), Rows = requests.Select(r => new AmendmentRequestDto @@ -107,6 +111,28 @@ public async Task GetAmendmentRequestsAsync(Guid window }; } + /// + /// #712: the tab's Grade achieved value. The saved journey record is the only source: the + /// revised grade the school chose, the grade it entered for the missing qualification, or the + /// grade the DfE holds for the reported result. Undecidable degrades to "" — the row is the + /// record of truth and the cell is enrichment. A present value is verbatim; only a missing, + /// null or whitespace-only value shows as an empty cell. + /// + private static string GradeAchievedFor(RequestState? state) + { + var value = state?.SelectedWhatToChange switch + { + WhatToChange.IncorrectGrade => AnswerText(state, EnquiryQuestionIds.RevisedGrade), + WhatToChange.MissingQualification => AnswerText(state, EnquiryQuestionIds.MissingGrade), + WhatToChange.ResultDoesNotBelong => state?.SelectedResult?.Grade, + _ => null + }; + return string.IsNullOrWhiteSpace(value) ? "" : value; + } + + private static string? AnswerText(RequestState? state, string questionId) => + state?.QuestionAnswers.GetValueOrDefault(questionId)?.TextValue; + /// "Results enquiry - Missing qualification" → "Missing qualification". The suffix IS /// the user-facing kind label; falling back to the whole string keeps an unexpected format /// visible rather than blank. diff --git a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/ResultsEnquiryIssueDto.cs b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/ResultsEnquiryIssueDto.cs index 28dab661c..c6e30d394 100644 --- a/src/DfE.CheckPerformanceData.Application/AmendmentRequests/ResultsEnquiryIssueDto.cs +++ b/src/DfE.CheckPerformanceData.Application/AmendmentRequests/ResultsEnquiryIssueDto.cs @@ -18,5 +18,10 @@ public sealed class ResultsEnquiryIssueDto public required string TypeLabel { get; init; } public required string QualificationText { get; init; } + + /// #712: the row's Grade achieved value — the revised grade, the entered grade or the + /// held grade, per the enquiry kind. "" when it cannot be determined, matching the sibling cells. + public required string GradeText { get; init; } + public required string ReferenceNumber { get; init; } } diff --git a/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/EnquiryQuestionIds.cs b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/EnquiryQuestionIds.cs new file mode 100644 index 000000000..6ba2aebeb --- /dev/null +++ b/src/DfE.CheckPerformanceData.Application/ResultsEnquiry/EnquiryQuestionIds.cs @@ -0,0 +1,13 @@ +namespace DfE.CheckPerformanceData.Application.ResultsEnquiry; + +/// +/// #712: the single owner of the two results-enquiry grade question ids. The ids are a +/// serialization contract between the journey flow files (IncorrectGrade_Post16.json, +/// MissingQualification_Post16.json) and the code that reads the saved answers — the flow tests +/// pin them against the JSON. The Web constants delegate here so one owner holds the contract. +/// +public static class EnquiryQuestionIds +{ + public const string RevisedGrade = "q-revised-grade"; + public const string MissingGrade = "q-missing-grade"; +} diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs index 19d3836db..61db611fd 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsController.cs @@ -68,7 +68,8 @@ private async Task BuildIndexViewModelAsync(Guid win Submitted = r.Submitted, CypmdId = r.CypmdId, TypeLabel = r.TypeLabel, - QualificationText = r.QualificationText + QualificationText = r.QualificationText, + GradeText = r.GradeText }).ToList(), ShowResultsEnquiries = result.HasResultsEnquiry }; diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs index efd6791eb..1a411b0ad 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/AmendmentRequests/AmendmentRequestsViewModel.cs @@ -127,6 +127,9 @@ public sealed class IssueRowViewModel public required string TypeLabel { get; init; } public required string QualificationText { get; init; } + /// #712: the row's Grade achieved value; "" when it cannot be determined. + public required string GradeText { get; init; } + // Same conversion and format as SubmittedRequestRowViewModel.SubmittedDateText: the stored // instant is UTC, display is London wall clock, GDS-style written-out date. public string DateText => LondonTime.ToLondon(Submitted).ToString("d MMMM yyyy"); diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs index 02186d9fd..2509e9ca7 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyController.cs @@ -42,12 +42,12 @@ public sealed class JourneyController( /// AB#296648: the revised-grade question id from IncorrectGrade_Post16.json. Named here /// because changing the selected result has to clear this one answer specifically. /// - internal const string RevisedGradeQuestionId = "q-revised-grade"; + internal const string RevisedGradeQuestionId = EnquiryQuestionIds.RevisedGrade; /// AB#297848: the missing-qualification flow's syllabus and grade question ids. Named /// here because choosing a different qualification has to clear these two answers specifically. internal const string SyllabusQuestionId = "q-syllabus-code"; - internal const string MissingGradeQuestionId = "q-missing-grade"; + internal const string MissingGradeQuestionId = EnquiryQuestionIds.MissingGrade; private const long MaxUploadBytes = 10 * 1024 * 1024; // 10 MB diff --git a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs index 1811921c4..67e8c85a2 100644 --- a/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs +++ b/src/DfE.CheckPerformanceData.Web/Controllers/Journey/JourneyViewModelBuilder.cs @@ -168,7 +168,7 @@ public SummaryViewModel BuildSummaryVm( ShowSyllabusCode = journey.SelectedQualification?.SyllabusCodes.Count > 0, AwardDate = awardDate, Ncn = Answer(NcnQuestionId), - GradeAchieved = Answer(MissingGradeQuestionId), + GradeAchieved = Answer(EnquiryQuestionIds.MissingGrade), AdditionalInformation = Answer(AdditionalInfoQuestionId), QualificationPageId = qualificationPageId, DetailsPageId = detailsPageId, @@ -181,11 +181,11 @@ public SummaryViewModel BuildSummaryVm( private const string CohortCountQuestionId = "q-cohort-count"; private const string AdditionalInfoQuestionId = "q-additional-info"; - // Question ids from MissingQualification_Post16.json. AB#297848. + // Question ids from MissingQualification_Post16.json. AB#297848. The missing-grade id is + // read directly from EnquiryQuestionIds, its single owner (#712). private const string SyllabusQuestionId = "q-syllabus-code"; private const string AwardDateQuestionId = "q-award-date"; private const string NcnQuestionId = "q-ncn"; - private const string MissingGradeQuestionId = "q-missing-grade"; /// How a window type is named to a school on the summary. private static string KeyStageLabelFor(Domain.Enums.CheckingWindowType? windowType) => windowType switch diff --git a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml index 96e3a07b0..017005961 100644 --- a/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml +++ b/src/DfE.CheckPerformanceData.Web/Views/AmendmentRequests/Index.cshtml @@ -255,6 +255,7 @@ CYPMD id Type Qualification + Grade achieved @@ -266,6 +267,7 @@ @issue.CypmdId @issue.TypeLabel @issue.QualificationText + @issue.GradeText } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsControllerTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsControllerTests.cs index 02f51fee1..790e1f041 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsControllerTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsControllerTests.cs @@ -350,6 +350,7 @@ public async Task Index_MapsIssueRowsOntoTheViewModel() CypmdId = "500001", TypeLabel = "Missing qualification", QualificationText = "ABRSM level 3", + GradeText = "A*", ReferenceNumber = "REF-1" } ], @@ -364,6 +365,7 @@ public async Task Index_MapsIssueRowsOntoTheViewModel() Assert.Equal("500001", row.CypmdId); Assert.Equal("Missing qualification", row.TypeLabel); Assert.Equal("ABRSM level 3", row.QualificationText); + Assert.Equal("A*", row.GradeText); } // GDS style writes dates out; 01/10/2026 would also collide with the US reading. Matches the @@ -374,7 +376,7 @@ public void IssueRowViewModel_FormatsTheDateGdsStyle() var row = new IssueRowViewModel { PupilName = "A", Submitted = new DateTime(2026, 10, 1, 9, 0, 0, DateTimeKind.Utc), - CypmdId = "", TypeLabel = "", QualificationText = "" + CypmdId = "", TypeLabel = "", QualificationText = "", GradeText = "" }; Assert.Equal("1 October 2026", row.DateText); } diff --git a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsIndexViewSourceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsIndexViewSourceTests.cs index 009e7e648..093b67cc3 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsIndexViewSourceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsIndexViewSourceTests.cs @@ -105,6 +105,22 @@ public void TheIssuesTableHasTheDesignedColumns() Assert.Contains(">CYPMD id", source); Assert.Contains(">Type", source); Assert.Contains(">Qualification", source); + Assert.Contains(">Grade achieved", source); + } + + // #712: the Grade achieved column is part of the Issues table — sliced like the row-actions + // pin, so the header and cell cannot be satisfied by a copy of the markup elsewhere in the + // panel. + [Fact] + public void TheGradeAchievedColumnSitsInsideTheIssuesTable() + { + var issuesPanel = ResultsEnquiriesPanel(); + var tableStart = issuesPanel.IndexOf("= 0, "The Issues table markup is missing."); + var tableEnd = issuesPanel.IndexOf("", tableStart, StringComparison.Ordinal) + "".Length; + var issuesTable = issuesPanel[tableStart..tableEnd]; + Assert.Contains(">Grade achieved", issuesTable); + Assert.Contains("@issue.GradeText", issuesTable); } // AC 4 (separation): the Issues TABLE renders enquiry fields only, with no per-row actions — diff --git a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsServiceTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsServiceTests.cs index 4aa57d8ea..862b0c49f 100644 --- a/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsServiceTests.cs +++ b/tests/DfE.CheckPerformanceData.UnitTests/AmendmentRequests/AmendmentRequestsServiceTests.cs @@ -250,13 +250,16 @@ public async Task GetAmendmentRequestsAsync_BuildsIssueRowsFromRowAndJourneyBlob .Returns([Enquiry("REF-1", "Alice", "Smith")]); _blobClient.GetAsync(WindowId, "REF-1").Returns(new RequestState { + // #712: the saved record carries the kind and the grade answer the tab shows. + SelectedWhatToChange = WhatToChange.MissingQualification, SelectedPupil = new PupilDto { Firstname = "Alice", Surname = "Smith", Id = Guid.NewGuid(), Sex = "F", DateOfBirth = "2008-01-01", Age = 18, Cypmd_Id = "500001", Identifier = "ULN500001" }, SelectedQualification = new QualificationReference - { Qan = "6037116X", QualificationTitle = "ABRSM level 3 certificate in practical music (Grade 8)" } + { Qan = "6037116X", QualificationTitle = "ABRSM level 3 certificate in practical music (Grade 8)" }, + QuestionAnswers = { [EnquiryQuestionIds.MissingGrade] = new QuestionAnswer { TextValue = "8" } } }); var result = await _sut.GetAmendmentRequestsAsync(WindowId); @@ -266,6 +269,7 @@ public async Task GetAmendmentRequestsAsync_BuildsIssueRowsFromRowAndJourneyBlob Assert.Equal("500001", row.CypmdId); Assert.Equal("Missing qualification", row.TypeLabel); Assert.Equal("ABRSM level 3 certificate in practical music (Grade 8)", row.QualificationText); + Assert.Equal("8", row.GradeText); } // Incorrect-grade and result-does-not-belong journeys store SelectedResult, not @@ -296,6 +300,164 @@ public async Task GetAmendmentRequestsAsync_IssueQualificationFallsBackToSelecte Assert.Equal("", row.CypmdId); } + // #712: an incorrect-grade row's Grade achieved is the revised grade the school chose, not the + // held grade of the result — asserting against "A*" rather than "5" is the pin. + [Fact] + public async Task GetAmendmentRequestsAsync_IssueGradeIsTheRevisedGradeForIncorrectGrade() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-5", "Dara", "Hughes", description: "Results enquiry - Incorrect grade")]); + _blobClient.GetAsync(WindowId, "REF-5").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.IncorrectGrade, + SelectedResult = new ResultRecord { QualificationName = "GCSE (9-1) Bus. Studs:Single", Grade = "5" }, + QuestionAnswers = { [EnquiryQuestionIds.RevisedGrade] = new QuestionAnswer { TextValue = "A*" } } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Incorrect grade", row.TypeLabel); + Assert.Equal("A*", row.GradeText); + } + + // #712: a result-does-not-belong row has no grade step, so Grade achieved is the grade the + // DfE holds for the reported result — never a school-submitted grade. + [Fact] + public async Task GetAmendmentRequestsAsync_IssueGradeIsTheHeldGradeForResultDoesNotBelong() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-6", "Ellie", "Nkemdirim", + description: "Results enquiry - Result does not belong to student")]); + _blobClient.GetAsync(WindowId, "REF-6").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.ResultDoesNotBelong, + SelectedResult = new ResultRecord { QualificationName = "GCSE (9-1) Bus. Studs:Single", Grade = "4" } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Result does not belong to student", row.TypeLabel); + Assert.Equal("4", row.GradeText); + } + + // #712: the record exists but cannot supply the grade — the cell degrades to empty and the + // row's other cells keep their values. A stored value is otherwise shown verbatim. + [Fact] + public async Task GetAmendmentRequestsAsync_MissingGradeAnswerLeavesTheGradeCellEmpty() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-7", "Freya", "Osei", description: "Results enquiry - Incorrect grade")]); + _blobClient.GetAsync(WindowId, "REF-7").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.IncorrectGrade, + SelectedResult = new ResultRecord { QualificationName = "GCSE (9-1) Bus. Studs:Single", Grade = "5" } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Freya Osei", row.PupilName); + Assert.Equal("GCSE (9-1) Bus. Studs:Single", row.QualificationText); + Assert.Equal("", row.GradeText); + } + + // A whitespace-only stored value is "blank" by the data-model invariant: it displays as an + // empty cell, and the stored string is never otherwise transformed. + [Fact] + public async Task GetAmendmentRequestsAsync_BlankGradeAnswerLeavesTheGradeCellEmpty() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-8", "Gita", "Patel", description: "Results enquiry - Incorrect grade")]); + _blobClient.GetAsync(WindowId, "REF-8").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.IncorrectGrade, + SelectedResult = new ResultRecord { QualificationName = "GCSE (9-1) Bus. Studs:Single", Grade = "5" }, + QuestionAnswers = { [EnquiryQuestionIds.RevisedGrade] = new QuestionAnswer { TextValue = " " } } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Gita Patel", row.PupilName); + Assert.Equal("GCSE (9-1) Bus. Studs:Single", row.QualificationText); + Assert.Equal("", row.GradeText); + } + + [Fact] + public async Task GetAmendmentRequestsAsync_MissingResultLeavesTheGradeCellEmpty() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-9", "Hana", "Quinn", + description: "Results enquiry - Result does not belong to student")]); + _blobClient.GetAsync(WindowId, "REF-9").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.ResultDoesNotBelong + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Hana Quinn", row.PupilName); + Assert.Equal("", row.QualificationText); + Assert.Equal("", row.GradeText); + } + + // #712: a saved record with a result but no recorded kind cannot be mapped to a grade + // source: the cell stays empty even though the result holds a grade. The kind, not the row's + // label, is the lookup. + [Fact] + public async Task GetAmendmentRequestsAsync_ARecordWithNoKindLeavesTheGradeCellEmpty() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-10", "Ivo", "Kovac", description: "Results enquiry - Incorrect grade")]); + _blobClient.GetAsync(WindowId, "REF-10").Returns(new RequestState + { + SelectedResult = new ResultRecord { QualificationName = "GCSE (9-1) Bus. Studs:Single", Grade = "5" } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("GCSE (9-1) Bus. Studs:Single", row.QualificationText); + Assert.Equal("", row.GradeText); + } + + // The blank guard applies to the missing-qualification answer too: an empty stored value + // displays as an empty cell. + [Fact] + public async Task GetAmendmentRequestsAsync_EmptyMissingGradeAnswerLeavesTheGradeCellEmpty() + { + _windowService.GetCheckingWindowAsync(WindowId).Returns(Window(DateTime.UtcNow)); + _requestRepo.GetAmendmentRequestsAsync(WindowId, 100001L).Returns([]); + _requestRepo.GetSubmittedResultsEnquiriesAsync(WindowId, 100001L) + .Returns([Enquiry("REF-11", "Jana", "Larsson")]); + _blobClient.GetAsync(WindowId, "REF-11").Returns(new RequestState + { + SelectedWhatToChange = WhatToChange.MissingQualification, + QuestionAnswers = { [EnquiryQuestionIds.MissingGrade] = new QuestionAnswer { TextValue = "" } } + }); + + var result = await _sut.GetAmendmentRequestsAsync(WindowId); + + var row = Assert.Single(result.IssueRows); + Assert.Equal("Missing qualification", row.TypeLabel); + Assert.Equal("", row.GradeText); + } + // A missing or unreadable blob must degrade to empty cells, never fail the whole page — the // row is the record of truth and the school must still see that the enquiry exists. [Fact] @@ -313,6 +475,7 @@ public async Task GetAmendmentRequestsAsync_MissingJourneyBlobLeavesEnrichmentCe Assert.Equal("Billy Brown", row.PupilName); Assert.Equal("", row.CypmdId); Assert.Equal("", row.QualificationText); + Assert.Equal("", row.GradeText); } // A blob read that throws (corrupt document, storage outage) must degrade the same way a @@ -333,6 +496,7 @@ public async Task GetAmendmentRequestsAsync_BlobReadThatThrowsLeavesEnrichmentCe Assert.Equal("Cara Davies", row.PupilName); Assert.Equal("", row.CypmdId); Assert.Equal("", row.QualificationText); + Assert.Equal("", row.GradeText); // The negative half is the real pin: adding {PupilName} "for debuggability" would land // PII in the Serilog sinks while a contains-reference-only assertion stayed green. _logger.Received(1).Log( @@ -345,12 +509,15 @@ public async Task GetAmendmentRequestsAsync_BlobReadThatThrowsLeavesEnrichmentCe Arg.Any>()); } - private static SubmittedRequestData Enquiry(string reference, string first, string last, DateTime? submitted = null) => new() + private static SubmittedRequestData Enquiry( + string reference, string first, string last, + string description = "Results enquiry - Missing qualification", + DateTime? submitted = null) => new() { PupilFirstname = first, PupilSurname = last, RequestType = RequestType.ResultsEnquiry, - RequestTypeDescription = "Results enquiry - Missing qualification", + RequestTypeDescription = description, ReferenceNumber = reference, Status = RequestStatus.Submitted, Submitted = submitted ?? DateTime.UtcNow diff --git a/tests/DfE.CheckPerformanceData.UnitTests/ResultsEnquiry/EnquiryQuestionIdsFlowAlignmentTests.cs b/tests/DfE.CheckPerformanceData.UnitTests/ResultsEnquiry/EnquiryQuestionIdsFlowAlignmentTests.cs new file mode 100644 index 000000000..29a8a7e5e --- /dev/null +++ b/tests/DfE.CheckPerformanceData.UnitTests/ResultsEnquiry/EnquiryQuestionIdsFlowAlignmentTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DfE.CheckPerformanceData.Application.Journey; +using DfE.CheckPerformanceData.Application.ResultsEnquiry; + +namespace DfE.CheckPerformanceData.Application.UnitTests.ResultsEnquiry; + +/// +/// #712: the two grade question ids are owned by and are read +/// from the saved journey record by the Results Enquiries tab. These pin the constants to the +/// shipped flow files: a rename of a constant must fail here before the tab can start showing +/// empty grades. +/// +public sealed class EnquiryQuestionIdsFlowAlignmentTests +{ + // Mirrors FileSystemQuestionFlowClient's deserialization options. + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + private static QuestionFlowConfig Load(string fileName) + => JsonSerializer.Deserialize(File.ReadAllText(LocateFlowFile(fileName)), JsonOptions)!; + + private static IEnumerable QuestionIds(QuestionFlowConfig flow) => + flow.Pages.SelectMany(p => p.Questions).Select(q => q.Id); + + [Fact] + public void The_revised_grade_id_is_a_question_in_the_incorrect_grade_flow() + { + var ids = QuestionIds(Load("IncorrectGrade_Post16.json")); + Assert.Contains(EnquiryQuestionIds.RevisedGrade, ids); + } + + [Fact] + public void The_missing_grade_id_is_a_question_in_the_missing_qualification_flow() + { + var ids = QuestionIds(Load("MissingQualification_Post16.json")); + Assert.Contains(EnquiryQuestionIds.MissingGrade, ids); + } + + private static string LocateFlowFile(string fileName) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine( + dir.FullName, + "src", "DfE.CheckPerformanceData.Web", "Data", "QuestionFlows", fileName); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + throw new FileNotFoundException("Could not locate " + fileName + " from " + AppContext.BaseDirectory); + } +}