diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.spec.ts index df87ca6b4..969d312e2 100644 --- a/apps/api/src/subjects/__tests__/subjects.service.spec.ts +++ b/apps/api/src/subjects/__tests__/subjects.service.spec.ts @@ -252,6 +252,42 @@ describe('SubjectsService', () => { }); }); + describe('findDefaultGroupCustomIds', () => { + const findManyArgs = () => + subjectModel.findMany.mock.lastCall?.[0] as { select: unknown; where: { AND: unknown[] } }; + + it('should return only the ids, so no personal information leaves the database', async () => { + subjectModel.findMany.mockResolvedValueOnce([{ id: 'root$a' }]); + await expect(subjectsService.findDefaultGroupCustomIds()).resolves.toStrictEqual(['root$a']); + expect(findManyArgs().select).toStrictEqual({ id: true }); + }); + + it('should constrain the query to what the caller may read, so a group manager is not shown other groups’ subjects', async () => { + const ability = createAppAbility([ + { action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' } + ]); + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findDefaultGroupCustomIds({ ability }); + expect(findManyArgs().where.AND[0]).toStrictEqual(accessibleQuery(ability, 'read', 'Subject')); + }); + + it('should match on the default group scope of the id, so a subject later added to a group is still offered', async () => { + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findDefaultGroupCustomIds(); + expect(findManyArgs().where.AND).toContainEqual({ id: { gte: 'root$', lt: 'root%' } }); + expect(JSON.stringify(findManyArgs().where)).not.toContain('groupIds'); + }); + + it('should match only subjects identified by a custom id, like the per-group lookup', async () => { + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findCustomIds('group-1'); + const [, , groupedCustomIdClause] = findManyArgs().where.AND; + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findDefaultGroupCustomIds(); + expect(findManyArgs().where.AND).toContainEqual(groupedCustomIdClause); + }); + }); + describe('findById', () => { it('should throw a `NotFoundException` if there is no subject with the provided id', async () => { subjectModel.findFirst.mockResolvedValueOnce(null); diff --git a/apps/api/src/subjects/subjects.controller.ts b/apps/api/src/subjects/subjects.controller.ts index 12b6d1b63..e5286cb4d 100644 --- a/apps/api/src/subjects/subjects.controller.ts +++ b/apps/api/src/subjects/subjects.controller.ts @@ -58,4 +58,11 @@ export class SubjectsController { findCustomIds(@Param('groupId', ValidObjectIdPipe) groupId: string, @CurrentUser('ability') ability: AppAbility) { return this.subjectsService.findCustomIds(groupId, { ability }); } + + @ApiOperation({ summary: 'Get the Custom IDs Scoped to the Default Group' }) + @Get('default-group/custom-ids') + @RouteAccess({ action: 'read', subject: 'Subject' }) + findDefaultGroupCustomIds(@CurrentUser('ability') ability: AppAbility) { + return this.subjectsService.findDefaultGroupCustomIds({ ability }); + } } diff --git a/apps/api/src/subjects/subjects.service.ts b/apps/api/src/subjects/subjects.service.ts index 8c8e1aed2..e04a9d2b4 100644 --- a/apps/api/src/subjects/subjects.service.ts +++ b/apps/api/src/subjects/subjects.service.ts @@ -1,6 +1,8 @@ import { InjectModel, InjectPrismaClient } from '@douglasneuroinformatics/libnest'; import type { Model } from '@douglasneuroinformatics/libnest'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; +import { encodeScopedSubjectId } from '@opendatacapture/subject-utils'; import type { Prisma } from '@prisma/client'; import { accessibleQuery } from '@/auth/ability.utils'; @@ -9,6 +11,22 @@ import type { EntityOperationOptions } from '@/core/types'; import { CreateSubjectDto } from './dto/create-subject.dto'; +const PERSONAL_INFO_FIELDS = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const; + +/** + * Prisma's MongoDB connector turns `startsWith` into an unescaped regex, so the `$` ending a subject + * id scope would anchor the pattern and match nothing. A lexical range selects the same ids. + */ +function idsWithPrefix(prefix: string): Prisma.StringFilter { + const lastCharCode = prefix.charCodeAt(prefix.length - 1); + return { gte: prefix, lt: prefix.slice(0, -1) + String.fromCharCode(lastCharCode + 1) }; +} + +/** A subject identified by personal information has every one of these fields set. */ +const IDENTIFIED_BY_CUSTOM_ID: Prisma.SubjectWhereInput = { + OR: PERSONAL_INFO_FIELDS.flatMap((field) => [{ [field]: null }, { [field]: { isSet: false } }]) +}; + @Injectable() export class SubjectsService { constructor( @@ -176,14 +194,28 @@ export class SubjectsService { * field is matched on both `null` and `isSet: false`. */ async findCustomIds(groupId: string, { ability }: EntityOperationOptions = {}): Promise { - const personalInfoFields = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const; + const subjects = await this.subjectModel.findMany({ + select: { id: true }, + where: { + AND: [accessibleQuery(ability, 'read', 'Subject'), { groupIds: { has: groupId } }, IDENTIFIED_BY_CUSTOM_ID] + } + }); + return subjects.map((subject) => subject.id); + } + + /** + * The custom ids a session without a group produces. Matched on the id's scope rather than on + * `groupIds`, because a later session, upload or assignment in a group adds that group to the + * subject without changing its id. + */ + async findDefaultGroupCustomIds({ ability }: EntityOperationOptions = {}): Promise { const subjects = await this.subjectModel.findMany({ select: { id: true }, where: { AND: [ accessibleQuery(ability, 'read', 'Subject'), - { groupIds: { has: groupId } }, - { OR: personalInfoFields.flatMap((field) => [{ [field]: null }, { [field]: { isSet: false } }]) } + { id: idsWithPrefix(encodeScopedSubjectId('', { groupName: DEFAULT_GROUP_NAME })) }, + IDENTIFIED_BY_CUSTOM_ID ] } }); diff --git a/apps/web/src/__tests__/start-session-form.test.tsx b/apps/web/src/__tests__/start-session-form.test.tsx index dc884cdb2..2f07495f8 100644 --- a/apps/web/src/__tests__/start-session-form.test.tsx +++ b/apps/web/src/__tests__/start-session-form.test.tsx @@ -1,12 +1,19 @@ import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; import type { Group } from '@opendatacapture/schemas/group'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import axios from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { StartSessionForm } from '@/components/StartSessionForm'; import '@/services/i18n'; +vi.mock('axios'); + +// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method +const get = vi.mocked(axios).get; + const onSubmit = vi.fn(); const groupWithIdPattern = (idValidationRegex: string): Group => ({ @@ -24,13 +31,15 @@ const groupWithIdPattern = (idValidationRegex: string): Group => ({ const renderForm = (customSubjectIds: string[], currentGroup: Group | null = null) => { render( - + + + ); const form = screen.getByTestId('start-session-form'); fireEvent.change(form.querySelector('[name="subjectIdentificationMethod"]')!, { @@ -61,6 +70,18 @@ const submit = (form: HTMLElement) => { const submittedSubjectId = () => onSubmit.mock.lastCall?.[0].subjectData.id; +const dateOfBirthInput = () => screen.getByTestId('date-input'); +const sexTrigger = () => screen.getByTestId('subjectSex-select-trigger'); + +const pickIdentifier = (identifier: string) => { + typeIdentifier(identifier); + fireEvent.click(screen.getByTestId(`subjectId-combobox-item-${identifier}`)); +}; + +const existingSubject = (demographics: { dateOfBirth: null | string; sex: null | string }) => { + get.mockResolvedValueOnce({ data: demographics, status: 200 }); +}; + /** Every error shown, with the field it landed on, so one that moved to the form's own errors fails too. */ const errorMessages = () => screen.queryAllByTestId('error-message-text').map((element) => ({ @@ -74,6 +95,7 @@ beforeEach(() => { // There are no vitest setup files in this repo, so RTL never auto-unmounts between tests. cleanup(); vi.clearAllMocks(); + get.mockResolvedValue({ data: null, status: 404 }); }); describe('StartSessionForm', () => { @@ -171,3 +193,62 @@ describe('StartSessionForm', () => { expect(submittedSubjectId()).toBe('Group_One$abc'); }); }); + +describe('StartSessionForm with an existing subject', () => { + it('should look the subject up by the id the form would submit, so the details shown are that subject’s', async () => { + renderForm(['alpha']); + pickIdentifier('alpha'); + await waitFor(() => + expect(get).toHaveBeenCalledWith(`/v1/subjects/${DEFAULT_GROUP_NAME}%24alpha`, expect.anything()) + ); + }); + + it('should fill in and lock the date of birth and sex the subject already records', async () => { + existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' }); + renderForm(['alpha']); + pickIdentifier('alpha'); + await waitFor(() => expect(dateOfBirthInput().value).toBe('1990-06-15')); + expect(dateOfBirthInput().disabled).toBe(true); + expect(sexTrigger().disabled).toBe(true); + }); + + it('should submit the filled-in details, so the locked values reach the session', async () => { + existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' }); + const form = renderForm(['alpha']); + pickIdentifier('alpha'); + await waitFor(() => expect(sexTrigger().disabled).toBe(true)); + submit(form); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(onSubmit.mock.lastCall?.[0].subjectData).toMatchObject({ + dateOfBirth: new Date('1990-06-15'), + sex: 'FEMALE' + }); + }); + + it('should lock only the details the subject records, leaving the rest editable', async () => { + existingSubject({ dateOfBirth: null, sex: 'MALE' }); + renderForm(['alpha']); + pickIdentifier('alpha'); + await waitFor(() => expect(sexTrigger().disabled).toBe(true)); + expect(dateOfBirthInput().disabled).toBe(false); + }); + + it('should not look up an identifier absent from the options, since no subject can have it', () => { + renderForm(['alpha']); + typeIdentifier('gamma'); + closeIdentifierPopup(); + expect(get).not.toHaveBeenCalled(); + }); + + it('should clear and unlock the details when the identifier changes to a new one, so a new subject inherits nothing', async () => { + existingSubject({ dateOfBirth: '1990-06-15', sex: 'FEMALE' }); + renderForm(['alpha']); + pickIdentifier('alpha'); + await waitFor(() => expect(dateOfBirthInput().disabled).toBe(true)); + typeIdentifier('gamma'); + closeIdentifierPopup(); + await waitFor(() => expect(dateOfBirthInput().disabled).toBe(false)); + expect(dateOfBirthInput().value).toBe(''); + expect(sexTrigger().disabled).toBe(false); + }); +}); diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index e37028f38..1f658b200 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -1,5 +1,8 @@ /* eslint-disable perfectionist/sort-objects */ +import { useRef, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + import { Form } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { FormTypes } from '@opendatacapture/runtime-core'; @@ -13,10 +16,19 @@ import { encodeScopedSubjectId, generateSubjectHash } from '@opendatacapture/sub import type { Promisable } from 'type-fest'; import { z } from 'zod/v4'; +import { useSubjectDemographicsLookup } from '@/hooks/useSubjectDemographicsLookup'; import { getValueForLanguage } from '@/utils/language'; const currentDate = new Date(); +type StartSessionFormValues = FormTypes.PartialData; + +type AutofilledDemographics = Pick; + +/** Empty unless a custom identifier is in use, so switching method clears what it filled in. */ +const selectedCustomId = (values: StartSessionFormValues) => + values.subjectIdentificationMethod === 'CUSTOM_ID' ? (values.subjectId ?? '') : ''; + type StartSessionFormData = { sessionDate: Date; sessionType: 'IN_PERSON' | 'RETROSPECTIVE'; @@ -46,10 +58,40 @@ export const StartSessionForm = ({ onSubmit }: StartSessionFormProps) => { const { resolvedLanguage, t } = useTranslation(); + const lookupDemographics = useSubjectDemographicsLookup(); + const [autofilled, setAutofilled] = useState({}); + const latestCustomId = useRef(''); const minDateOfBirth = currentGroup?.settings.minimumAge ? new Date(currentDate.getTime() - currentGroup.settings.minimumAge * 31556952000) : undefined; + const scopeCustomId = (customId: string) => + encodeScopedSubjectId(customId, { groupName: currentGroup?.name ?? DEFAULT_GROUP_NAME }); + + const autofillExistingSubject = async ( + values: StartSessionFormValues, + setValues: Dispatch> + ) => { + const customId = selectedCustomId(values); + latestCustomId.current = customId; + const demographics = customSubjectIds.includes(customId) ? await lookupDemographics(scopeCustomId(customId)) : null; + // A slower lookup for an identifier the user has since moved on from must not overwrite the newer one. + if (latestCustomId.current !== customId) { + return; + } + const next: AutofilledDemographics = { + subjectDateOfBirth: demographics?.dateOfBirth ?? undefined, + subjectSex: demographics?.sex ?? undefined + }; + setValues((current) => ({ + ...current, + subjectDateOfBirth: + next.subjectDateOfBirth ?? (autofilled.subjectDateOfBirth ? undefined : current.subjectDateOfBirth), + subjectSex: next.subjectSex ?? (autofilled.subjectSex ? undefined : current.subjectSex) + })); + setAutofilled(next); + }; + return (
{ expect(get).toHaveBeenCalledWith('/v1/subjects/groups/group-1/custom-ids'); }); - it('should make no request without a group, so a user with no group is offered no subjects', async () => { - await expect(runQuery()).resolves.toStrictEqual([]); - expect(get).not.toHaveBeenCalled(); + it('should request the default group custom ids without a group, so an admin with no group is offered the subjects its sessions create', async () => { + get.mockResolvedValueOnce({ data: ['root$a'] }); + await expect(runQuery()).resolves.toStrictEqual(['root$a']); + expect(get).toHaveBeenCalledWith('/v1/subjects/default-group/custom-ids'); }); it('should reject a response that is not a list of ids, so nothing unparsed reaches the form', async () => { diff --git a/apps/web/src/hooks/__tests__/useSubjectDemographicsLookup.test.ts b/apps/web/src/hooks/__tests__/useSubjectDemographicsLookup.test.ts new file mode 100644 index 000000000..3fbf862ec --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSubjectDemographicsLookup.test.ts @@ -0,0 +1,44 @@ +import { QueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { subjectDemographicsQueryOptions } from '../useSubjectDemographicsLookup'; + +vi.mock('axios'); + +const runQuery = (id: string) => new QueryClient().fetchQuery(subjectDemographicsQueryOptions({ params: { id } })); + +// eslint-disable-next-line @typescript-eslint/unbound-method -- a vitest mock, never invoked as a method +const get = vi.mocked(axios).get; + +describe('subjectDemographicsQueryOptions', () => { + beforeEach(() => { + get.mockReset(); + }); + + it('should return only the date of birth and sex, so no other subject data reaches the form', async () => { + get.mockResolvedValueOnce({ + data: { dateOfBirth: '1990-01-01', firstName: 'Jane', id: 'root$a', sex: 'FEMALE' }, + status: 200 + }); + await expect(runQuery('root$a')).resolves.toStrictEqual({ dateOfBirth: new Date('1990-01-01'), sex: 'FEMALE' }); + }); + + it('should resolve to null for an id no subject has, so a new identifier leaves the fields editable', async () => { + get.mockResolvedValueOnce({ data: { message: 'Not Found' }, status: 404 }); + await expect(runQuery('root$new')).resolves.toBeNull(); + }); + + it('should accept a 404 as a valid status, so a new identifier raises no error notification', async () => { + get.mockResolvedValueOnce({ data: null, status: 404 }); + await runQuery('root$new'); + const { validateStatus } = get.mock.lastCall?.[1] as { validateStatus: (status: number) => boolean }; + expect([validateStatus(200), validateStatus(404), validateStatus(500)]).toStrictEqual([true, true, false]); + }); + + it('should encode the scope separator in the path, so the id reaches the API intact', async () => { + get.mockResolvedValueOnce({ data: { dateOfBirth: null, sex: null }, status: 200 }); + await runQuery('root$a b'); + expect(get.mock.lastCall?.[0]).toBe('/v1/subjects/root%24a%20b'); + }); +}); diff --git a/apps/web/src/hooks/useSubjectCustomIdsQuery.ts b/apps/web/src/hooks/useSubjectCustomIdsQuery.ts index c2e121c5a..2969dd721 100644 --- a/apps/web/src/hooks/useSubjectCustomIdsQuery.ts +++ b/apps/web/src/hooks/useSubjectCustomIdsQuery.ts @@ -7,14 +7,17 @@ type SubjectCustomIdsQueryParams = { groupId?: string; }; -/** Without a group there is nothing to suggest, so no request is made and no subjects are listed. */ +/** + * Without a group, a session's custom ID is scoped to the default group, so the matching suggestions + * are the subjects whose id carries that scope rather than those of any particular group. + */ export const subjectCustomIdsQueryOptions = ({ params }: { params: SubjectCustomIdsQueryParams }) => { return queryOptions({ queryFn: async (): Promise => { - if (!params.groupId) { - return []; - } - const response = await axios.get(`/v1/subjects/groups/${params.groupId}/custom-ids`); + const url = params.groupId + ? `/v1/subjects/groups/${params.groupId}/custom-ids` + : '/v1/subjects/default-group/custom-ids'; + const response = await axios.get(url); return $SubjectCustomIds.parse(response.data); }, queryKey: ['subjects', 'custom-ids', params.groupId] diff --git a/apps/web/src/hooks/useSubjectDemographicsLookup.ts b/apps/web/src/hooks/useSubjectDemographicsLookup.ts new file mode 100644 index 000000000..7ce1102e2 --- /dev/null +++ b/apps/web/src/hooks/useSubjectDemographicsLookup.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import { $SubjectDemographics } from '@opendatacapture/schemas/subject'; +import type { SubjectDemographics } from '@opendatacapture/schemas/subject'; +import { queryOptions, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +type SubjectDemographicsQueryParams = { + id: string; +}; + +/** Resolves to `null` for an id no subject has yet, which is an expected answer rather than an error. */ +export const subjectDemographicsQueryOptions = ({ params }: { params: SubjectDemographicsQueryParams }) => { + return queryOptions({ + queryFn: async (): Promise => { + const response = await axios.get(`/v1/subjects/${encodeURIComponent(params.id)}`, { + validateStatus: (status) => status === 200 || status === 404 + }); + return response.status === 404 ? null : $SubjectDemographics.parse(response.data); + }, + queryKey: ['subjects', 'demographics', params.id] + }); +}; + +export function useSubjectDemographicsLookup() { + const queryClient = useQueryClient(); + return useCallback( + (id: string) => queryClient.fetchQuery(subjectDemographicsQueryOptions({ params: { id } })), + [queryClient] + ); +} diff --git a/packages/schemas/src/subject/subject.ts b/packages/schemas/src/subject/subject.ts index 7ba17043f..4df0bb893 100644 --- a/packages/schemas/src/subject/subject.ts +++ b/packages/schemas/src/subject/subject.ts @@ -26,6 +26,10 @@ export const $Subject = $BaseModel.extend({ sex: $Sex.nullish() }); +/** The details a custom-id subject may carry, which the start-session form fills in for an existing one. */ +export type SubjectDemographics = z.infer; +export const $SubjectDemographics = $Subject.pick({ dateOfBirth: true, sex: true }); + /** The ids of a group's subjects that are identified by a custom id rather than personal information. */ export type SubjectCustomIds = z.infer; export const $SubjectCustomIds = $Subject.shape.id.array(); diff --git a/testing/src/pages/_app/session/start-session.page.ts b/testing/src/pages/_app/session/start-session.page.ts index 3d9fe6959..5e14132ab 100644 --- a/testing/src/pages/_app/session/start-session.page.ts +++ b/testing/src/pages/_app/session/start-session.page.ts @@ -3,11 +3,15 @@ import type { Locator, Page } from '@playwright/test'; import { AppPage } from '../route.page'; export class StartSessionPage extends AppPage { + readonly dateOfBirthField: Locator; readonly endSessionButton: Locator; readonly errorMessages: Locator; readonly pageHeader: Locator; readonly selectField: Locator; readonly sessionForm: Locator; + /** The hidden native select that carries the value; `sexTrigger` is the control a user sees. */ + readonly sexField: Locator; + readonly sexTrigger: Locator; /** Errors on the identifier field itself, unlike `errorMessages`, which spans the whole page. */ readonly subjectIdErrors: Locator; readonly subjectIdField: Locator; @@ -21,6 +25,9 @@ export class StartSessionPage extends AppPage { this.successMessage = page.getByRole('heading', { name: 'Session Successfully Started' }); this.errorMessages = page.getByTestId('error-message-text'); this.subjectIdField = this.sessionForm.locator('[name="subjectId"]'); + this.dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]'); + this.sexField = this.sessionForm.locator('[name="subjectSex"]'); + this.sexTrigger = this.sessionForm.getByTestId('subjectSex-select-trigger'); this.subjectIdErrors = this.sessionForm.locator('[data-field-group="subjectId"]').getByTestId('error-message-text'); // The end session nav item opens a dialog rather than navigating, so it carries no route. this.endSessionButton = page.getByTestId('nav-button-#'); @@ -44,16 +51,8 @@ export class StartSessionPage extends AppPage { /** Everything the form needs beyond how the subject was identified. */ async fillSessionDetails(sex: string) { - const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]'); - const sessionDate = this.sessionForm.locator('[name="sessionDate"]'); - await this.fillSubjectDetails(sex); - - await sessionTypeSelector.selectOption('Retrospective'); - - await sessionDate.waitFor({ state: 'visible' }); - const expectedSessionDate = new Date().toISOString().split('T')[0]!; - await sessionDate.fill(expectedSessionDate); + await this.fillSessionTiming(); } async fillSessionForm(firstName: string, lastName: string, sex: string) { @@ -69,15 +68,24 @@ export class StartSessionPage extends AppPage { await this.fillSessionDetails(sex); } + /** A retrospective session dated today, without touching the subject's details. */ + async fillSessionTiming() { + const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]'); + const sessionDate = this.sessionForm.locator('[name="sessionDate"]'); + + await sessionTypeSelector.selectOption('Retrospective'); + + await sessionDate.waitFor({ state: 'visible' }); + const expectedSessionDate = new Date().toISOString().split('T')[0]!; + await sessionDate.fill(expectedSessionDate); + } + /** The subject's date of birth and sex, leaving the session at its in-person default. */ async fillSubjectDetails(sex: string) { - const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]'); - const sexSelector = this.sessionForm.locator('[name="subjectSex"]'); - - await dateOfBirthField.waitFor({ state: 'visible' }); - await dateOfBirthField.fill('1990-01-01'); + await this.dateOfBirthField.waitFor({ state: 'visible' }); + await this.dateOfBirthField.fill('1990-01-01'); - await sexSelector.selectOption(sex); + await this.sexField.selectOption(sex); } async selectIdentificationMethod(methodName: string) { diff --git a/testing/src/specs/start-session.spec.ts b/testing/src/specs/start-session.spec.ts index d73e28d37..8977e0a9d 100644 --- a/testing/src/specs/start-session.spec.ts +++ b/testing/src/specs/start-session.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from '@playwright/test'; + import { StartSessionPage } from '../pages/_app/session/start-session.page'; import { ApiClient } from '../support/api-client'; import { expect, test } from '../support/fixtures'; @@ -60,6 +62,53 @@ test.describe('custom identifier suggestions', () => { expect(response.status()).toBe(200); expect(await response.json()).toStrictEqual([]); }); + + test('should list custom ids scoped to the default group, but not those scoped to a named group, when no group is given', async ({ + api, + uniqueId + }) => { + const rootScopedId = `root$default-${uniqueId}`; + const groupScopedId = `Clinic$named-${uniqueId}`; + await api.createSession(null, { id: rootScopedId }); + await api.createSession((await api.createGroup()).id, { id: groupScopedId }); + + const ids = await api.findDefaultGroupSubjectCustomIds(); + + expect(ids).toContain(rootScopedId); + expect(ids).not.toContain(groupScopedId); + }); + + test('should still list a default group custom id after its subject is seen in a group', async ({ + api, + uniqueId + }) => { + const rootScopedId = `root$regrouped-${uniqueId}`; + await api.createSession(null, { id: rootScopedId }); + // Starting a session in a group adds that group to the subject, but leaves its id alone. + await api.createSession((await api.createGroup()).id, { id: rootScopedId }); + + expect(await api.findDefaultGroupSubjectCustomIds()).toContain(rootScopedId); + }); + + test('should list no default group subjects to a group manager outside their groups', async ({ + api, + apiRequestContext, + uniqueId + }) => { + await api.createSession(null, { id: `root$default-${uniqueId}` }); + const { credentials } = await api.createUser({ + basePermissionLevel: 'GROUP_MANAGER', + groupIds: [(await api.createGroup()).id] + }); + const accessToken = await ApiClient.login(apiRequestContext, credentials); + + const response = await apiRequestContext.get('/api/v1/subjects/default-group/custom-ids', { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + expect(response.status()).toBe(200); + expect(await response.json()).toStrictEqual([]); + }); }); test.describe('start session', () => { @@ -137,11 +186,34 @@ test.describe('start session', () => { await startSessionPage.subjectIdOption(identifier).click(); await expect(startSessionPage.subjectIdField).toHaveValue(identifier); - await startSessionPage.fillSessionDetails('Male'); + // The first session recorded the subject's date of birth and sex, so the form fills those in. + await startSessionPage.fillSessionTiming(); await startSessionPage.submitForm(); await expect(startSessionPage.successMessage).toBeVisible(); }); + test('should suggest default group subjects to an admin who belongs to no group, even once seen in a group', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `default-${uniqueId}`; + // With no current group the form scopes a custom id to the default group, as `root$`. + await api.createSession(null, { id: `root$${identifier}` }); + await api.createSession((await api.createGroup()).id, { id: `root$${identifier}` }); + const { credentials } = await api.createUser({ basePermissionLevel: 'ADMIN', groupIds: [] }); + await authenticateAs(credentials); + await page.goto('/session/start-session'); + + const startSessionPage = new StartSessionPage(page); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('CUSTOM_ID'); + await startSessionPage.typeSubjectId(identifier); + + await expect(startSessionPage.subjectIdOption(identifier)).toBeVisible(); + }); + test('should show a required-field error for every missing field when submitting the personal information form empty', async ({ getPageModel }) => { @@ -234,3 +306,106 @@ test.describe('start session', () => { await expect(page.getByTestId('nav-button-/session/start-session')).toBeDisabled(); }); }); + +// A custom id says nothing about the subject behind it, so the form fills in what an existing subject +// already records. These run as an admin with no group, whose custom ids carry the `root$` scope. +test.describe('existing subject details', () => { + const openAsGrouplessAdmin = async ({ + api, + authenticateAs, + page + }: { + api: ApiClient; + authenticateAs: (credentials: Awaited>['credentials']) => Promise; + page: Page; + }) => { + const { credentials } = await api.createUser({ basePermissionLevel: 'ADMIN', groupIds: [] }); + await authenticateAs(credentials); + await page.goto('/session/start-session'); + const startSessionPage = new StartSessionPage(page); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('CUSTOM_ID'); + return startSessionPage; + }; + + // Midday UTC, so the date the form shows is the same in any timezone the suite runs in. + const dateOfBirth = new Date('1990-06-15T12:00:00Z'); + + test('should fill in and lock the details of a subject chosen from the suggestions', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `known-${uniqueId}`; + await api.createSession(null, { dateOfBirth, id: `root$${identifier}`, sex: 'FEMALE' }); + const startSessionPage = await openAsGrouplessAdmin({ api, authenticateAs, page }); + + await startSessionPage.typeSubjectId(identifier); + await startSessionPage.subjectIdOption(identifier).click(); + + await expect(startSessionPage.dateOfBirthField).toHaveValue('1990-06-15'); + await expect(startSessionPage.dateOfBirthField).toBeDisabled(); + await expect(startSessionPage.sexField).toHaveValue('FEMALE'); + await expect(startSessionPage.sexTrigger).toBeDisabled(); + + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + }); + + test('should fill in the details when a typed identifier matches an existing subject, as picking it would', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `typed-${uniqueId}`; + await api.createSession(null, { dateOfBirth, id: `root$${identifier}`, sex: 'MALE' }); + const startSessionPage = await openAsGrouplessAdmin({ api, authenticateAs, page }); + + await startSessionPage.typeSubjectId(identifier); + await startSessionPage.dismissSubjectIdOptions(); + + await expect(startSessionPage.sexField).toHaveValue('MALE'); + await expect(startSessionPage.sexTrigger).toBeDisabled(); + }); + + test('should clear and unlock the details when the identifier changes to one no subject has', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `before-${uniqueId}`; + await api.createSession(null, { dateOfBirth, id: `root$${identifier}`, sex: 'FEMALE' }); + const startSessionPage = await openAsGrouplessAdmin({ api, authenticateAs, page }); + await startSessionPage.typeSubjectId(identifier); + await startSessionPage.subjectIdOption(identifier).click(); + await expect(startSessionPage.sexTrigger).toBeDisabled(); + + await startSessionPage.typeSubjectId(`new-${uniqueId}`); + await startSessionPage.dismissSubjectIdOptions(); + + await expect(startSessionPage.dateOfBirthField).toHaveValue(''); + await expect(startSessionPage.dateOfBirthField).toBeEnabled(); + await expect(startSessionPage.sexField).not.toHaveValue('FEMALE'); + await expect(startSessionPage.sexTrigger).toBeEnabled(); + }); + + test('should lock only the details the subject records, leaving the rest to fill in', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `partial-${uniqueId}`; + await api.createSession(null, { id: `root$${identifier}`, sex: 'MALE' }); + const startSessionPage = await openAsGrouplessAdmin({ api, authenticateAs, page }); + + await startSessionPage.typeSubjectId(identifier); + await startSessionPage.subjectIdOption(identifier).click(); + + await expect(startSessionPage.sexTrigger).toBeDisabled(); + await expect(startSessionPage.dateOfBirthField).toBeEnabled(); + }); +}); diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 5ef6c67ab..6c457ea18 100644 --- a/testing/src/support/api-client.ts +++ b/testing/src/support/api-client.ts @@ -110,6 +110,15 @@ export class ApiClient { ); } + /** The ids the start-session form suggests to a user with no group: custom ids scoped to the default group. */ + async findDefaultGroupSubjectCustomIds(): Promise { + return this.expectJson( + this.request.get(`${API}/subjects/default-group/custom-ids`, { headers: this.authHeaders }), + 200, + 'find the custom subject ids scoped to the default group' + ); + } + /** Reads a group back as admin, to check whether a write by another role actually took effect. */ async findGroupById(id: string): Promise { return this.expectJson(