@faststore/core 4.8.1-dev.1 → 4.8.1-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +129 -124
  3. package/@generated/gql.ts +3 -3
  4. package/@generated/graphql.ts +4 -2
  5. package/@generated/persisted-documents.json +1 -1
  6. package/@generated/schema.graphql +4 -0
  7. package/CHANGELOG.md +14 -0
  8. package/package.json +6 -6
  9. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +4 -6
  10. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +18 -3
  11. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +1 -0
  12. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +7 -0
  13. package/src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton.tsx +7 -9
  14. package/src/components/account/Menu/Menu.tsx +24 -7
  15. package/src/components/navigation/Navbar/Navbar.tsx +17 -19
  16. package/src/components/navigation/Navbar/NavbarAccountArea.tsx +57 -0
  17. package/src/components/navigation/NavbarSlider/NavbarSlider.tsx +16 -18
  18. package/src/components/templates/LandingPage/LandingPage.tsx +1 -1
  19. package/src/pages/[slug]/p.tsx +3 -1
  20. package/src/pages/api/fs/switch-contract.ts +194 -0
  21. package/src/pages/index.tsx +2 -1
  22. package/src/pages/s.tsx +9 -3
  23. package/src/sdk/account/changeContractToken.ts +19 -63
  24. package/src/sdk/account/clearPersistedSessionState.ts +13 -7
  25. package/src/sdk/account/useAvailableContracts.ts +1 -0
  26. package/src/sdk/account/useSwitchContract.ts +11 -3
  27. package/src/sdk/session/index.ts +2 -1
  28. package/src/sdk/session/isSignInAreaResolved.ts +12 -0
  29. package/test/components/account/ContractSwitcher.browser.test.tsx +42 -0
  30. package/test/components/account/Menu.browser.test.tsx +123 -0
  31. package/test/components/account/OrganizationDrawer.browser.test.tsx +148 -0
  32. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +2 -0
  33. package/test/components/account/OrganizationSignInButton.browser.test.tsx +49 -0
  34. package/test/components/navigation/Navbar.browser.test.tsx +183 -0
  35. package/test/components/navigation/NavbarAccountArea.browser.test.tsx +115 -0
  36. package/test/components/navigation/NavbarSlider.browser.test.tsx +143 -0
  37. package/test/pages/api/switch-contract.test.ts +385 -0
  38. package/test/sdk/account/changeContractToken.browser.test.ts +23 -71
  39. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +24 -0
  40. package/test/sdk/account/useSwitchContract.browser.test.ts +21 -0
  41. package/test/sdk/session/isSignInAreaResolved.test.ts +22 -0
@@ -1,7 +1,5 @@
1
1
  import fetch from 'isomorphic-unfetch'
2
2
 
