@@ -71,6 +71,25 @@ jest.mock('next/dynamic', () => ({
7171 return < MockDeleteProfile { ...props } /> ;
7272 }
7373
74+ // The clinic-wide controls previously fell through to `null`, which let the
75+ // whole point of the scope split regress without failing anything. They are
76+ // identifiable now so the composition tests below can assert placement.
77+ if ( source . includes ( 'Sections/AppointmentLockWindowPreference' ) ) {
78+ return < div > Appointment Lock Window Preference</ div > ;
79+ }
80+
81+ if ( source . includes ( 'Sections/CrossClinicMessagingPreference' ) ) {
82+ return < div > Cross Clinic Messaging Preference</ div > ;
83+ }
84+
85+ if ( source . includes ( 'Sections/YourOrganizations' ) ) {
86+ return < div > Your Organizations</ div > ;
87+ }
88+
89+ if ( source . includes ( 'Sections/FederationSection' ) ) {
90+ return < div > Federation Section</ div > ;
91+ }
92+
7493 return null ;
7594 } ;
7695
@@ -119,14 +138,102 @@ jest.mock('@/app/features/settings/pages/Settings/Sections/CompanionTerminologyP
119138 default : ( ) => < div > Companion Terminology</ div > ,
120139} ) ) ;
121140
141+ const mockHasPermission = jest . fn ( ( ) => true ) ;
142+ jest . mock ( '@/app/hooks/usePermissions' , ( ) => ( {
143+ __esModule : true ,
144+ useHasPermission : ( ) => mockHasPermission ( ) ,
145+ } ) ) ;
146+
147+ /**
148+ * Returns the labelled scope band (`<section aria-labelledby>`) that contains
149+ * the given control, so a test can assert WHERE a control sits rather than just
150+ * that it rendered somewhere on the page.
151+ */
152+ const bandContaining = ( text : string ) : HTMLElement | null =>
153+ screen . getByText ( text ) . closest ( 'section[aria-labelledby^="settings-band-"]' ) ;
154+
122155describe ( 'Settings page' , ( ) => {
123- it ( 'renders the header with the subtitle and auto-save indicator' , ( ) => {
156+ beforeEach ( ( ) => {
157+ mockHasPermission . mockReturnValue ( true ) ;
158+ } ) ;
159+
160+ it ( 'puts every per-user control in the Personal band' , ( ) => {
124161 render ( < Settings /> ) ;
125162
126- expect ( screen . getByRole ( 'heading' , { name : 'Settings' } ) ) . toBeInTheDocument ( ) ;
163+ for ( const control of [
164+ 'Personal Card' ,
165+ 'Timezone Preference' ,
166+ 'Default Open Screen Preference' ,
167+ 'Companion Terminology' ,
168+ 'Your Organizations' ,
169+ 'Delete Profile' ,
170+ // Device-scoped, but still the signed-in person's own surface.
171+ 'Appearance Preference' ,
172+ ] ) {
173+ expect ( bandContaining ( control ) ) . toHaveAttribute ( 'aria-labelledby' , 'settings-band-Personal' ) ;
174+ }
175+ } ) ;
176+
177+ it ( 'puts every clinic-wide control in the Organisation band' , ( ) => {
178+ render ( < Settings /> ) ;
179+
180+ for ( const control of [
181+ 'Appointment Lock Window Preference' ,
182+ 'Cross Clinic Messaging Preference' ,
183+ 'Federation Section' ,
184+ ] ) {
185+ expect ( bandContaining ( control ) ) . toHaveAttribute (
186+ 'aria-labelledby' ,
187+ 'settings-band-Organisation'
188+ ) ;
189+ }
190+ } ) ;
191+
192+ it ( 'keeps the device theme out of the account-scoped group' , ( ) => {
193+ render ( < Settings /> ) ;
194+
195+ // Appearance does not follow the account to another device, so it must not
196+ // sit under the group that promises "your account".
197+ const appearanceGroup = screen . getByText ( 'Appearance Preference' ) . closest ( 'section' ) ;
198+ expect ( appearanceGroup ) . toHaveTextContent ( 'This device' ) ;
199+ expect ( appearanceGroup ) . not . toHaveTextContent ( 'Only you' ) ;
200+ } ) ;
201+
202+ // Scoped to the GROUP, not the band. The organisation band mixes two gates -
203+ // scheduling goes through updateOrg (teams:edit:any), federation through
204+ // integrations:edit:any - so a Supervisor holds one and not the other. A
205+ // band-level verdict would be wrong for exactly that role.
206+ it ( 'marks the scheduling group read-only when the member cannot edit it' , ( ) => {
207+ mockHasPermission . mockReturnValue ( false ) ;
208+ render ( < Settings /> ) ;
209+
210+ const group = screen . getByText ( 'Appointment Lock Window Preference' ) . closest ( 'section' ) ;
211+ expect ( group ) . toHaveTextContent ( / M a n a g e d b y a c l i n i c a d m i n i s t r a t o r / ) ;
212+ } ) ;
213+
214+ it ( 'does not mark the scheduling group read-only for a member who can edit it' , ( ) => {
215+ mockHasPermission . mockReturnValue ( true ) ;
216+ render ( < Settings /> ) ;
217+
218+ const group = screen . getByText ( 'Appointment Lock Window Preference' ) . closest ( 'section' ) ;
219+ expect ( group ) . not . toHaveTextContent ( / M a n a g e d b y a c l i n i c a d m i n i s t r a t o r / ) ;
220+ } ) ;
221+
222+ it ( 'keeps the organisation band description permission-neutral' , ( ) => {
223+ mockHasPermission . mockReturnValue ( false ) ;
224+ render ( < Settings /> ) ;
225+
226+ // The band must not claim a single verdict for controls behind two gates.
127227 expect (
128- screen . getByText ( 'Your preferences, and the clinic settings you administer ' )
228+ screen . getByText ( 'Shared clinic settings. Changes here apply to every colleague. ' )
129229 ) . toBeInTheDocument ( ) ;
230+ } ) ;
231+
232+ it ( 'renders the header with the subtitle and auto-save indicator' , ( ) => {
233+ render ( < Settings /> ) ;
234+
235+ expect ( screen . getByRole ( 'heading' , { name : 'Settings' } ) ) . toBeInTheDocument ( ) ;
236+ expect ( screen . getByText ( 'Your preferences and clinic settings' ) ) . toBeInTheDocument ( ) ;
130237 expect ( screen . getByText ( 'Changes save automatically' ) ) . toBeInTheDocument ( ) ;
131238 } ) ;
132239
0 commit comments