Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
"@hmcts-cft/simple-router": "workspace:*",
"@hmcts/admin-pages": "workspace:*",
"@hmcts/administrative-court-daily-cause-list": "workspace:*",
"@hmcts/ast-daily-hearing-list": "workspace:*",
"@hmcts/auth": "workspace:*",
"@hmcts/care-standards-tribunal-weekly-hearing-list": "workspace:*",
"@hmcts/cic-weekly-hearing-list": "workspace:*",
"@hmcts/civil-and-family-daily-cause-list": "workspace:*",
"@hmcts/civil-daily-cause-list": "workspace:*",
"@hmcts/cookie-manager": "1.1.0",
Expand All @@ -36,6 +38,7 @@
"@hmcts/london-administrative-court-daily-cause-list": "workspace:*",
"@hmcts/public-pages": "workspace:*",
"@hmcts/rcj-standard-daily-cause-list": "workspace:*",
"@hmcts/send-daily-hearing-list": "workspace:*",
"@hmcts/sjp-press-list": "workspace:*",
"@hmcts/sjp-public-list": "workspace:*",
"@hmcts/subscriptions": "workspace:*",
Expand Down
67 changes: 57 additions & 10 deletions apps/web/src/pages/(admin)/manual-upload-summary/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ vi.mock("@hmcts/admin-pages", async () => {
};
});

vi.mock("@hmcts/publication", async () => {
const actual = await vi.importActual("@hmcts/publication");
return {
...actual,
createArtefact: vi.fn(),
processPublication: vi.fn(),
updateArtefactFileExtension: vi.fn()
};
});
vi.mock("@hmcts/publication", () => ({
createArtefact: vi.fn(),
processPublication: vi.fn(),
updateArtefactFileExtension: vi.fn(),
extractAndStoreArtefactSearch: vi.fn(),
Provenance: { MANUAL_UPLOAD: "MANUAL_UPLOAD" },
Sensitivity: { PUBLIC: "PUBLIC", PRIVATE: "PRIVATE", CLASSIFIED: "CLASSIFIED" },
Language: { ENGLISH: "ENGLISH", WELSH: "WELSH", BILINGUAL: "BILINGUAL" }
}));

