From 5e33d48ce7eb9867c6fd54f72455eb3cc0376716 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 13:38:44 -0400 Subject: [PATCH 01/13] feat: adjust query to allow admins to see groupless custom ids --- apps/web/src/hooks/useSubjectCustomIdsQuery.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useSubjectCustomIdsQuery.ts b/apps/web/src/hooks/useSubjectCustomIdsQuery.ts index c2e121c5a..760a457c7 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 that belong to no group rather than those of any particular one. + */ 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/ungrouped/custom-ids'; + const response = await axios.get(url); return $SubjectCustomIds.parse(response.data); }, queryKey: ['subjects', 'custom-ids', params.groupId] From af0a87b4e1e96a42eb5acbd217a1d4c05c15575b Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 13:39:12 -0400 Subject: [PATCH 02/13] test: e2e tests --- testing/src/specs/start-session.spec.ts | 56 +++++++++++++++++++++++++ testing/src/support/api-client.ts | 9 ++++ 2 files changed, 65 insertions(+) diff --git a/testing/src/specs/start-session.spec.ts b/testing/src/specs/start-session.spec.ts index d73e28d37..1e945a56a 100644 --- a/testing/src/specs/start-session.spec.ts +++ b/testing/src/specs/start-session.spec.ts @@ -60,6 +60,41 @@ test.describe('custom identifier suggestions', () => { expect(response.status()).toBe(200); expect(await response.json()).toStrictEqual([]); }); + + test('should list custom-id subjects in no group, but not those in a group, when no group is given', async ({ + api, + uniqueId + }) => { + const ungroupedId = `root$ungrouped-${uniqueId}`; + const groupedId = `grouped-${uniqueId}`; + await api.createSession(null, { id: ungroupedId }); + await api.createSession((await api.createGroup()).id, { id: groupedId }); + + const ids = await api.findUngroupedSubjectCustomIds(); + + expect(ids).toContain(ungroupedId); + expect(ids).not.toContain(groupedId); + }); + + test('should list no ungrouped subjects to a group manager, since they may read only their own groups', async ({ + api, + apiRequestContext, + uniqueId + }) => { + await api.createSession(null, { id: `root$ungrouped-${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/ungrouped/custom-ids', { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + expect(response.status()).toBe(200); + expect(await response.json()).toStrictEqual([]); + }); }); test.describe('start session', () => { @@ -142,6 +177,27 @@ test.describe('start session', () => { await expect(startSessionPage.successMessage).toBeVisible(); }); + test('should suggest subjects in no group to an admin who belongs to no group', async ({ + api, + authenticateAs, + page, + uniqueId + }) => { + const identifier = `ungrouped-${uniqueId}`; + // With no current group the form scopes a custom id to the default group, as `root$`. + await api.createSession(null, { 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 }) => { diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 5ef6c67ab..9b6cf6710 100644 --- a/testing/src/support/api-client.ts +++ b/testing/src/support/api-client.ts @@ -152,6 +152,15 @@ export class ApiClient { ); } + /** The ids the start-session form suggests to a user with no group: custom-id subjects in no group. */ + async findUngroupedSubjectCustomIds(): Promise { + return this.expectJson( + this.request.get(`${API}/subjects/ungrouped/custom-ids`, { headers: this.authHeaders }), + 200, + 'find the custom ids of subjects in no group' + ); + } + /** Reads a user back as admin, to check what a write actually stored. */ async findUserById(id: string): Promise { return this.expectJson( From ec752b8a5b69fc12d97475ad786ba4eecacf8c8b Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 13:40:20 -0400 Subject: [PATCH 03/13] feat: update backend to allow admins to see groupless custom ids --- apps/api/src/subjects/subjects.controller.ts | 7 ++++++ apps/api/src/subjects/subjects.service.ts | 24 +++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/api/src/subjects/subjects.controller.ts b/apps/api/src/subjects/subjects.controller.ts index 12b6d1b63..f2a526451 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 of Subjects in No Group' }) + @Get('ungrouped/custom-ids') + @RouteAccess({ action: 'read', subject: 'Subject' }) + findUngroupedCustomIds(@CurrentUser('ability') ability: AppAbility) { + return this.subjectsService.findUngroupedCustomIds({ ability }); + } } diff --git a/apps/api/src/subjects/subjects.service.ts b/apps/api/src/subjects/subjects.service.ts index 8c8e1aed2..9117bceb9 100644 --- a/apps/api/src/subjects/subjects.service.ts +++ b/apps/api/src/subjects/subjects.service.ts @@ -9,6 +9,13 @@ import type { EntityOperationOptions } from '@/core/types'; import { CreateSubjectDto } from './dto/create-subject.dto'; +const PERSONAL_INFO_FIELDS = ['dateOfBirth', 'firstName', 'lastName', 'sex'] as const; + +/** 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,15 +183,20 @@ 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 } }, - { OR: personalInfoFields.flatMap((field) => [{ [field]: null }, { [field]: { isSet: false } }]) } - ] + AND: [accessibleQuery(ability, 'read', 'Subject'), { groupIds: { has: groupId } }, IDENTIFIED_BY_CUSTOM_ID] + } + }); + return subjects.map((subject) => subject.id); + } + + async findUngroupedCustomIds({ ability }: EntityOperationOptions = {}): Promise { + const subjects = await this.subjectModel.findMany({ + select: { id: true }, + where: { + AND: [accessibleQuery(ability, 'read', 'Subject'), { groupIds: { isEmpty: true } }, IDENTIFIED_BY_CUSTOM_ID] } }); return subjects.map((subject) => subject.id); From 86cd0956bf494b726ac7f5b39ec87ba981726b90 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 13:40:51 -0400 Subject: [PATCH 04/13] test: unit tests for groupless custom ids --- .../__tests__/subjects.service.spec.ts | 35 +++++++++++++++++++ .../useSubjectCustomIdsQuery.test.ts | 7 ++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.spec.ts index df87ca6b4..febf13b9a 100644 --- a/apps/api/src/subjects/__tests__/subjects.service.spec.ts +++ b/apps/api/src/subjects/__tests__/subjects.service.spec.ts @@ -252,6 +252,41 @@ describe('SubjectsService', () => { }); }); + describe('findUngroupedCustomIds', () => { + 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.findUngroupedCustomIds()).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 ungrouped subjects', async () => { + const ability = createAppAbility([ + { action: 'read', conditions: { groupIds: { hasSome: ['group-1'] } }, subject: 'Subject' } + ]); + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findUngroupedCustomIds({ ability }); + expect(findManyArgs().where.AND[0]).toStrictEqual(accessibleQuery(ability, 'read', 'Subject')); + }); + + it('should match only subjects that belong to no group', async () => { + subjectModel.findMany.mockResolvedValueOnce([]); + await subjectsService.findUngroupedCustomIds(); + expect(findManyArgs().where.AND).toContainEqual({ groupIds: { isEmpty: true } }); + }); + + 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.findUngroupedCustomIds(); + 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/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts b/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts index c52fa857b..82a33977c 100644 --- a/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts +++ b/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts @@ -23,9 +23,10 @@ describe('subjectCustomIdsQueryOptions', () => { 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 ungrouped 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/ungrouped/custom-ids'); }); it('should reject a response that is not a list of ids, so nothing unparsed reaches the form', async () => { From ed5a8923b988f08820adb86bd7bf36030a1c8fa2 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 14:32:49 -0400 Subject: [PATCH 05/13] feat: adjust custom id query to find default group --- apps/web/src/hooks/useSubjectCustomIdsQuery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useSubjectCustomIdsQuery.ts b/apps/web/src/hooks/useSubjectCustomIdsQuery.ts index 760a457c7..2969dd721 100644 --- a/apps/web/src/hooks/useSubjectCustomIdsQuery.ts +++ b/apps/web/src/hooks/useSubjectCustomIdsQuery.ts @@ -9,14 +9,14 @@ type SubjectCustomIdsQueryParams = { /** * Without a group, a session's custom ID is scoped to the default group, so the matching suggestions - * are the subjects that belong to no group rather than those of any particular one. + * 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 => { const url = params.groupId ? `/v1/subjects/groups/${params.groupId}/custom-ids` - : '/v1/subjects/ungrouped/custom-ids'; + : '/v1/subjects/default-group/custom-ids'; const response = await axios.get(url); return $SubjectCustomIds.parse(response.data); }, From 4afd9aee7c47209dab1ec4fc480370d1fd005573 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 14:33:22 -0400 Subject: [PATCH 06/13] feat: adjust backend to deal with default group subjects --- apps/api/src/subjects/subjects.controller.ts | 8 +++---- apps/api/src/subjects/subjects.service.ts | 24 ++++++++++++++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/api/src/subjects/subjects.controller.ts b/apps/api/src/subjects/subjects.controller.ts index f2a526451..e5286cb4d 100644 --- a/apps/api/src/subjects/subjects.controller.ts +++ b/apps/api/src/subjects/subjects.controller.ts @@ -59,10 +59,10 @@ export class SubjectsController { return this.subjectsService.findCustomIds(groupId, { ability }); } - @ApiOperation({ summary: 'Get the Custom IDs of Subjects in No Group' }) - @Get('ungrouped/custom-ids') + @ApiOperation({ summary: 'Get the Custom IDs Scoped to the Default Group' }) + @Get('default-group/custom-ids') @RouteAccess({ action: 'read', subject: 'Subject' }) - findUngroupedCustomIds(@CurrentUser('ability') ability: AppAbility) { - return this.subjectsService.findUngroupedCustomIds({ ability }); + 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 9117bceb9..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'; @@ -11,6 +13,15 @@ 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 } }]) @@ -192,11 +203,20 @@ export class SubjectsService { return subjects.map((subject) => subject.id); } - async findUngroupedCustomIds({ ability }: EntityOperationOptions = {}): Promise { + /** + * 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: { isEmpty: true } }, IDENTIFIED_BY_CUSTOM_ID] + AND: [ + accessibleQuery(ability, 'read', 'Subject'), + { id: idsWithPrefix(encodeScopedSubjectId('', { groupName: DEFAULT_GROUP_NAME })) }, + IDENTIFIED_BY_CUSTOM_ID + ] } }); return subjects.map((subject) => subject.id); From 3429883b2508ba3c51213e1e0aec6e0f87e72bb7 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 14:34:14 -0400 Subject: [PATCH 07/13] test: unit tests for custom id combobox and default group subjects --- .../subjects/__tests__/subjects.service.spec.ts | 17 +++++++++-------- .../__tests__/useSubjectCustomIdsQuery.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/api/src/subjects/__tests__/subjects.service.spec.ts b/apps/api/src/subjects/__tests__/subjects.service.spec.ts index febf13b9a..969d312e2 100644 --- a/apps/api/src/subjects/__tests__/subjects.service.spec.ts +++ b/apps/api/src/subjects/__tests__/subjects.service.spec.ts @@ -252,29 +252,30 @@ describe('SubjectsService', () => { }); }); - describe('findUngroupedCustomIds', () => { + 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.findUngroupedCustomIds()).resolves.toStrictEqual(['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 ungrouped subjects', async () => { + 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.findUngroupedCustomIds({ ability }); + await subjectsService.findDefaultGroupCustomIds({ ability }); expect(findManyArgs().where.AND[0]).toStrictEqual(accessibleQuery(ability, 'read', 'Subject')); }); - it('should match only subjects that belong to no group', async () => { + 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.findUngroupedCustomIds(); - expect(findManyArgs().where.AND).toContainEqual({ groupIds: { isEmpty: true } }); + 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 () => { @@ -282,7 +283,7 @@ describe('SubjectsService', () => { await subjectsService.findCustomIds('group-1'); const [, , groupedCustomIdClause] = findManyArgs().where.AND; subjectModel.findMany.mockResolvedValueOnce([]); - await subjectsService.findUngroupedCustomIds(); + await subjectsService.findDefaultGroupCustomIds(); expect(findManyArgs().where.AND).toContainEqual(groupedCustomIdClause); }); }); diff --git a/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts b/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts index 82a33977c..e2c7624f0 100644 --- a/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts +++ b/apps/web/src/hooks/__tests__/useSubjectCustomIdsQuery.test.ts @@ -23,10 +23,10 @@ describe('subjectCustomIdsQueryOptions', () => { expect(get).toHaveBeenCalledWith('/v1/subjects/groups/group-1/custom-ids'); }); - it('should request the ungrouped custom ids without a group, so an admin with no group is offered the subjects its sessions create', async () => { + 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/ungrouped/custom-ids'); + 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 () => { From 23b5f2079903dd4ee20dd4a0c6ce1f8f77b220c0 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 14:34:46 -0400 Subject: [PATCH 08/13] test: adjust e2e test for combobox to deal with deafult group users --- testing/src/specs/start-session.spec.ts | 39 ++++++++++++++++--------- testing/src/support/api-client.ts | 18 ++++++------ 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/testing/src/specs/start-session.spec.ts b/testing/src/specs/start-session.spec.ts index 1e945a56a..68860e657 100644 --- a/testing/src/specs/start-session.spec.ts +++ b/testing/src/specs/start-session.spec.ts @@ -61,34 +61,46 @@ test.describe('custom identifier suggestions', () => { expect(await response.json()).toStrictEqual([]); }); - test('should list custom-id subjects in no group, but not those in a group, when no group is given', async ({ + 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 ungroupedId = `root$ungrouped-${uniqueId}`; - const groupedId = `grouped-${uniqueId}`; - await api.createSession(null, { id: ungroupedId }); - await api.createSession((await api.createGroup()).id, { id: groupedId }); + 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.findUngroupedSubjectCustomIds(); + const ids = await api.findDefaultGroupSubjectCustomIds(); - expect(ids).toContain(ungroupedId); - expect(ids).not.toContain(groupedId); + expect(ids).toContain(rootScopedId); + expect(ids).not.toContain(groupScopedId); }); - test('should list no ungrouped subjects to a group manager, since they may read only their own groups', async ({ + 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$ungrouped-${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/ungrouped/custom-ids', { + const response = await apiRequestContext.get('/api/v1/subjects/default-group/custom-ids', { headers: { Authorization: `Bearer ${accessToken}` } }); @@ -177,15 +189,16 @@ test.describe('start session', () => { await expect(startSessionPage.successMessage).toBeVisible(); }); - test('should suggest subjects in no group to an admin who belongs to no group', async ({ + 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 = `ungrouped-${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'); diff --git a/testing/src/support/api-client.ts b/testing/src/support/api-client.ts index 9b6cf6710..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( @@ -152,15 +161,6 @@ export class ApiClient { ); } - /** The ids the start-session form suggests to a user with no group: custom-id subjects in no group. */ - async findUngroupedSubjectCustomIds(): Promise { - return this.expectJson( - this.request.get(`${API}/subjects/ungrouped/custom-ids`, { headers: this.authHeaders }), - 200, - 'find the custom ids of subjects in no group' - ); - } - /** Reads a user back as admin, to check what a write actually stored. */ async findUserById(id: string): Promise { return this.expectJson( From 5228aa8f1c3917545a27428a753cc2435a8dd970 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 16:37:18 -0400 Subject: [PATCH 09/13] feat: make it so that dob and sex are locked if custom subject already has one in session form --- .../StartSessionForm/StartSessionForm.tsx | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) 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 (
Date: Wed, 30 Sep 2026 16:37:50 -0400 Subject: [PATCH 10/13] feat: new query to get custom id subjects dob and sex --- .../src/hooks/useSubjectDemographicsLookup.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/web/src/hooks/useSubjectDemographicsLookup.ts 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] + ); +} From 5953c1b1bcecd852e785778d5d28b81c06ae7945 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 16:38:28 -0400 Subject: [PATCH 11/13] test: test file for new custom subject dob/sex query hook --- .../useSubjectDemographicsLookup.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/web/src/hooks/__tests__/useSubjectDemographicsLookup.test.ts 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'); + }); +}); From 0f9e67d468cb6dd1955eeae7bfee176836a8c9b1 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 16:38:57 -0400 Subject: [PATCH 12/13] feat: subjectdemographics subtype of subject schema --- packages/schemas/src/subject/subject.ts | 4 ++++ 1 file changed, 4 insertions(+) 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(); From 96d43cb92218b67517b9fdbd54dc6311953643d5 Mon Sep 17 00:00:00 2001 From: David Roper Date: Wed, 30 Sep 2026 16:39:24 -0400 Subject: [PATCH 13/13] test: unit and e2e tests for new behaviour --- .../src/__tests__/start-session-form.test.tsx | 95 +++++++++++++-- .../pages/_app/session/start-session.page.ts | 38 +++--- testing/src/specs/start-session.spec.ts | 108 +++++++++++++++++- 3 files changed, 218 insertions(+), 23 deletions(-) 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/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 68860e657..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'; @@ -184,7 +186,8 @@ 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(); }); @@ -303,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(); + }); +});