merge from main

This commit is contained in:
Kenneth Kehl
2025-09-17 12:39:32 -07:00
15 changed files with 636 additions and 1183 deletions

View File

@@ -167,33 +167,12 @@ def test_can_show_notifications(
assert page_title in page.h1.text.strip()
path_to_json = page.find("div", {"data-key": "notifications"})["data-resource"]
url = urlparse(path_to_json)
assert url.path == "/services/{}/notifications{}".format(
SERVICE_ONE_ID,
expected_update_endpoint,
)
query_dict = parse_qs(url.query)
if status_argument:
assert query_dict["status"] == [status_argument]
if expected_page_argument:
assert query_dict["page"] == [str(expected_page_argument)]
assert "to" not in query_dict
mock_get_notifications.assert_called_with(
limit_days=expected_limit_days,
page=expected_page_argument,
service_id=SERVICE_ONE_ID,
status=expected_api_call,
template_type=list(extra_args.values()),
to=expected_to_argument,
)
json_response = client_request.get_response(
"main.get_notifications_as_json",
service_id=service_one["id"],
service_id=SERVICE_ONE_ID,
status=status_argument,
page=expected_page_argument,
to=expected_to_argument,
**extra_args
)
json_content = json.loads(json_response.get_data(as_text=True))
@@ -203,6 +182,8 @@ def test_can_show_notifications(
"service_data_retention_days",
}
mock_get_notifications.assert_called()
def test_can_show_notifications_if_data_retention_not_available(
client_request,
@@ -305,6 +286,7 @@ def test_download_links_show_when_data_available(
mocker.patch("app.s3_client.check_s3_file_exists", return_value=True)
mock_obj = mocker.Mock()
mock_obj.content_length = 1024
mocker.patch("app.s3_client.get_s3_object", return_value=mock_obj)
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=mock_obj)
page = client_request.get(
@@ -345,6 +327,7 @@ def test_download_links_partial_data_available(
mock_obj = mocker.Mock()
mock_obj.content_length = 2048
mocker.patch("app.s3_client.s3_csv_client.get_csv_upload", return_value=mock_obj)
mocker.patch("app.s3_client.get_s3_object", return_value=mock_obj)
page = client_request.get(
"main.all_jobs_activity",
@@ -370,6 +353,9 @@ def test_download_links_no_data_available(
mocker.patch("app.job_api_client.get_page_of_jobs", return_value=mock_jobs_empty)
mocker.patch("app.job_api_client.get_immediate_jobs", return_value=[])
mocker.patch("app.s3_client.check_s3_file_exists", return_value=False)
mock_obj = mocker.Mock()
mock_obj.content_length = 0
mocker.patch("app.s3_client.get_s3_object", return_value=mock_obj)
page = client_request.get(
"main.all_jobs_activity",

View File

@@ -92,12 +92,13 @@ def test_should_show_page_for_one_job(
assert " ".join(page.find("tbody").find("tr").text.split()) == (
"2021234567 template content Delivered 01-01-2016 at 06:09 AM"
)
assert page.find("div", {"data-key": "notifications"})["data-resource"] == url_for(
client_request.get_response(
"main.view_job_updates",
service_id=SERVICE_ONE_ID,
job_id=fake_uuid,
status=status_argument,
)
mock_get_notifications.assert_called()
csv_link = page.select_one("a[download]")
assert csv_link["href"] == url_for(
"main.view_job_csv",

View File

@@ -354,3 +354,93 @@ test('handleDropdownChange shows empty message when user has no jobs', () => {
window.fetchData.mockRestore();
});
test('fetchData returns early when isPolling is true', async () => {
window.isPolling = true;
global.fetch = jest.fn();
const result = await window.fetchData('service');
expect(global.fetch).not.toHaveBeenCalled();
expect(result).toBeUndefined();
window.isPolling = false;
delete global.fetch;
});
test('fetchData returns early when document is hidden', async () => {
Object.defineProperty(document, 'hidden', { value: true, writable: true });
global.fetch = jest.fn();
const result = await window.fetchData('service');
expect(global.fetch).not.toHaveBeenCalled();
expect(result).toBeUndefined();
Object.defineProperty(document, 'hidden', { value: false, writable: true });
delete global.fetch;
});
test('fetchData returns undefined when weeklyChart is missing', async () => {
const chart = document.getElementById('weeklyChart');
if (chart) {
chart.remove();
}
window.isPolling = false;
global.fetch = jest.fn();
const result = await window.fetchData('service');
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
const container = document.getElementById('activityChart');
if (container && !document.getElementById('weeklyChart')) {
const newChart = document.createElement('div');
newChart.id = 'weeklyChart';
newChart.setAttribute('data-service-id', '12345');
newChart.style.width = '600px';
container.appendChild(newChart);
}
delete global.fetch;
});
test('handleDropdownChange updates subtitle text correctly', () => {
const selectElement = document.getElementById('options');
selectElement.value = 'individual';
const event = { target: selectElement };
jest.spyOn(window, 'fetchData').mockImplementation(() => {});
window.handleDropdownChange(event);
const subtitle = document.querySelector('#activityChartContainer .chart-subtitle');
expect(subtitle.textContent).toContain('Individual');
window.fetchData.mockRestore();
});
test('dropdown change handles DOM updates for table filtering', () => {
const selectElement = document.getElementById('options');
jest.spyOn(window, 'fetchData').mockImplementation(() => {});
selectElement.value = 'service';
window.handleDropdownChange({ target: selectElement });
let subtitle = document.querySelector('#activityChartContainer .chart-subtitle');
expect(subtitle.textContent).toContain('Service');
selectElement.value = 'individual';
window.handleDropdownChange({ target: selectElement });
subtitle = document.querySelector('#activityChartContainer .chart-subtitle');
expect(subtitle.textContent).toContain('Individual');
window.fetchData.mockRestore();
});

View File

@@ -1,513 +0,0 @@
const each = require('jest-each').default;
const helpers = require('./support/helpers.js');
const serviceNumber = '6658542f-0cad-491f-bec8-ab8457700ead';
const resourceURL = `/services/${serviceNumber}/notifications/email.json?status=sending%2Cdelivered%2Cfailed`;
const updateKey = 'counts';
let responseObj = {};
let jqueryAJAXReturnObj;
beforeAll(() => {
// ensure all timers go through Jest
jest.useFakeTimers();
// mock the bits of jQuery used
jest.spyOn(window.$, 'ajax');
// set up the object returned from $.ajax so it responds with whatever responseObj is set to
jqueryAJAXReturnObj = {
done: callback => {
// The server takes 1 second to respond
jest.advanceTimersByTime(1000);
callback(responseObj);
return jqueryAJAXReturnObj;
},
fail: () => {}
};
$.ajax.mockImplementation(() => jqueryAJAXReturnObj);
// RollupJS assigns our bundled module code, including morphdom, to window.GOVUK.
// morphdom is assigned to its vendor property so we need to copy that here for the updateContent
// code to pick it up.
window.GOVUK.vendor = {
morphdom: require('morphdom')
};
require('../../app/assets/javascripts/updateContent.js');
});
afterAll(() => {
require('./support/teardown.js');
});
describe('Update content', () => {
const getInitialHTMLString = partial => `
<div data-module="update-content" data-resource="${resourceURL}" data-key="${updateKey}">
${partial}
</div>`;
describe("All variations", () => {
beforeEach(() => {
// Intentionally basic example because we're not testing changes to the partial
document.body.innerHTML = getInitialHTMLString(`<p class="notification-status">Sending</p>`);
// default the response to match the content inside div[data-module]
responseObj[updateKey] = `<p class="notification-status">Sending</p>`;
});
describe("By default", () => {
beforeEach(() => {
// start the module
window.GOVUK.modules.start();
});
test("It should use the GET HTTP method", () => {
jest.advanceTimersByTime(2000);
expect($.ajax.mock.calls[0][1].method).toEqual('get');
});
test("It shouldn't send any data as part of the requests", () => {
jest.advanceTimersByTime(2000);
expect($.ajax.mock.calls[0][1].data).toEqual({});
});
test("It should request updates with a dynamic interval", () => {
// First call doesnt happen in the first 2000ms
jest.advanceTimersByTime(1999);
expect($.ajax).toHaveBeenCalledTimes(0);
// But it happens after 2000ms by default
jest.advanceTimersByTime(1);
expect($.ajax).toHaveBeenCalledTimes(1);
// It took the server 1000ms to respond to the first call so we
// will back off the next call shouldnt happen in the next 6904ms
jest.advanceTimersByTime(6904);
expect($.ajax).toHaveBeenCalledTimes(1);
// But it should happen after 6905ms
jest.advanceTimersByTime(1);
expect($.ajax).toHaveBeenCalledTimes(2);
});
each([
[1000, 0],
[1500, 100],
[4590, 500],
[6905, 1000],
[24000, 10000],
]).test('It calculates a delay of %dms if the API responds in %dms', (waitTime, responseTime) => {
expect(
window.GOVUK.Modules.UpdateContent.calculateBackoff(responseTime)
).toBe(
waitTime
);
});
});
describe("If a form is used as a source for data, referenced in the data-form attribute", () => {
beforeEach(() => {
// Add a form to the page
document.body.innerHTML += `
<form method="post" id="service">
<input type="hidden" name="serviceName" value="Buckhurst surgery" />
<input type="hidden" name="serviceNumber" value="${serviceNumber}" />
</form>`;
// Link the component to the form
document.querySelector('[data-module=update-content]').setAttribute('data-form', 'service');
// start the module
window.GOVUK.modules.start();
});
test("requests should use the same HTTP method as the form", () => {
jest.advanceTimersByTime(2000);
expect($.ajax.mock.calls[0][1].method).toEqual('post');
})
test("requests should use the data from the form", () => {
jest.advanceTimersByTime(2000);
expect($.ajax.mock.calls[0][1].data).toEqual(helpers.getFormDataFromPairs([
['serviceName', 'Buckhurst surgery'],
['serviceNumber', serviceNumber]
]));
})
});
});
describe('When updating the contents of DOM nodes', () => {
let partialData;
const getPartial = items => {
let pillsHTML = '';
items.forEach(item => {
pillsHTML += `
<li ${item.selected ? 'aria-selected="true"' : ''} role="tab">
<div ${item.selected ? 'class="pill-selected-item" tabindex="0"' : ''}>
<div class="big-number-smaller">
<div class="big-number-number">${item.count}</div>
</div>
<div class="pill-label">${item.label}</div>
</div>
</li>`;
});
return `
<div class="tabs ajax-block-container">
<ul role="tablist" class="pill">
${pillsHTML}
</ul>
</div>`;
};
beforeEach(() => {
partialData = [
{
count: 0,
label: 'total',
selected: true
},
{
count: 0,
label: 'sending',
selected: false
},
{
count: 0,
label: 'delivered',
selected: false
},
{
count: 0,
label: 'failed',
selected: false
}
];
document.body.innerHTML = getInitialHTMLString(getPartial(partialData));
});
test("It should replace the original HTML with that of the partial, to match that returned from AJAX responses", () => {
// default the response to match the content inside div[data-module]
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
expect(document.querySelector('.ajax-block-container').parentNode.hasAttribute('data-resource')).toBe(false);
});
test("It should make requests to the URL specified in the data-resource attribute", () => {
// default the response to match the content inside div[data-module]
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
expect($.ajax.mock.calls[0][0]).toEqual(resourceURL);
});
test("If the response contains no changes, the DOM should stay the same", () => {
// send the done callback a response with updates included
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check a sample DOM node is unchanged
expect(document.querySelectorAll('.big-number-number')[0].textContent.trim()).toEqual("0");
});
test("If the response contains changes, it should update the DOM with them", () => {
partialData[0].count = 1;
// send the done callback a response with updates included
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check the right DOM node is updated
expect(document.querySelectorAll('.big-number-number')[0].textContent.trim()).toEqual("1");
});
});
describe("When adding or removing DOM nodes", () => {
let partialData;
const getPartial = items => {
const getItemHTMLString = content => {
var areas = '';
content.areas.forEach(area =>
areas += "\n" + `<li class="area-list-item area-list-item--unremoveable area-list-item--smaller">${area}</li>`
);
return `
<div class="keyline-block">
<div class="file-list govuk-!-margin-bottom-2">
<h2>
<a class="file-list-filename-large usa-link" href="/services/7597847f-ad8e-4600-8faf-c42a647d8dee/current-alerts/b9e53cda-54f9-47bc-9fb2-b78a11eda6a9">${content.title}</a>
</h2>
<div class="govuk-grid-row">
<div class="grid-col-6">
<span class="file-list-hint-large govuk-!-margin-bottom-2">
${content.hint}
</span>
</div>
<div class="grid-col-6 file-list-status">
<p class="usa-body govuk-!-margin-bottom-0 usa-hint">
${content.status}
</p>
</div>
</div>
<ul class="area-list">
${areas}
</ul>
</div>
</div>`;
};
var itemsHTMLString = '';
items.forEach(item => itemsHTMLString += "\n" + getItemHTMLString(item));
return `<div class="ajax-block-container">
${itemsHTMLString};
<div class="keyline-block"></div>
</div>`;
};
beforeEach(() => {
partialData = [
{
title: "Gas leak",
hint: "There's a gas leak in the local area. Residents should vacate until further notice.",
status: "Waiting for approval",
areas: [
"Santa Claus Village, Rovaniemi B",
"Santa Claus Village, Rovaniemi C"
]
}
];
});
test("If the response contains no changes, the DOM should stay the same", () => {
document.body.innerHTML = getInitialHTMLString(getPartial(partialData));
// make a response with no changes
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check it has the same number of items
expect(document.querySelectorAll('.file-list').length).toEqual(1);
expect(document.querySelectorAll('.file-list h2 a')[0].textContent.trim()).toEqual("Gas leak");
});
test("If the response adds a node, the DOM should contain that node", () => {
document.body.innerHTML = getInitialHTMLString(getPartial(partialData));
partialData.push({
title: "Reservoir flooding template",
hint: "The local reservoir has flooded. All people within 5 miles should move to a safer location.",
status: "Waiting for approval",
areas: [
"Santa Claus Village, Rovaniemi A",
"Santa Claus Village, Rovaniemi D"
]
});
// make the response have an extra item
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check the node has been added
expect(document.querySelectorAll('.file-list').length).toEqual(2);
expect(document.querySelectorAll('.file-list h2 a')[0].textContent.trim()).toEqual("Gas leak");
expect(document.querySelectorAll('.file-list h2 a')[1].textContent.trim()).toEqual("Reservoir flooding template");
});
test("If the response removes a node, the DOM should not contain that node", () => {
// add another item so we start with 2
partialData.push({
title: "Reservoir flooding template",
hint: "The local reservoir has flooded. All people within 5 miles should move to a safer location.",
status: "Waiting for approval",
areas: [
"Santa Claus Village, Rovaniemi A",
"Santa Claus Village, Rovaniemi D"
]
});
document.body.innerHTML = getInitialHTMLString(getPartial(partialData));
// remove the last item
partialData.pop();
// default the response to match the content inside div[data-module]
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check the node has been removed
expect(document.querySelectorAll('.file-list').length).toEqual(1);
expect(document.querySelectorAll('.file-list h2 a')[0].textContent.trim()).toEqual("Gas leak");
});
test("If other scripts have added classes to the DOM, they should persist through updates to a single component", () => {
document.body.innerHTML = getInitialHTMLString(getPartial(partialData));
// mark classes to persist on the partial
document.querySelector('.ajax-block-container').setAttribute('data-classes-to-persist', 'js-child-has-focus');
// Add class to indicate focus state of link on parent heading
document.querySelectorAll('.file-list h2')[0].classList.add('js-child-has-focus');
// Add an item to trigger an update
partialData.push({
title: "Reservoir flooding template",
hint: "The local reservoir has flooded. All people within 5 miles should move to a safer location.",
status: "Waiting for approval",
areas: [
"Santa Claus Village, Rovaniemi A",
"Santa Claus Village, Rovaniemi D"
]
});
// make the response have an extra item
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// check the class is still there
expect(document.querySelectorAll('.file-list h2')[0].classList.contains('js-child-has-focus')).toBe(true);
});
test("If other scripts have added classes to the DOM, they should persist through updates to multiple components", () => {
// Create duplicate components in the page
document.body.innerHTML = getInitialHTMLString(getPartial(partialData)) + "\n" + getInitialHTMLString(getPartial(partialData));
var partialsInPage = document.querySelectorAll('.ajax-block-container');
// Mark classes to persist on the partials (2nd is made up)
partialsInPage[0].setAttribute('data-classes-to-persist', 'js-child-has-focus');
partialsInPage[1].setAttribute('data-classes-to-persist', 'js-2nd-child-has-focus');
// Add examples of those classes on each partial (2nd is made up)
partialsInPage[0].querySelectorAll('.file-list h2')[0].classList.add('js-child-has-focus');
partialsInPage[1].querySelectorAll('.file-list h2')[0].classList.add('js-2nd-child-has-focus');
// Add an item to trigger an update
partialData.push({
title: "Reservoir flooding template",
hint: "The local reservoir has flooded. All people within 5 miles should move to a safer location.",
status: "Waiting for approval",
areas: [
"Santa Claus Village, Rovaniemi A",
"Santa Claus Village, Rovaniemi D"
]
});
// make all responses have an extra item
responseObj[updateKey] = getPartial(partialData);
// start the module
window.GOVUK.modules.start();
jest.advanceTimersByTime(2000);
// re-select in case nodes in partialsInPage have changed
partialsInPage = document.querySelectorAll('.ajax-block-container');
// check the classes are still there
expect(partialsInPage[0].querySelectorAll('.file-list h2')[0].classList.contains('js-child-has-focus')).toBe(true);
expect(partialsInPage[1].querySelectorAll('.file-list h2')[0].classList.contains('js-2nd-child-has-focus')).toBe(true);
// check each heading only has the classes assigned to it before updates occurred
expect(partialsInPage[0].querySelectorAll('.file-list h2')[0].classList.contains('js-2nd-child-has-focus')).toBe(false);
expect(partialsInPage[1].querySelectorAll('.file-list h2')[0].classList.contains('js-child-has-focus')).toBe(false);
});
});
afterEach(() => {
document.body.innerHTML = '';
// tidy up record of mocked AJAX calls
$.ajax.mockClear();
// ensure any timers set by continually starting the module are cleared
jest.clearAllTimers();
});
});