vi.mock("@hmcts/notifications", () => ({
sendLocationAndCaseSubscriptionNotifications: vi.fn(),
Expand All @@ -106,7 +106,7 @@ vi.mock("@hmcts/postgres-prisma", () => ({
import { getManualUpload, saveUploadedFile } from "@hmcts/admin-pages";
import { getLocationById } from "@hmcts/location";
import { sendListTypePublicationNotifications } from "@hmcts/notifications";
import { createArtefact, processPublication } from "@hmcts/publication";
import { createArtefact, extractAndStoreArtefactSearch, processPublication } from "@hmcts/publication";

describe("manual-upload-summary page", () => {
beforeEach(() => {
Expand Down Expand Up @@ -893,6 +893,53 @@ describe("manual-upload-summary page", () => {
);
});

it("should call extractAndStoreArtefactSearch when a JSON file is uploaded", async () => {
const jsonContent = JSON.stringify({ data: "test" });
const jsonUploadData = {
...mockUploadData,
fileName: "test-hearing-list.json",
file: Buffer.from(jsonContent)
};

vi.mocked(getManualUpload).mockResolvedValue(jsonUploadData);
vi.mocked(saveUploadedFile).mockResolvedValue(".json");
vi.mocked(processPublication).mockResolvedValue({});
vi.mocked(createArtefact).mockResolvedValue({ artefactId: "test-artefact-id-123", isUpdate: false });
vi.mocked(extractAndStoreArtefactSearch).mockResolvedValue(undefined);

const session = { save: vi.fn((callback) => callback()) };
const req = { query: { uploadId: "test-upload-id" }, session } as unknown as Request;
const res = { redirect: vi.fn(), render: vi.fn() } as unknown as Response;

await callHandler(POST, req, res);

expect(extractAndStoreArtefactSearch).toHaveBeenCalledWith("test-artefact-id-123", 6, { data: "test" });
expect(res.redirect).toHaveBeenCalledWith("/manual-upload-success");
});

it("should continue upload when extractAndStoreArtefactSearch throws", async () => {
const jsonContent = JSON.stringify({ data: "test" });
const jsonUploadData = {
...mockUploadData,
fileName: "test-hearing-list.json",
file: Buffer.from(jsonContent)
};

vi.mocked(getManualUpload).mockResolvedValue(jsonUploadData);
vi.mocked(saveUploadedFile).mockResolvedValue(".json");
vi.mocked(processPublication).mockResolvedValue({});
vi.mocked(createArtefact).mockResolvedValue({ artefactId: "test-artefact-id-123", isUpdate: false });
vi.mocked(extractAndStoreArtefactSearch).mockRejectedValue(new Error("Search extraction failed"));

const session = { save: vi.fn((callback) => callback()) };
const req = { query: { uploadId: "test-upload-id" }, session } as unknown as Request;
const res = { redirect: vi.fn(), render: vi.fn() } as unknown as Response;

await callHandler(POST, req, res);

expect(res.redirect).toHaveBeenCalledWith("/manual-upload-success");
});
Comment on lines +896 to +941

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "--- manual-upload-summary controller ---"
rg -n -C6 'extractAndStoreArtefactSearch|processPublication\(' 'apps/web/src/pages/(admin)/manual-upload-summary/index.ts'

Repository: hmcts/cath-service

Length of output: 1776


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- locate processPublication implementation ---"
fd -a 'publication' . | head -n 20
echo
rg -n -C8 'function processPublication|const processPublication|processPublication\s*=' . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'
echo
echo "--- locate extractAndStoreArtefactSearch implementation ---"
rg -n -C8 'extractAndStoreArtefactSearch' . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

Repository: hmcts/cath-service

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- publication package files ---"
git ls-files | rg '^packages/.*/.*publication.*\.(ts|tsx|js)$|^apps/.*/.*publication.*\.(ts|tsx|js)$|^libs/.*/.*publication.*\.(ts|tsx|js)$'

echo
echo "--- search for processPublication and extractAndStoreArtefactSearch usages ---"
rg -n -C6 'processPublication|extractAndStoreArtefactSearch' packages apps libs --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

Repository: hmcts/cath-service

Length of output: 50375


Avoid extracting JSON search data twice. The JSON upload path calls extractAndStoreArtefactSearch(...) directly and then passes the same jsonData into processPublication(...), which already calls it internally. Remove one of those calls to avoid duplicate DB work and the extra race window.


it("should handle missing fileName gracefully when determining isFlatFile", async () => {
const noFileNameUploadData = {
...mockUploadData,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/pages/(admin)/manual-upload-summary/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@

const listTypeId = uploadData.listType ? Number.parseInt(uploadData.listType, 10) : null;
const listType = listTypeId ? await findListTypeById(listTypeId) : null;
const listTypeName = listType ? (locale === "cy" ? listType.welshFriendlyName : listType.friendlyName) || uploadData.listType : uploadData.listType;
const listTypeName = listType
? (locale === "cy" ? listType.welshFriendlyName : listType.shortenedFriendlyName || listType.friendlyName) || listType.name || uploadData.listType

Check warning on line 19 in apps/web/src/pages/(admin)/manual-upload-summary/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=hmcts.cath&issues=AZ7_fmI0M5jVkSooRB7Z&open=AZ7_fmI0M5jVkSooRB7Z&pullRequest=772
: uploadData.listType;

return { courtName, listTypeName };
}
Expand Down
102 changes: 92 additions & 10 deletions apps/web/src/pages/(admin)/non-strategic-upload-summary/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,32 @@ vi.mock("@hmcts/system-admin-pages", () => ({
if (id === 1) return Promise.resolve({ id: 1, friendlyName: "Test List Type", welshFriendlyName: "Test List Type CY" });
if (id === 5) return Promise.resolve({ id: 5, friendlyName: "Magistrates Public List", welshFriendlyName: "Rhestr Gyhoeddus Ynadon" });
if (id === 6) return Promise.resolve({ id: 6, friendlyName: "Crown Daily List", welshFriendlyName: "Rhestr Ddyddiol y Goron" });
if (id === 7)
return Promise.resolve({
id: 7,
name: "CIC_WEEKLY_HEARING_LIST",
friendlyName: "CIC Weekly Hearing List",
welshFriendlyName: "Rhestr Wythnosol CIC",
isNonStrategic: true
});
return Promise.resolve(null);
}),
AuditLogAction: {
NON_STRATEGIC_UPLOAD: "Non strategic upload"
}
}));

vi.mock("@hmcts/list-types-common", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hmcts/list-types-common")>();
return {
...actual,
hasConverterForListType: vi.fn(() => true),
convertExcelForListType: vi.fn(() => Promise.resolve({ cases: [] })),
hasConverterForListTypeName: vi.fn(() => false),
convertExcelForListTypeName: vi.fn()
};
});

vi.mock("@hmcts/web-core", async () => {
const actual = await vi.importActual("@hmcts/web-core");
return {
Expand All @@ -73,18 +92,18 @@ vi.mock("@hmcts/admin-pages", async () => {
};
});

vi.mock("@hmcts/publication", async () => {
const actual = await vi.importActual("@hmcts/publication");
return {
...actual,
createArtefact: vi.fn(() => Promise.resolve({ artefactId: "artefact-id-123", isUpdate: false })),
processPublication: vi.fn(() => Promise.resolve({})),
updateArtefactFileExtension: vi.fn(() => Promise.resolve())
};
});
vi.mock("@hmcts/publication", () => ({
createArtefact: vi.fn(() => Promise.resolve({ artefactId: "artefact-id-123", isUpdate: false })),
processPublication: vi.fn(() => Promise.resolve({})),
updateArtefactFileExtension: vi.fn(() => Promise.resolve()),
extractAndStoreArtefactSearch: vi.fn(() => Promise.resolve()),
Provenance: { MANUAL_UPLOAD: "MANUAL_UPLOAD" },
Sensitivity: { PUBLIC: "PUBLIC", PRIVATE: "PRIVATE", CLASSIFIED: "CLASSIFIED" },
Language: { ENGLISH: "ENGLISH", WELSH: "WELSH", BILINGUAL: "BILINGUAL" }
}));

import { getNonStrategicUpload, saveUploadedFile } from "@hmcts/admin-pages";
import { createArtefact, processPublication } from "@hmcts/publication";
import { createArtefact, extractAndStoreArtefactSearch, processPublication } from "@hmcts/publication";

describe("non-strategic-upload-summary page", () => {
beforeEach(() => {
Expand Down Expand Up @@ -399,6 +418,69 @@ describe("non-strategic-upload-summary page", () => {
);
});

it("should convert Excel file and call extractAndStoreArtefactSearch for non-strategic list type", async () => {
const { hasConverterForListType, convertExcelForListType } = await import("@hmcts/list-types-common");

const mockUploadData = {
file: Buffer.from("excel content"),
fileName: "test.xlsx",
fileType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
locationId: "123",
listType: "7",
hearingStartDate: { day: "23", month: "10", year: "2025" },
sensitivity: "PUBLIC",
language: "ENGLISH",
displayFrom: { day: "20", month: "10", year: "2025" },
displayTo: { day: "30", month: "10", year: "2025" }
};

vi.mocked(getNonStrategicUpload).mockResolvedValue(mockUploadData);
vi.mocked(createArtefact).mockResolvedValue({ artefactId: "artefact-id-123", isUpdate: false });
vi.mocked(hasConverterForListType).mockReturnValue(true);
vi.mocked(convertExcelForListType).mockResolvedValue({ cases: [] });
vi.mocked(extractAndStoreArtefactSearch).mockResolvedValue(undefined);

const session = { save: (callback: (err?: any) => void) => callback() };
const req = { query: { uploadId: "test-upload-id" }, session } as unknown as Request;
const res = { redirect: vi.fn(), render: vi.fn() } as unknown as Response;

await callHandler(POST, req, res);

expect(extractAndStoreArtefactSearch).toHaveBeenCalledWith("artefact-id-123", 7, { cases: [] });
expect(res.redirect).toHaveBeenCalledWith("/non-strategic-upload-success");
});

it("should continue upload when extractAndStoreArtefactSearch throws after Excel conversion", async () => {
const { hasConverterForListType, convertExcelForListType } = await import("@hmcts/list-types-common");

const mockUploadData = {
file: Buffer.from("excel content"),
fileName: "test.xlsx",
fileType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
locationId: "123",
listType: "7",
hearingStartDate: { day: "23", month: "10", year: "2025" },
sensitivity: "PUBLIC",
language: "ENGLISH",
displayFrom: { day: "20", month: "10", year: "2025" },
displayTo: { day: "30", month: "10", year: "2025" }
};

vi.mocked(getNonStrategicUpload).mockResolvedValue(mockUploadData);
vi.mocked(createArtefact).mockResolvedValue({ artefactId: "artefact-id-123", isUpdate: false });
vi.mocked(hasConverterForListType).mockReturnValue(true);
vi.mocked(convertExcelForListType).mockResolvedValue({ cases: [] });
vi.mocked(extractAndStoreArtefactSearch).mockRejectedValue(new Error("Search extraction failed"));

const session = { save: (callback: (err?: any) => void) => callback() };
const req = { query: { uploadId: "test-upload-id" }, session } as unknown as Request;
const res = { redirect: vi.fn(), render: vi.fn() } as unknown as Response;

await callHandler(POST, req, res);

expect(res.redirect).toHaveBeenCalledWith("/non-strategic-upload-success");
});

it("should redirect with language parameter when lng=cy", async () => {
const mockUploadData = {
file: Buffer.from("test"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@

const listTypeId = uploadData.listType ? Number.parseInt(uploadData.listType, 10) : null;
const listType = listTypeId ? await findListTypeById(listTypeId) : null;
const listTypeName = listType ? (locale === "cy" ? listType.welshFriendlyName : listType.friendlyName) || uploadData.listType : uploadData.listType;
const listTypeName = listType
? (locale === "cy" ? listType.welshFriendlyName : listType.shortenedFriendlyName || listType.friendlyName) || listType.name || uploadData.listType

Check warning on line 24 in apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=hmcts.cath&issues=AZ7_fmFDM5jVkSooRB7Y&open=AZ7_fmFDM5jVkSooRB7Y&pullRequest=772
: uploadData.listType;

return { courtName, listTypeName };
}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/pages/(admin)/non-strategic-upload/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import "@hmcts/ast-daily-hearing-list"; // Register AST converter
import "@hmcts/care-standards-tribunal-weekly-hearing-list"; // Register CST converter
import "@hmcts/cic-weekly-hearing-list"; // Register CIC converter
import "@hmcts/send-daily-hearing-list"; // Register SEND converter
import { LANGUAGE_LABELS, SENSITIVITY_LABELS, storeNonStrategicUpload, type UploadFormData, validateNonStrategicUploadForm } from "@hmcts/admin-pages";
import { requireRole, USER_ROLES } from "@hmcts/auth";
import { getAllLocations, getLocationById } from "@hmcts/location";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{% extends "layouts/base-template.njk" %}
{% from "govuk/components/table/macro.njk" import govukTable %}

{% block head %}
{{ super() }}
<style>
.back-to-top {
margin-top: 40px;
}
</style>
{% endblock %}

{% block page_content %}
<div class="govuk-grid-row">
<div class="govuk-grid-column-full">

<h1 class="govuk-heading-l" id="top">{{ header.listTitle }}</h1>

<p class="govuk-body">
<a href="{{ t.factLinkUrl }}" class="govuk-link">{{ t.factLinkText }}</a> {{ t.factAdditionalText }}
</p>

<p class="govuk-body">
{% for line in t.venueAddressLines %}{{ line }}{% if not loop.last %}<br>{% endif %}{% endfor %}
</p>

<p class="govuk-body govuk-!-margin-bottom-1"><strong>{{ t.listForDate }} {{ header.listForDate }}</strong></p>

<p class="govuk-body">{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}</p>

<details class="govuk-details govuk-!-margin-top-6" data-module="govuk-details" open>
<summary class="govuk-details__summary">
<span class="govuk-details__summary-text">
{{ t.importantInformationTitle }}
</span>
</summary>
<div class="govuk-details__text">
{% for paragraph in t.importantInformationParagraphs %}
<p class="govuk-body">{{ paragraph }}</p>
{% endfor %}
<p class="govuk-body">
{{ t.importantInformationLinkPrefix }}
<a href="{{ t.importantInformationLinkUrl }}" class="govuk-link">{{ t.importantInformationLinkText }}</a>.
</p>
</div>
</details>

<div class="govuk-form-group govuk-!-margin-top-6">
<h2 class="govuk-heading-s">{{ t.searchCasesTitle }}</h2>
<label class="govuk-label govuk-visually-hidden" for="case-search-input">
{{ t.searchCasesLabel }}
</label>
<input class="govuk-input govuk-!-width-one-half" id="case-search-input" name="search" type="text" aria-label="{{ t.searchCasesLabel }}">
</div>

<div id="hearings-table-container">
<table class="govuk-table" id="hearings-table" role="table" aria-label="{{ t.pageTitle }}">
<thead class="govuk-table__head">
<tr class="govuk-table__row">
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.appellant }}</th>
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.appealReferenceNumber }}</th>
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.caseType }}</th>
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.hearingType }}</th>
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.hearingTime }}</th>
<th scope="col" class="govuk-table__header">{{ t.tableHeaders.additionalInformation }}</th>
</tr>
</thead>
<tbody class="govuk-table__body">
{% for hearing in hearings %}
<tr class="govuk-table__row">
<td class="govuk-table__cell">{{ hearing.appellant }}</td>
<td class="govuk-table__cell">{{ hearing.appealReferenceNumber }}</td>
<td class="govuk-table__cell">{{ hearing.caseType }}</td>
<td class="govuk-table__cell">{{ hearing.hearingType }}</td>
<td class="govuk-table__cell">{{ hearing.hearingTime }}</td>
<td class="govuk-table__cell">{{ hearing.additionalInformation }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

<p class="govuk-body-s govuk-!-margin-top-6">{{ t.dataSource }}: {{ dataSource }}</p>

<div class="back-to-top">
<a href="#top" class="govuk-link">{{ t.backToTop }}</a>
</div>

</div>
</div>
{% endblock %}
Loading
Loading