Skip to content

Commit

Permalink
Synchronize test timers for all users (#7896)
Browse files Browse the repository at this point in the history
* Add custom started at time for test timer

* Add update timer started at mutation

* Add comment for timer reset useEffect

* Remove deletion of empty line

* Remove timer useEffect for refactor

* Move scroll useEffect back to original position

* Linting fix

* Fix timer reset when result ready

* Update gql codegen

* Handle null timerStartedAt number

* Add test order service timer test

* Add test card timer test

* Add queue mutation resolver test

* Add null test for test card timer

* Update TestOrderServiceTest

* Fix comment on reset logic

* Add illegal gql exception to update test timer

* Ensure single invocation

* Remove explicit truthy cast
  • Loading branch information
mpbrown committed Jul 22, 2024
1 parent 1336ab0 commit 90ca18a
Show file tree
Hide file tree
Showing 12 changed files with 372 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ public TestCorrectionStatus getCorrectionStatus() {
public String getReasonForCorrection() {
return wrapped.getReasonForCorrection();
}

public String getTimerStartedAt() {
return wrapped.getTimerStartedAt();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,10 @@ public void updateAoeQuestions(
personService.updateTestResultDeliveryPreference(patientId, testResultDelivery);
}
}

@MutationMapping
public void updateTestOrderTimerStartedAt(
@Argument UUID testOrderId, @Argument String startedAt) {
testOrderService.updateTimerStartedAt(testOrderId, startedAt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,18 @@ public void removePatientFromQueue(UUID patientId) {
_testOrderRepo.save(order);
}

@AuthorizationConfiguration.RequirePermissionUpdateTestForPatient
public void updateTimerStartedAt(UUID id, String startedAt) {
Optional<TestOrder> optionalTestOrder = _testOrderRepo.findById(id);
if (optionalTestOrder.isPresent()) {
TestOrder order = optionalTestOrder.get();
order.setTimerStartedAt(startedAt);
_testOrderRepo.save(order);
} else {
throw new IllegalGraphqlArgumentException("Cannot find TestOrder");
}
}

private TestOrder retrieveTestOrder(UUID patientId) {
Organization org = _organizationService.getCurrentOrganization();
Person patient = _personService.getPatientNoPermissionsCheck(patientId, org);
Expand Down
5 changes: 5 additions & 0 deletions backend/src/main/resources/graphql/main.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ type TestOrder {
dateTested: DateTime
correctionStatus: String
reasonForCorrection: String
timerStartedAt: String
}

type TestResult {
Expand Down Expand Up @@ -694,6 +695,10 @@ type Mutation {
genderOfSexualPartners: [String]
testResultDelivery: TestResultDeliveryPreference
): String @requiredPermissions(allOf: ["UPDATE_TEST"])
updateTestOrderTimerStartedAt(
testOrderId: ID!
startedAt: String
): String @requiredPermissions(allOf: ["UPDATE_TEST"])
sendPatientLinkSms(internalId: ID!): Boolean
@requiredPermissions(allOf: ["UPDATE_TEST"])
sendPatientLinkSmsByTestEventId(testEventId: ID!): Boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package gov.cdc.usds.simplereport.api.queue;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;

import gov.cdc.usds.simplereport.service.PersonService;
import gov.cdc.usds.simplereport.service.TestOrderService;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.stereotype.Component;

@Component
class QueueMutationResolverTest {
@Test
void updateTestTimer() {
// GIVEN
TestOrderService testOrderService = mock(TestOrderService.class);
PersonService personService = mock(PersonService.class);

long currentTime = System.currentTimeMillis();
String currentTimeString = Long.toString(currentTime);

var sut = new QueueMutationResolver(testOrderService, personService);

// WHEN
sut.updateTestOrderTimerStartedAt(UUID.randomUUID(), currentTimeString);

// THEN
verify(testOrderService).updateTimerStartedAt(any(UUID.class), eq(currentTimeString));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2372,6 +2372,40 @@ void removeTest_sentToReportingService() {
verify(testEventReportingService, times(2)).report(any());
}

@Test
@WithSimpleReportOrgAdminUser
void updateTimer_savesCorrectly() {
TestOrder timerOrder = addTestToQueue();
long currentTime = System.currentTimeMillis();
String currentTimeString = Long.toString(currentTime);

// saves a time value
_service.updateTimerStartedAt(timerOrder.getInternalId(), currentTimeString);
TestOrder modifiedTimerOrder = _service.getTestOrder(timerOrder.getInternalId());
assertEquals(modifiedTimerOrder.getTimerStartedAt(), currentTimeString);

// saves a null time value
_service.updateTimerStartedAt(timerOrder.getInternalId(), null);
TestOrder modifiedNoTimerOrder = _service.getTestOrder(timerOrder.getInternalId());
assertNull(modifiedNoTimerOrder.getTimerStartedAt());
}

@Test
@WithSimpleReportOrgAdminUser
void updateTimer_testOrderNotFound_throwsException() {
long currentTime = System.currentTimeMillis();
String currentTimeString = Long.toString(currentTime);
UUID testOrderId = UUID.randomUUID();

IllegalGraphqlArgumentException caught =
assertThrows(
IllegalGraphqlArgumentException.class,
() -> {
_service.updateTimerStartedAt(testOrderId, currentTimeString);
});
assertEquals("Cannot find TestOrder", caught.getMessage());
}

private List<TestEvent> makeAdminData() {
var org = _organizationService.createOrganization("Da Org", "airport", "da-org-airport");
_organizationService.setIdentityVerified("da-org-airport", true);
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/app/testQueue/TestCard/TestCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import mockSupportedDiseaseMultiplex, {
} from "../mocks/mockSupportedDiseaseMultiplex";
import mockSupportedDiseaseTestPerformedHIV from "../../supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedHIV";
import mockSupportedDiseaseTestPerformedSyphilis from "../../supportAdmin/DeviceType/mocks/mockSupportedDiseaseTestPerformedSyphilis";
import { UpdateTestOrderTimerStartedAtDocument } from "../../../generated/graphql";

import { TestCard, TestCardProps } from "./TestCard";

Expand Down Expand Up @@ -414,6 +415,69 @@ describe("TestCard", () => {
expect(await screen.findByTestId("timer")).toHaveTextContent("Start timer");
});

it("updates the test order timer started at value when the timer is clicked", async () => {
const currentTime = Date.now();
const props = { ...testProps };
props.testOrder.timerStartedAt = currentTime.toString();
const { user } = await renderQueueItem({
props: props,
mocks: [
{
request: {
query: UpdateTestOrderTimerStartedAtDocument,
variables: { testOrderId: props.testOrder.internalId },
},
result: { data: { updateTestOrderTimerStartedAt: null } },
},
],
});

const timerButton = await screen.findByTestId("timer");
expect(timerButton).toHaveTextContent("15:00");

await user.click(timerButton);
expect(timerButton).toHaveTextContent("Start timer");
});

it("handles a null timer started at value", async () => {
const currentTime = Date.now();
global.Date.now = jest.fn(() => new Date(currentTime).getTime());
const props = { ...testProps };
props.testOrder.timerStartedAt = null;
const { user } = await renderQueueItem({
props: props,
mocks: [
{
request: {
query: UpdateTestOrderTimerStartedAtDocument,
variables: {
testOrderId: props.testOrder.internalId,
startedAt: currentTime.toString(),
},
},
result: { data: { updateTestOrderTimerStartedAt: null } },
},
{
request: {
query: UpdateTestOrderTimerStartedAtDocument,
variables: { testOrderId: props.testOrder.internalId },
},
result: { data: { updateTestOrderTimerStartedAt: null } },
},
],
});

const timerButton = await screen.findByTestId("timer");
expect(timerButton).toHaveTextContent("Start timer");

await user.click(timerButton);
expect(timerButton).toHaveTextContent("15:00");

await user.click(timerButton);
expect(timerButton).toHaveTextContent("Start timer");
global.Date.now = Date.now;
});

it("renders dropdown of device types", async () => {
const { user } = await renderQueueItem({
mocks: [blankUpdateAoeEventMock],
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/app/testQueue/TestCard/TestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
QueriedFacility,
QueriedTestOrder,
} from "../TestCardForm/types";
import { useUpdateTestOrderTimerStartedAtMutation } from "../../../generated/graphql";
import { getAppInsights } from "../../TelemetryService";

import { CloseTestCardModal } from "./CloseTestCardModal";

Expand All @@ -39,11 +41,16 @@ export const TestCard = ({
const navigate = useNavigate();
const timer = useTestTimer(
testOrder.internalId,
testOrder.deviceType.testLength
testOrder.deviceType.testLength,
testOrder.timerStartedAt === null
? undefined
: Number(testOrder.timerStartedAt)
);
const organization = useSelector<RootState, Organization>(
(state: any) => state.organization as Organization
);
const [updateTestOrderTimerStartedAt] =
useUpdateTestOrderTimerStartedAtMutation();

const [isCloseModalOpen, setIsCloseModalOpen] = useState(false);

Expand All @@ -59,13 +66,21 @@ export const TestCard = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const appInsights = getAppInsights();

const timerContext = {
organizationName: organization.name,
facilityName: facility!.name,
patientId: testOrder.patient.internalId,
testOrderId: testOrder.internalId,
};

const trackTimerReset = () => {
if (appInsights) {
appInsights.trackEvent({ name: "Test timer reset" }, timerContext);
}
};

const { patientFullName, patientDateOfBirth } =
useTestOrderPatient(testOrder);

Expand All @@ -76,6 +91,24 @@ export const TestCard = ({
removeTimer(testOrder.internalId);
};

const saveStartedAtCallback = async (startedAt: number | undefined) => {
const response = await updateTestOrderTimerStartedAt({
variables: {
testOrderId: testOrder.internalId,
startedAt: startedAt?.toString(),
},
});
if (!response.data) {
throw Error("updateTestOrderTimerStartedAt null response data");
}
if (startedAt === undefined) {
timer.reset(trackTimerReset);
testOrder.timerStartedAt = "0";
} else {
testOrder.timerStartedAt = startedAt?.toString();
}
};

return (
<>
<CloseTestCardModal
Expand Down Expand Up @@ -146,7 +179,11 @@ export const TestCard = ({
</div>
<div className="grid-col"></div>
<div className="grid-col-auto padding-x-0">
<TestTimerWidget timer={timer} context={timerContext} />
<TestTimerWidget
timer={timer}
context={timerContext}
saveStartedAtCallback={saveStartedAtCallback}
/>
</div>
<div className="grid-col-auto close-button-col">
<Button
Expand Down
50 changes: 46 additions & 4 deletions frontend/src/app/testQueue/TestTimer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ describe("TestTimer", () => {
timer.update(10);
testCountdown(timer.countdown, 9, 58);
});
it("adjusts values when a custom start time is provided", () => {
let twoMinutesAgo = now - 120000;
let timer: Timer = new Timer(internalId, 15, twoMinutesAgo);
timer.setStartedAt(twoMinutesAgo);

// two seconds passed
timer.tick(now + 2000);
testCountdown(timer.countdown, 12, 58);
});
});

describe("TestTimerWidget", () => {
Expand All @@ -92,9 +101,15 @@ describe("TestTimerWidget", () => {
removeTimer("internal-id");
});

const renderWithUser = (testLength: number) => ({
const renderWithUser = (testLength: number, startedAt: number = 0) => ({
user: userEvent.setup(),
...render(<DummyTestTimer testLength={testLength} context={context} />),
...render(
<DummyTestTimer
testLength={testLength}
context={context}
startedAt={startedAt}
/>
),
});

it("tracks a custom event when the timer is started", async () => {
Expand Down Expand Up @@ -147,15 +162,42 @@ describe("TestTimerWidget", () => {
)
);
});

it("displays the correct value when a custom start time is provided", async () => {
const twoMinutesAgo = Date.now() - 2 * 60 * 1000;
const { user } = renderWithUser(3, twoMinutesAgo);
expect(screen.queryByText("Start timer")).not.toBeInTheDocument();

// some machines may run this test faster than others so just check that there is less than 1 minute remaining rather than checking for specific time
expect(
await screen.findByText("0:", { exact: false })
).toBeInTheDocument();

const timerButton = await screen.findByRole("timer");
await user.click(timerButton);

expect(trackEventMock).toHaveBeenCalledWith(
{ name: "Test timer reset" },
context
);
});
});
});

function DummyTestTimer(props: {
testLength: number;
context: TimerTrackEventMetadata;
startedAt: number;
}) {
const timer = useTestTimer("internal-id", props.testLength);
return <TestTimerWidget timer={timer} context={props.context} />;
const timer = useTestTimer("internal-id", props.testLength, props.startedAt);
const saveStartedAtCallback = () => {};
return (
<TestTimerWidget
timer={timer}
context={props.context}
saveStartedAtCallback={saveStartedAtCallback}
/>
);
}

function testCountdown(actualMillis: number, mins: number, secs: number) {
Expand Down
Loading

0 comments on commit 90ca18a

Please sign in to comment.