3
- import storeConfig from '../../../discovery.config'
4
-
5
3
  export class ContractSwitchError extends Error {
6
4
  constructor(message: string) {
7
5
  super(message)
@@ -11,51 +9,20 @@ export class ContractSwitchError extends Error {
11
9
 
12
10
  export const isContractSwitchEnabled = true
13
11
 
14
- type VtexAuthCookie = {
15
- Name: string
16
- Value: string
17
- }
18
-
19
- export type SwitchPropertiesResponse = {
20
- authStatus?: string
21
- expiresIn?: number
22
- authCookie?: VtexAuthCookie | null
23
- accountAuthCookie?: VtexAuthCookie | null
24
- }
25
-
26
- const DEFAULT_AUTH_COOKIE_MAX_AGE = 86_400
27
- const SWITCH_PROPERTIES_TIMEOUT_MS = 30_000
28
-
29
- export function applyVtexAuthCookieFromSwitchResponse(
30
- response: SwitchPropertiesResponse
31
- ): void {
32
- if (globalThis.window === undefined) {
33
- return
34
- }
35
-
36
- const maxAge = response.expiresIn ?? DEFAULT_AUTH_COOKIE_MAX_AGE
37
- const secure =
38
- globalThis.window.location.protocol === 'https:' ? '; secure' : ''
39
-
40
- const cookies = [response.authCookie, response.accountAuthCookie].filter(
41
- (cookie): cookie is VtexAuthCookie =>
42
- Boolean(cookie?.Name?.trim() && cookie?.Value)
43
- )
44
-
45
- for (const cookie of cookies) {
46
- document.cookie = `${cookie.Name}=${cookie.Value}; path=/; max-age=${maxAge}; samesite=lax${secure}`
47
- }
48
- }
12
+ const SWITCH_CONTRACT_TIMEOUT_MS = 30_000
49
13
 
50
14
  /**
51
- * Switches the buyer's active contract via VTEX Identity storefront credential API.
15
+ * Switches the buyer's active contract through `/api/fs/switch-contract`.
52
16
  *
53
- * POST /api/authenticator/storefront/credential/switch-properties?an={account}
54
- * Body: { properties: { customerId: contractId } }
17
+ * The route relays the call to VTEX Identity and answers with a single
18
+ * `Set-Cookie` batch: the new auth cookie plus the expiry of the previous
19
+ * contract's checkout orderForm cookies, which are `HttpOnly` and therefore
20
+ * unreachable from here. Because both travel in the same response, there is no
21
+ * window where the browser holds the new credential alongside the old
22
+ * orderForm (B2BTEAM-3827).
55
23
  *
56
- * Applies the returned `authCookie` (and optional `accountAuthCookie`) so the
57
- * storefront JWT reflects the new commercial context. Throws `ContractSwitchError`
58
- * on hard failures so `switchContract` can keep the previous contract active.
24
+ * Throws `ContractSwitchError` on any failure so `switchContract` can keep the
25
+ * previous contract active.
59
26
  */
60
27
  export async function changeContractToken(
61
28
  contractId: string
@@ -64,23 +31,16 @@ export async function changeContractToken(
64
31
  throw new ContractSwitchError('Missing contractId for contract switch')
65
32
  }
66
33
 
67
- const an = encodeURIComponent(storeConfig.api.storeId)
68
- const url = `/api/authenticator/storefront/credential/switch-properties?an=${an}`
69
-
70
34
  let response: Response
71
35
  try {
72
- response = await fetch(url, {
36
+ response = await fetch('/api/fs/switch-contract', {
73
37
  method: 'POST',
74
38
  credentials: 'include',
75
39
  headers: {
76
40
  'Content-Type': 'application/json',
77
41
  },
78
- body: JSON.stringify({
79
- properties: {
80
- customerId: contractId,
81
- },
82
- }),
83
- signal: AbortSignal.timeout(SWITCH_PROPERTIES_TIMEOUT_MS),
42
+ body: JSON.stringify({ contractId }),
43
+ signal: AbortSignal.timeout(SWITCH_CONTRACT_TIMEOUT_MS),
84
44
  })
85
45
  } catch (error) {
86
46
  if (
@@ -101,19 +61,15 @@ export async function changeContractToken(
101
61
  )
102
62
  }
103
63
 
104
- const payload = (await response.json()) as SwitchPropertiesResponse
64
+ // Reading the body to completion guarantees the `Set-Cookie` headers are
65
+ // committed before `switchContract` reloads the page.
66
+ const payload = (await response.json().catch(() => null)) as {
67
+ success?: boolean
68
+ } | null
105
69
 
106
- if (payload.authStatus?.toLowerCase() !== 'success') {
70
+ if (!payload?.success) {
107
71
  throw new ContractSwitchError('Contract switch was not successful')
108
72
  }
109
73
 
110
- if (!payload.authCookie?.Name || !payload.authCookie?.Value) {
111
- throw new ContractSwitchError(
112
- 'Contract switch succeeded but no auth cookie was returned'
113
- )
114
- }
115
-
116
- applyVtexAuthCookieFromSwitchResponse(payload)
117
-
118
74
  return true
119
75
  }
@@ -4,20 +4,26 @@ import {
4
4
  STORAGE_KEY_PERSON_ID,
5
5
  } from 'src/utils/cookieCacheBusting'
6
6
 
7
+ /** Persisted `@faststore/sdk` stores that belong to the previous commercial context. */
8
+ export const PERSISTED_STORE_KEYS = ['fs::session', 'fs::cart'] as const
9
+
7
10
  /**
8
- * Clears client-side session persistence so a hard reload rehydrates from
9
- * validateSession instead of stale IndexedDB / sessionStorage values.
11
+ * Clears client-side persistence of the previous commercial context so a hard
12
+ * reload rehydrates from validateSession / validateCart under the new
13
+ * contract.
14
+ *
15
+ * The checkout orderForm cookies are `HttpOnly` and are expired by
16
+ * `/api/fs/switch-contract` instead (B2BTEAM-3827).
10
17
  */
11
18
  export async function clearPersistedSessionState(): Promise<void> {
12
19
  try {
13
20
  const { del } = await import('idb-keyval')
14
- await del('fs::session').catch(() => {})
21
+ await Promise.all(
22
+ PERSISTED_STORE_KEYS.map((key) => del(key).catch(() => {}))
23
+ )
15
24
  } catch {}
16
25
 
17
- if (typeof sessionStorage === 'undefined') {
18
- return
19
- }
20
-
26
+ if (typeof sessionStorage === 'undefined') return
21
27
  try {
22
28
  sessionStorage.removeItem(SESSION_READY_KEY)
23
29
  sessionStorage.removeItem(STORAGE_KEY_PERSON_ID)
@@ -13,6 +13,7 @@ export const AvailableContractsQuery = gql(`
13
13
  id
14
14
  corporateName
15
15
  isActive
16
+ isDefault
16
17
  }
17
18
  }
18
19
  `)
@@ -11,8 +11,11 @@ import {
11
11
  * Orchestrates a full change of commercial context (REQ-06).
12
12
  *
13
13
  * Steps:
14
- * 1. POST switch-properties with the target contract id and apply the returned auth cookie.
15
- * 2. Clear persisted session state and reload the page.
14
+ * 1. POST `/api/fs/switch-contract`, which swaps the auth cookie and expires
15
+ * the previous contract's checkout orderForm in one response. A failure
16
+ * here changes nothing, so the previous contract stays active.
17
+ * 2. Clear persisted session and cart state and reload the page so
18
+ * validateSession/validateCart run under the new contract.
16
19
  */
17
20
  export const useSwitchContract = () => {
18
21
  const [loading, setLoading] = useState(false)
@@ -31,7 +34,12 @@ export const useSwitchContract = () => {
31
34
  return false
32
35
  }
33
36
 
34
- await clearPersistedSessionState()
37
+ // The cookies were already swapped, so the reload has to happen even if
38
+ // clearing the local stores fails: from here on the server session is
39
+ // the authoritative source and validateCart rebuilds the cart.
40
+ try {
41
+ await clearPersistedSessionState()
42
+ } catch {}
35
43
 
36
44
  if (globalThis.window !== undefined) {
37
45
  globalThis.window.location.reload()
@@ -282,8 +282,9 @@ export const useSession = ({ filter }: SessionOptions = { filter: true }) => {
282
282
  channel,
283
283
  isValidating,
284
284
  isSessionReady,
285
+ hasValidated,
285
286
  }),
286
- [isValidating, session, channel, isSessionReady]
287
+ [isValidating, session, channel, isSessionReady, hasValidated]
287
288
  )
288
289
  }
289
290
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Navbar account area must not render "Sign in" for a B2B buyer whose
3
+ * session has not been validated yet on this page load (Design QA 26/Aug
4
+ * 00:02:55–00:03:52). `isSessionReady` alone is pre-seeded from sessionStorage.
5
+ */
6
+ export const isSignInAreaResolved = ({
7
+ isSessionReady,
8
+ hasValidated,
9
+ }: {
10
+ isSessionReady: boolean
11
+ hasValidated: boolean
12
+ }): boolean => isSessionReady && hasValidated
@@ -2,6 +2,7 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
 
5
+ import '@testing-library/jest-dom/vitest'
5
6
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
6
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
8
 
@@ -117,6 +118,47 @@ describe('ContractSwitcher', () => {
117
118
  expect(options.length).toBe(2)
118
119
  })
119
120
 
121
+ it('marks the default contract with a star and lists it first (Design QA 00:02:00)', async () => {
122
+ // Note: id 'a' is pinned to the current session (this file mocks
123
+ // useSession with b2b.customerId: 'a'), so it must stay on the active
124
+ // contract for it to resolve as the current one; Alpha (the default)
125
+ // and Aardvark get other ids and land in the alternatives list, which
126
+ // is what this test exercises.
127
+ mockUseAvailableContracts.mockReturnValue({
128
+ contracts: [
129
+ { id: 'a', corporateName: 'Beta', isActive: true, isDefault: false },
130
+ { id: 'b', corporateName: 'Alpha', isActive: false, isDefault: true },
131
+ {
132
+ id: 'c',
133
+ corporateName: 'Aardvark',
134
+ isActive: false,
135
+ isDefault: false,
136
+ },
137
+ ],
138
+ loading: false,
139
+ error: null,
140
+ })
141
+
142
+ const { container } = render(
143
+ <ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />
144
+ )
145
+
146
+ await screen.findByText('Alpha')
147
+ expect(
148
+ container.querySelectorAll('[data-fs-contract-switcher-default]')
149
+ ).toHaveLength(1)
150
+ expect(
151
+ container.querySelector('[data-fs-contract-switcher-default]')
152
+ ).toHaveAttribute('aria-label', 'Default contract')
153
+
154
+ const names = Array.from(
155
+ container.querySelectorAll(
156
+ '[data-fs-contract-switcher-option] [data-fs-contract-switcher-option-name]'
157
+ )
158
+ ).map((el) => el.textContent)
159
+ expect(names[0]).toBe('Alpha') // default first, then alphabetical ('Aardvark')
160
+ })
161
+
120
162
  it('requires selecting a contract before Confirm is enabled, then switches (REQ-03)', async () => {
121
163
  render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
122
164
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import '@testing-library/jest-dom/vitest'
6
+ import { act, fireEvent, render, screen } from '@testing-library/react'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ const mockUseSession = vi.hoisted(() => vi.fn())
10
+ const mockUseScreenResize = vi.hoisted(() => vi.fn())
11
+ const mockOrganizationDrawer = vi.hoisted(() => vi.fn())
12
+
13
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
14
+
15
+ vi.mock('src/sdk/account/accountPageContext', () => ({
16
+ useAccountNavigationLabels: () => ({ switchLabel: 'Switch' }),
17
+ }))
18
+
19
+ vi.mock('src/sdk/ui/useScreenResize', () => ({
20
+ default: mockUseScreenResize,
21
+ }))
22
+
23
+ vi.mock('next/router', () => ({
24
+ useRouter: () => ({ pathname: '/pvt/account' }),
25
+ }))
26
+
27
+ vi.mock(
28
+ '../../../src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer',
29
+ () => ({
30
+ OrganizationDrawer: (props: Record<string, unknown>) => {
31
+ mockOrganizationDrawer(props)
32
+ return <div data-testid="organization-drawer" />
33
+ },
34
+ })
35
+ )
36
+
37
+ import Menu from '../../../src/components/account/Menu/Menu'
38
+
39
+ const items = [{ title: 'Orders', route: '/pvt/account/orders' }]
40
+
41
+ describe('Menu', () => {
42
+ beforeEach(() => {
43
+ mockUseScreenResize.mockReturnValue({ isDesktop: true })
44
+ })
45
+
46
+ afterEach(() => {
47
+ vi.clearAllMocks()
48
+ })
49
+
50
+ it('does not render the Switch button without an organization unit', () => {
51
+ mockUseSession.mockReturnValue({ b2b: {} })
52
+
53
+ render(<Menu accountName="Jane" items={items} />)
54
+
55
+ expect(screen.queryByRole('button', { name: 'Switch' })).toBeNull()
56
+ })
57
+
58
+ it('renders the Switch button when the buyer has an organization unit', () => {
59
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
60
+
61
+ render(<Menu accountName="Jane" items={items} />)
62
+
63
+ expect(screen.getByRole('button', { name: 'Switch' })).toBeTruthy()
64
+ })
65
+
66
+ it('mounts the OrganizationDrawer with initialView "switch" when Switch is clicked', () => {
67
+ mockUseSession.mockReturnValue({
68
+ b2b: { unitId: 'unit-1', isRepresentative: true },
69
+ })
70
+
71
+ render(<Menu accountName="Jane" items={items} />)
72
+
73
+ expect(screen.queryByTestId('organization-drawer')).toBeNull()
74
+
75
+ fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
76
+
77
+ expect(screen.getByTestId('organization-drawer')).toBeTruthy()
78
+ expect(mockOrganizationDrawer).toHaveBeenCalledWith(
79
+ expect.objectContaining({
80
+ isOpen: true,
81
+ initialView: 'switch',
82
+ isRepresentative: true,
83
+ })
84
+ )
85
+ })
86
+
87
+ it('passes isRepresentative as a boolean even when b2b.isRepresentative is undefined', () => {
88
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
89
+
90
+ render(<Menu accountName="Jane" items={items} />)
91
+ fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
92
+
93
+ expect(mockOrganizationDrawer).toHaveBeenCalledWith(
94
+ expect.objectContaining({ isRepresentative: false })
95
+ )
96
+ })
97
+
98
+ it('unmounts the OrganizationDrawer when closeDrawer is invoked', () => {
99
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
100
+
101
+ render(<Menu accountName="Jane" items={items} />)
102
+ fireEvent.click(screen.getByRole('button', { name: 'Switch' }))
103
+ expect(screen.getByTestId('organization-drawer')).toBeTruthy()
104
+
105
+ const closeDrawer = mockOrganizationDrawer.mock.calls[0][0]
106
+ .closeDrawer as () => void
107
+ act(() => {
108
+ closeDrawer()
109
+ })
110
+
111
+ expect(screen.queryByTestId('organization-drawer')).toBeNull()
112
+ })
113
+
114
+ it('renders only the nav list on mobile (no account/switch area)', () => {
115
+ mockUseScreenResize.mockReturnValue({ isDesktop: false })
116
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
117
+
118
+ render(<Menu accountName="Jane" items={items} />)
119
+
120
+ expect(screen.queryByRole('button', { name: 'Switch' })).toBeNull()
121
+ expect(screen.getByText('Orders')).toBeTruthy()
122
+ })
123
+ })
@@ -2,6 +2,7 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
 
5
+ import '@testing-library/jest-dom/vitest'
5
6
  import { fireEvent, render, screen } from '@testing-library/react'
6
7
  import type { ReactNode } from 'react'
7
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
10
  const mockFadeOut = vi.hoisted(() => vi.fn())
10
11
  const mockUseSession = vi.hoisted(() => vi.fn())
11
12
  const mockCloseDrawer = vi.hoisted(() => vi.fn())
13
+ const mockUseAvailableContracts = vi.hoisted(() => vi.fn())
12
14
 
13
15
  vi.mock('@faststore/ui', async (importOriginal) => {
14
16
  const actual = await importOriginal<typeof import('@faststore/ui')>()
@@ -38,6 +40,10 @@ vi.mock('@faststore/ui', async (importOriginal) => {
38
40
 
39
41
  vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
40
42
 
43
+ vi.mock('src/sdk/account/useAvailableContracts', () => ({
44
+ useAvailableContracts: mockUseAvailableContracts,
45
+ }))
46
+
41
47
  vi.mock(
42
48
  '../../../src/components/account/Drawer/OrganizationDrawer/ContractSwitcher',
43
49
  () => ({
@@ -82,6 +88,11 @@ describe('OrganizationDrawer', () => {
82
88
  },
83
89
  person: { givenName: 'Jane' },
84
90
  })
91
+ mockUseAvailableContracts.mockReturnValue({
92
+ contracts: [],
93
+ loading: false,
94
+ error: null,
95
+ })
85
96
  })
86
97
 
87
98
  afterEach(() => {
@@ -194,4 +205,141 @@ describe('OrganizationDrawer', () => {
194
205
  screen.getByRole('heading', { name: 'Organization', level: 1 })
195
206
  ).toBeTruthy()
196
207
  })
208
+
209
+ it('opens directly in the switcher when initialView is "switch"', () => {
210
+ render(
211
+ <OrganizationDrawer
212
+ isOpen
213
+ closeDrawer={vi.fn()}
214
+ isRepresentative
215
+ initialView="switch"
216
+ />
217
+ )
218
+ expect(screen.getByTestId('contract-switcher')).toBeInTheDocument()
219
+ })
220
+
221
+ it('shows the default-contract star in the header when the active contract is the unit default', () => {
222
+ mockUseAvailableContracts.mockReturnValue({
223
+ contracts: [
224
+ {
225
+ id: 'unit-1',
226
+ corporateName: 'Acme Contract',
227
+ isActive: true,
228
+ isDefault: true,
229
+ },
230
+ {
231
+ id: 'other',
232
+ corporateName: 'Other Contract',
233
+ isActive: false,
234
+ isDefault: false,
235
+ },
236
+ ],
237
+ loading: false,
238
+ error: null,
239
+ })
240
+ mockUseSession.mockReturnValue({
241
+ b2b: {
242
+ unitId: 'unit-1',
243
+ unitName: 'Stellar Global',
244
+ contractName: 'Acme Contract',
245
+ customerId: 'unit-1',
246
+ userName: 'Jane Buyer',
247
+ userEmail: 'jane@example.com',
248
+ organizationManager: true,
249
+ },
250
+ person: { givenName: 'Jane' },
251
+ })
252
+
253
+ render(
254
+ <OrganizationDrawer
255
+ isOpen
256
+ closeDrawer={mockCloseDrawer}
257
+ isRepresentative={false}
258
+ />
259
+ )
260
+
261
+ expect(screen.getByLabelText('Default contract')).toBeTruthy()
262
+ })
263
+
264
+ it('does not show the default-contract star when the active contract is not the unit default', () => {
265
+ mockUseAvailableContracts.mockReturnValue({
266
+ contracts: [
267
+ {
268
+ id: 'unit-1',
269
+ corporateName: 'Acme Contract',
270
+ isActive: true,
271
+ isDefault: false,
272
+ },
273
+ {
274
+ id: 'other',
275
+ corporateName: 'Other Contract',
276
+ isActive: false,
277
+ isDefault: true,
278
+ },
279
+ ],
280
+ loading: false,
281
+ error: null,
282
+ })
283
+ mockUseSession.mockReturnValue({
284
+ b2b: {
285
+ unitId: 'unit-1',
286
+ unitName: 'Stellar Global',
287
+ contractName: 'Acme Contract',
288
+ customerId: 'unit-1',
289
+ userName: 'Jane Buyer',
290
+ userEmail: 'jane@example.com',
291
+ organizationManager: true,
292
+ },
293
+ person: { givenName: 'Jane' },
294
+ })
295
+
296
+ render(
297
+ <OrganizationDrawer
298
+ isOpen
299
+ closeDrawer={mockCloseDrawer}
300
+ isRepresentative={false}
301
+ />
302
+ )
303
+
304
+ expect(screen.queryByLabelText('Default contract')).toBeNull()
305
+ })
306
+
307
+ it('fetches the contract list only while open for an org member', () => {
308
+ const { rerender } = render(
309
+ <OrganizationDrawer
310
+ isOpen={false}
311
+ closeDrawer={mockCloseDrawer}
312
+ isRepresentative={false}
313
+ />
314
+ )
315
+
316
+ expect(mockUseAvailableContracts).toHaveBeenLastCalledWith(false)
317
+
318
+ rerender(
319
+ <OrganizationDrawer
320
+ isOpen
321
+ closeDrawer={mockCloseDrawer}
322
+ isRepresentative={false}
323
+ />
324
+ )
325
+
326
+ expect(mockUseAvailableContracts).toHaveBeenLastCalledWith(true)
327
+ })
328
+
329
+ it('never fetches the contract list when the buyer has no organization unit', () => {
330
+ mockUseSession.mockReturnValue({
331
+ b2b: { userName: 'Jane Buyer' },
332
+ person: null,
333
+ })
334
+
335
+ render(
336
+ <OrganizationDrawer
337
+ isOpen
338
+ closeDrawer={mockCloseDrawer}
339
+ isRepresentative={false}
340
+ />
341
+ )
342
+
343
+ expect(mockUseAvailableContracts).toHaveBeenLastCalledWith(false)
344
+ })
197
345
  })
@@ -36,6 +36,8 @@ describe('OrganizationDrawerHeader', () => {
36
36
  />
37
37
  )
38
38
 
39
+ expect(screen.getByLabelText('Default contract')).toBeTruthy()
40
+
39
41
  fireEvent.click(screen.getByRole('button', { name: /change/i }))
40
42
  expect(onChangeContract).toHaveBeenCalledTimes(1)
41
43
  })
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import '@testing-library/jest-dom/vitest'
5
+ import { render, screen } from '@testing-library/react'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockUseSession = vi.hoisted(() => vi.fn())
9
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
10
+ vi.mock('src/sdk/account/accountPageContext', () => ({
11
+ useAccountNavigationLabels: () => ({
12
+ companyLabel: 'Company',
13
+ contractLabel: 'Contract',
14
+ }),
15
+ }))
16
+ vi.mock(
17
+ 'src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer',
18
+ () => ({
19
+ OrganizationDrawer: () => null,
20
+ })
21
+ )
22
+
23
+ import { OrganizationSignInButton } from '../../../src/components/account/Drawer/OrganizationSignInButton/OrganizationSignInButton'
24
+
25
+ const icon = { alt: 'account', icon: 'User' }
26
+
27
+ describe('OrganizationSignInButton', () => {
28
+ it('shows the active contract name when the session has one (Design QA 00:02:55)', () => {
29
+ mockUseSession.mockReturnValue({
30
+ person: { id: 'p1' },
31
+ b2b: { contractName: ' Caetano Corp ' },
32
+ })
33
+ render(<OrganizationSignInButton icon={icon} isRepresentative />)
34
+ expect(screen.getByRole('button', { name: 'account' })).toHaveTextContent(
35
+ 'Caetano Corp'
36
+ )
37
+ })
38
+
39
+ it('falls back to the company label without a contract name', () => {
40
+ mockUseSession.mockReturnValue({
41
+ person: { id: 'p1' },
42
+ b2b: { contractName: '' },
43
+ })
44
+ render(<OrganizationSignInButton icon={icon} isRepresentative />)
45
+ expect(screen.getByRole('button', { name: 'account' })).toHaveTextContent(
46
+ 'Company'
47
+ )
48
+ })
49
+ })