@faststore/core 4.4.1-dev.0 → 4.5.0-dev.1

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 (89) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +137 -51
  3. package/@generated/cached-operations.json +4 -0
  4. package/@generated/gql.ts +30 -0
  5. package/@generated/graphql.ts +141 -0
  6. package/@generated/persisted-documents.json +5 -0
  7. package/@generated/schema.graphql +100 -0
  8. package/CHANGELOG.md +26 -0
  9. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
  10. package/cms/faststore/schema.json +89 -0
  11. package/discovery.config.default.js +3 -0
  12. package/discovery.config.js +10 -1
  13. package/discovery.config.local.example.js +62 -0
  14. package/package.json +6 -6
  15. package/public/icons.svg +4 -0
  16. package/src/Layout.tsx +6 -0
  17. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
  18. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  22. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  23. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  24. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  25. package/src/components/account/Layout/Layout.tsx +14 -5
  26. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
  27. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
  28. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
  29. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
  30. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
  31. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
  32. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
  33. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
  34. package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
  35. package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
  36. package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
  37. package/src/components/cms/global/Components.ts +10 -0
  38. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  39. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  40. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  41. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  42. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  43. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  44. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  45. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  46. package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
  47. package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
  48. package/src/pages/[slug]/p.tsx +45 -0
  49. package/src/pages/pvt/account/quotes/index.tsx +264 -0
  50. package/src/sdk/account/changeContractToken.ts +119 -0
  51. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  52. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  53. package/src/sdk/account/refreshToken.ts +21 -8
  54. package/src/sdk/account/useAvailableContracts.ts +49 -0
  55. package/src/sdk/account/useSwitchContract.ts +60 -0
  56. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  57. package/src/sdk/analytics/utils.ts +9 -0
  58. package/src/utils/quoteStatus.ts +27 -0
  59. package/src/utils/retry.ts +69 -0
  60. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  61. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  62. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  63. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  64. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  65. package/test/components/account/Layout.test.tsx +62 -0
  66. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  67. package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
  68. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  69. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  70. package/test/components/account/doLogout.browser.test.ts +102 -0
  71. package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
  72. package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
  73. package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
  74. package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
  75. package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
  76. package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
  77. package/test/components/quotes/quoteFilters.test.ts +185 -0
  78. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  79. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  80. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  81. package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
  82. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  83. package/test/sdk/account/refreshToken.test.ts +87 -0
  84. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  85. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  86. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  87. package/test/sdk/analytics/utils.test.ts +23 -0
  88. package/test/server/index.test.ts +5 -0
  89. package/test/utils/retry.test.ts +59 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import React from 'react'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ vi.mock('@faststore/ui', async () => {
10
+ const actual =
11
+ await vi.importActual<typeof import('@faststore/ui')>('@faststore/ui')
12
+ return {
13
+ ...actual,
14
+ FilterSlider: ({
15
+ children,
16
+ applyBtnProps,
17
+ clearBtnProps,
18
+ }: React.PropsWithChildren<{
19
+ applyBtnProps?: { onClick?: () => void; children?: React.ReactNode }
20
+ clearBtnProps?: { onClick?: () => void; children?: React.ReactNode }
21
+ }>) => (
22
+ <div>
23
+ {children}
24
+ <button onClick={applyBtnProps?.onClick}>
25
+ {applyBtnProps?.children}
26
+ </button>
27
+ <button onClick={clearBtnProps?.onClick}>
28
+ {clearBtnProps?.children}
29
+ </button>
30
+ </div>
31
+ ),
32
+ }
33
+ })
34
+
35
+ import MyAccountQuotesFilterSlider from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider'
36
+ import { getAllFacets } from '../../../src/components/account/quotes/MyAccountListQuotes/quoteFilters'
37
+
38
+ const originalLocation = window.location
39
+
40
+ function stubLocation(search = '') {
41
+ Object.defineProperty(window, 'location', {
42
+ configurable: true,
43
+ value: { ...originalLocation, search, href: '' },
44
+ })
45
+ }
46
+
47
+ function baseProps(overrides: Record<string, unknown> = {}) {
48
+ return {
49
+ facets: getAllFacets({
50
+ page: 1,
51
+ status: [],
52
+ createdAtFrom: '',
53
+ createdAtTo: '',
54
+ expiresAtFrom: '',
55
+ expiresAtTo: '',
56
+ label: '',
57
+ }),
58
+ testId: 'test-filter-slider',
59
+ dispatch: vi.fn(),
60
+ expanded: false,
61
+ selected: [],
62
+ title: 'Filters',
63
+ clearButtonLabel: 'Clear All',
64
+ applyButtonLabel: 'View Results',
65
+ ...overrides,
66
+ }
67
+ }
68
+
69
+ describe('MyAccountQuotesFilterSlider', () => {
70
+ beforeEach(() => {
71
+ stubLocation()
72
+ })
73
+
74
+ afterEach(() => {
75
+ vi.clearAllMocks()
76
+ Object.defineProperty(window, 'location', {
77
+ configurable: true,
78
+ value: originalLocation,
79
+ })
80
+ })
81
+
82
+ it('navigates with the selected status params on apply', () => {
83
+ render(
84
+ <MyAccountQuotesFilterSlider
85
+ {...(baseProps({
86
+ selected: [{ key: 'status', value: 'Draft' }],
87
+ }) as any)}
88
+ />
89
+ )
90
+
91
+ fireEvent.click(screen.getByText('View Results'))
92
+
93
+ expect(window.location.href).toBe('/pvt/account/quotes?status=Draft')
94
+ })
95
+
96
+ it('navigates with the created date range params on apply', () => {
97
+ render(<MyAccountQuotesFilterSlider {...(baseProps() as any)} />)
98
+
99
+ fireEvent.change(screen.getAllByLabelText('From')[0], {
100
+ target: { value: '2026-01-01' },
101
+ })
102
+ fireEvent.change(screen.getAllByLabelText('To')[0], {
103
+ target: { value: '2026-01-31' },
104
+ })
105
+
106
+ fireEvent.click(screen.getByText('View Results'))
107
+
108
+ expect(window.location.href).toBe(
109
+ '/pvt/account/quotes?createdAtFrom=2026-01-01&createdAtTo=2026-01-31'
110
+ )
111
+ })
112
+
113
+ it('preserves an existing label param when applying filters', () => {
114
+ stubLocation('?label=hello')
115
+
116
+ render(
117
+ <MyAccountQuotesFilterSlider
118
+ {...(baseProps({
119
+ selected: [{ key: 'status', value: 'Draft' }],
120
+ }) as any)}
121
+ />
122
+ )
123
+
124
+ fireEvent.click(screen.getByText('View Results'))
125
+
126
+ expect(window.location.href).toBe(
127
+ '/pvt/account/quotes?label=hello&status=Draft'
128
+ )
129
+ })
130
+
131
+ it('navigates with no query string when no filters are applied', () => {
132
+ render(<MyAccountQuotesFilterSlider {...(baseProps() as any)} />)
133
+
134
+ fireEvent.click(screen.getByText('View Results'))
135
+
136
+ expect(window.location.href).toBe('/pvt/account/quotes')
137
+ })
138
+
139
+ it('clears the created/expiry date refs and dispatches an empty facet selection on clear', () => {
140
+ const dispatch = vi.fn()
141
+ render(
142
+ <MyAccountQuotesFilterSlider {...(baseProps({ dispatch }) as any)} />
143
+ )
144
+
145
+ fireEvent.click(screen.getByText('Clear All'))
146
+
147
+ expect(dispatch).toHaveBeenCalledWith({
148
+ type: 'selectFacets',
149
+ payload: [],
150
+ })
151
+ })
152
+ })
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { fireEvent, 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
+
11
+ import MyAccountQuotesSelectedTags from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags'
12
+ import { formatFilterDate } from '../../../src/components/account/quotes/MyAccountListQuotes/quoteFilters'
13
+
14
+ mockUseSession.mockReturnValue({ locale: 'en-US' })
15
+
16
+ describe('MyAccountQuotesSelectedTags', () => {
17
+ it('renders nothing when there are no active filters', () => {
18
+ const { container } = render(
19
+ <MyAccountQuotesSelectedTags
20
+ filters={{}}
21
+ onClearAll={vi.fn()}
22
+ onRemoveFilter={vi.fn()}
23
+ />
24
+ )
25
+
26
+ expect(container.firstChild).toBeNull()
27
+ })
28
+
29
+ it('renders a range tag when both from and to dates are set', () => {
30
+ render(
31
+ <MyAccountQuotesSelectedTags
32
+ filters={{ createdAtFrom: '2026-01-01', createdAtTo: '2026-01-31' }}
33
+ onClearAll={vi.fn()}
34
+ onRemoveFilter={vi.fn()}
35
+ />
36
+ )
37
+
38
+ const expected = `Created: ${formatFilterDate('2026-01-01', 'en-US')} to ${formatFilterDate('2026-01-31', 'en-US')}`
39
+ expect(screen.getByText(expected)).toBeTruthy()
40
+ })
41
+
42
+ it('renders an open-ended "from" tag when only the start date is set', () => {
43
+ render(
44
+ <MyAccountQuotesSelectedTags
45
+ filters={{ expiresAtFrom: '2026-02-01' }}
46
+ onClearAll={vi.fn()}
47
+ onRemoveFilter={vi.fn()}
48
+ />
49
+ )
50
+
51
+ const expected = `Expires: from ${formatFilterDate('2026-02-01', 'en-US')}`
52
+ expect(screen.getByText(expected)).toBeTruthy()
53
+ })
54
+
55
+ it('renders an open-ended "to" tag when only the end date is set', () => {
56
+ render(
57
+ <MyAccountQuotesSelectedTags
58
+ filters={{ expiresAtTo: '2026-02-28' }}
59
+ onClearAll={vi.fn()}
60
+ onRemoveFilter={vi.fn()}
61
+ />
62
+ )
63
+
64
+ const expected = `Expires: to ${formatFilterDate('2026-02-28', 'en-US')}`
65
+ expect(screen.getByText(expected)).toBeTruthy()
66
+ })
67
+
68
+ it('renders one tag per selected status using its mapped label', () => {
69
+ render(
70
+ <MyAccountQuotesSelectedTags
71
+ filters={{ status: ['Draft', 'Approved'] }}
72
+ onClearAll={vi.fn()}
73
+ onRemoveFilter={vi.fn()}
74
+ />
75
+ )
76
+
77
+ expect(screen.getByText('Draft')).toBeTruthy()
78
+ expect(screen.getByText('Approved')).toBeTruthy()
79
+ })
80
+
81
+ it('falls back to the raw value for an unmapped status', () => {
82
+ render(
83
+ <MyAccountQuotesSelectedTags
84
+ filters={{ status: ['NotAStatus'] }}
85
+ onClearAll={vi.fn()}
86
+ onRemoveFilter={vi.fn()}
87
+ />
88
+ )
89
+
90
+ expect(screen.getByText('NotAStatus')).toBeTruthy()
91
+ })
92
+
93
+ it('calls onRemoveFilter with the right key/value when a tag is cleared', () => {
94
+ const onRemoveFilter = vi.fn()
95
+ render(
96
+ <MyAccountQuotesSelectedTags
97
+ filters={{ status: ['Draft'] }}
98
+ onClearAll={vi.fn()}
99
+ onRemoveFilter={onRemoveFilter}
100
+ />
101
+ )
102
+
103
+ fireEvent.click(screen.getByText('×'))
104
+
105
+ expect(onRemoveFilter).toHaveBeenCalledWith('status', 'Draft')
106
+ })
107
+
108
+ it('calls onClearAll when the Clear All button is clicked', () => {
109
+ const onClearAll = vi.fn()
110
+ render(
111
+ <MyAccountQuotesSelectedTags
112
+ filters={{ status: ['Draft'] }}
113
+ onClearAll={onClearAll}
114
+ onRemoveFilter={vi.fn()}
115
+ />
116
+ )
117
+
118
+ fireEvent.click(screen.getByText('Clear All'))
119
+
120
+ expect(onClearAll).toHaveBeenCalledTimes(1)
121
+ })
122
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+
8
+ import MyAccountQuotesStatusSelector from '../../../src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector'
9
+
10
+ describe('MyAccountQuotesStatusSelector', () => {
11
+ it('renders a chip with the mapped label for each selected value', () => {
12
+ render(
13
+ <MyAccountQuotesStatusSelector value={['Draft']} onChange={vi.fn()} />
14
+ )
15
+
16
+ expect(screen.getByText('Draft')).toBeTruthy()
17
+ })
18
+
19
+ it('opens the dropdown when the trigger is clicked', () => {
20
+ const { container } = render(
21
+ <MyAccountQuotesStatusSelector value={[]} onChange={vi.fn()} />
22
+ )
23
+
24
+ expect(screen.queryByLabelText('Approved')).toBeNull()
25
+
26
+ fireEvent.click(
27
+ container.querySelector('[aria-controls="status-listbox"]')!
28
+ )
29
+
30
+ expect(screen.getByLabelText('Approved')).toBeTruthy()
31
+ })
32
+
33
+ it('calls onChange with the key added when a checkbox is checked', () => {
34
+ const onChange = vi.fn()
35
+ const { container } = render(
36
+ <MyAccountQuotesStatusSelector value={[]} onChange={onChange} />
37
+ )
38
+
39
+ fireEvent.click(
40
+ container.querySelector('[aria-controls="status-listbox"]')!
41
+ )
42
+ fireEvent.click(screen.getByLabelText('Draft'))
43
+
44
+ expect(onChange).toHaveBeenCalledWith(['Draft'])
45
+ })
46
+
47
+ it('calls onChange with the key removed when an already-selected checkbox is unchecked', () => {
48
+ const onChange = vi.fn()
49
+ const { container } = render(
50
+ <MyAccountQuotesStatusSelector value={['Draft']} onChange={onChange} />
51
+ )
52
+
53
+ fireEvent.click(
54
+ container.querySelector('[aria-controls="status-listbox"]')!
55
+ )
56
+ fireEvent.click(screen.getByLabelText('Draft'))
57
+
58
+ expect(onChange).toHaveBeenCalledWith([])
59
+ })
60
+
61
+ it('calls onChange with the key removed when a chip remove button is clicked', () => {
62
+ const onChange = vi.fn()
63
+ render(
64
+ <MyAccountQuotesStatusSelector
65
+ value={['Draft', 'Approved']}
66
+ onChange={onChange}
67
+ />
68
+ )
69
+
70
+ fireEvent.click(screen.getByLabelText('Remove Draft'))
71
+
72
+ expect(onChange).toHaveBeenCalledWith(['Approved'])
73
+ })
74
+
75
+ it('falls back to the raw key for a chip with no matching status entry', () => {
76
+ render(
77
+ <MyAccountQuotesStatusSelector
78
+ value={['NotAStatus']}
79
+ onChange={vi.fn()}
80
+ />
81
+ )
82
+
83
+ expect(screen.getByText('NotAStatus')).toBeTruthy()
84
+ })
85
+ })
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ getSelectedFacets,
5
+ getAllFacets,
6
+ hasActiveFilters,
7
+ countActiveFilters,
8
+ formatFilterDate,
9
+ } from '../../../src/components/account/quotes/MyAccountListQuotes/quoteFilters'
10
+ import {
11
+ quoteStatusMap,
12
+ type QuoteStatusKey,
13
+ } from '../../../src/utils/quoteStatus'
14
+
15
+ const emptyFilters = {
16
+ page: 1,
17
+ status: [],
18
+ createdAtFrom: '',
19
+ createdAtTo: '',
20
+ expiresAtFrom: '',
21
+ expiresAtTo: '',
22
+ label: '',
23
+ }
24
+
25
+ describe('quoteStatusMap', () => {
26
+ it('has entries for all 9 statuses', () => {
27
+ expect(Object.keys(quoteStatusMap)).toHaveLength(9)
28
+ })
29
+
30
+ it('all keys are PascalCase', () => {
31
+ Object.keys(quoteStatusMap).forEach((key) => {
32
+ expect(key[0]).toBe(key[0].toUpperCase())
33
+ expect(key).toBe(key[0].toUpperCase() + key.slice(1))
34
+ })
35
+ })
36
+
37
+ it('all entries have a non-empty label', () => {
38
+ Object.values(quoteStatusMap).forEach((entry) => {
39
+ expect(entry.label).toBeTruthy()
40
+ })
41
+ })
42
+
43
+ it('all entries have a valid variant', () => {
44
+ const validVariants = ['neutral', 'warning', 'info', 'success', 'danger']
45
+ Object.values(quoteStatusMap).forEach((entry) => {
46
+ expect(validVariants).toContain(entry.variant)
47
+ })
48
+ })
49
+ })
50
+
51
+ describe('getSelectedFacets', () => {
52
+ it('returns empty array when no status selected', () => {
53
+ expect(getSelectedFacets(emptyFilters)).toEqual([])
54
+ })
55
+
56
+ it('returns one facet per status, preserving PascalCase', () => {
57
+ const filters = { ...emptyFilters, status: ['InReview', 'Approved'] }
58
+ const facets = getSelectedFacets(filters)
59
+ expect(facets).toEqual([
60
+ { key: 'status', value: 'InReview' },
61
+ { key: 'status', value: 'Approved' },
62
+ ])
63
+ })
64
+
65
+ it('does NOT lowercase the status values', () => {
66
+ const filters = { ...emptyFilters, status: ['ConvertedToOrder'] }
67
+ const facets = getSelectedFacets(filters)
68
+ expect(facets[0].value).toBe('ConvertedToOrder')
69
+ expect(facets[0].value).not.toBe('convertedtoorder')
70
+ })
71
+ })
72
+
73
+ describe('getAllFacets', () => {
74
+ it('returns 3 facets: status, createdAt, expiresAt', () => {
75
+ const facets = getAllFacets(emptyFilters)
76
+ expect(facets).toHaveLength(3)
77
+ expect(facets[0].key).toBe('status')
78
+ expect(facets[1].key).toBe('createdAt')
79
+ expect(facets[2].key).toBe('expiresAt')
80
+ })
81
+
82
+ it('status facet values match quoteStatusMap keys (PascalCase)', () => {
83
+ const facets = getAllFacets(emptyFilters)
84
+ const statusFacet = facets[0]
85
+ if (statusFacet.__typename !== 'StoreFacetBoolean') throw new Error()
86
+
87
+ const expectedKeys = Object.keys(quoteStatusMap) as QuoteStatusKey[]
88
+ statusFacet.values.forEach((v, i) => {
89
+ expect(v.value).toBe(expectedKeys[i])
90
+ expect(v.label).toBe(quoteStatusMap[expectedKeys[i]].label)
91
+ })
92
+ })
93
+
94
+ it('range facets carry the date filters', () => {
95
+ const filters = {
96
+ ...emptyFilters,
97
+ createdAtFrom: '2024-01-01',
98
+ createdAtTo: '2024-12-31',
99
+ }
100
+ const facets = getAllFacets(filters)
101
+ const createdAt = facets[1]
102
+ if (createdAt.__typename !== 'StoreFacetRange') throw new Error()
103
+ expect(createdAt.from).toBe('2024-01-01')
104
+ expect(createdAt.to).toBe('2024-12-31')
105
+ })
106
+ })
107
+
108
+ describe('hasActiveFilters', () => {
109
+ it('returns false for empty filters', () => {
110
+ expect(hasActiveFilters(emptyFilters)).toBe(false)
111
+ })
112
+
113
+ it('returns true when status is selected', () => {
114
+ expect(hasActiveFilters({ ...emptyFilters, status: ['Draft'] })).toBe(true)
115
+ })
116
+
117
+ it('returns true when label is set', () => {
118
+ expect(hasActiveFilters({ ...emptyFilters, label: 'quote-123' })).toBe(true)
119
+ })
120
+
121
+ it('returns true when any date filter is set', () => {
122
+ expect(
123
+ hasActiveFilters({ ...emptyFilters, createdAtFrom: '2024-01-01' })
124
+ ).toBe(true)
125
+ })
126
+ })
127
+
128
+ describe('countActiveFilters', () => {
129
+ it('returns 0 for empty filters', () => {
130
+ expect(countActiveFilters(emptyFilters)).toBe(0)
131
+ })
132
+
133
+ it('counts status as one regardless of how many statuses', () => {
134
+ expect(
135
+ countActiveFilters({ ...emptyFilters, status: ['Draft', 'Approved'] })
136
+ ).toBe(1)
137
+ })
138
+
139
+ it('counts createdAt range as one', () => {
140
+ expect(
141
+ countActiveFilters({
142
+ ...emptyFilters,
143
+ createdAtFrom: '2024-01-01',
144
+ createdAtTo: '2024-12-31',
145
+ })
146
+ ).toBe(1)
147
+ })
148
+
149
+ it('counts all three groups independently', () => {
150
+ expect(
151
+ countActiveFilters({
152
+ ...emptyFilters,
153
+ status: ['Draft'],
154
+ createdAtFrom: '2024-01-01',
155
+ expiresAtTo: '2025-01-01',
156
+ })
157
+ ).toBe(3)
158
+ })
159
+ })
160
+
161
+ describe('formatFilterDate', () => {
162
+ it('formats a valid YYYY-MM-DD date', () => {
163
+ const result = formatFilterDate('2024-03-15', 'en-US')
164
+ expect(result).toMatch(/03.+15.+2024|2024.+03.+15/)
165
+ })
166
+
167
+ it('returns the original string for empty input', () => {
168
+ expect(formatFilterDate('', 'en-US')).toBe('')
169
+ })
170
+
171
+ it('returns the original string for invalid format', () => {
172
+ expect(formatFilterDate('not-a-date', 'en-US')).toBe('not-a-date')
173
+ expect(formatFilterDate('2024/03/15', 'en-US')).toBe('2024/03/15')
174
+ })
175
+
176
+ it('returns the original string for zero month/day', () => {
177
+ expect(formatFilterDate('2024-00-15', 'en-US')).toBe('2024-00-15')
178
+ expect(formatFilterDate('2024-03-00', 'en-US')).toBe('2024-03-00')
179
+ })
180
+
181
+ it('returns the original string for dates that roll over (e.g. Feb 31)', () => {
182
+ expect(formatFilterDate('2024-02-31', 'en-US')).toBe('2024-02-31')
183
+ expect(formatFilterDate('2024-04-31', 'en-US')).toBe('2024-04-31')
184
+ })
185
+ })
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ vi.mock('discovery.config', () => ({
8
+ default: {
9
+ api: { storeId: 'b2bfaststoredev' },
10
+ },
11
+ }))
12
+
13
+ const mockFetch = vi.hoisted(() => vi.fn())
14
+ vi.mock('isomorphic-unfetch', () => ({ __esModule: true, default: mockFetch }))
15
+
16
+ import {
17
+ ContractSwitchError,
18
+ applyVtexAuthCookieFromSwitchResponse,
19
+ changeContractToken,
20
+ isContractSwitchEnabled,
21
+ } from '../../../src/sdk/account/changeContractToken'
22
+
23
+ const successPayload = {
24
+ authStatus: 'Success',
25
+ expiresIn: 86_399,
26
+ authCookie: {
27
+ Name: 'VtexIdclientAutCookie_b2bfaststoredev',
28
+ Value: 'new-contract-token',
29
+ },
30
+ accountAuthCookie: null,
31
+ }
32
+
33
+ describe('changeContractToken', () => {
34
+ beforeEach(() => {
35
+ document.cookie = ''
36
+ mockFetch.mockResolvedValue({
37
+ ok: true,
38
+ status: 200,
39
+ json: async () => successPayload,
40
+ })
41
+ })
42
+
43
+ afterEach(() => {
44
+ document.cookie = ''
45
+ vi.clearAllMocks()
46
+ })
47
+
48
+ it('is enabled', () => {
49
+ expect(isContractSwitchEnabled).toBe(true)
50
+ })
51
+
52
+ it('rejects when contractId is missing', async () => {
53
+ await expect(changeContractToken('')).rejects.toThrow(ContractSwitchError)
54
+ })
55
+
56
+ it('posts to switch-properties with the contract customerId', async () => {
57
+ const contractId = '69b5d80e-8f61-44e0-a5cb-db54c326699c'
58
+
59
+ await expect(changeContractToken(contractId)).resolves.toBe(true)
60
+
61
+ expect(mockFetch).toHaveBeenCalledTimes(1)
62
+ const [url, init] = mockFetch.mock.calls[0]
63
+ expect(url).toBe(
64
+ '/api/authenticator/storefront/credential/switch-properties?an=b2bfaststoredev'
65
+ )
66
+ expect(init.method).toBe('POST')
67
+ expect(init.credentials).toBe('include')
68
+ expect(init.signal).toBeDefined()
69
+ expect(JSON.parse(init.body as string)).toEqual({
70
+ properties: { customerId: contractId },
71
+ })
72
+ })
73
+
74
+ it('applies the auth cookie returned by switch-properties', async () => {
75
+ await changeContractToken('contract-1')
76
+
77
+ expect(document.cookie).toContain(
78
+ 'VtexIdclientAutCookie_b2bfaststoredev=new-contract-token'
79
+ )
80
+ })
81
+
82
+ it('throws when the switch request fails', async () => {
83
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 })
84
+
85
+ await expect(changeContractToken('contract-1')).rejects.toThrow(
86
+ /Failed to switch contract \(401\)/
87
+ )
88
+ })
89
+
90
+ it('throws when the switch request times out', async () => {
91
+ const timeoutError = new Error('The operation was aborted')
92
+ timeoutError.name = 'TimeoutError'
93
+ mockFetch.mockRejectedValueOnce(timeoutError)
94
+
95
+ await expect(changeContractToken('contract-1')).rejects.toThrow(
96
+ /Contract switch timed out/
97
+ )
98
+ })
99
+
100
+ it('throws when authStatus is not success', async () => {
101
+ mockFetch.mockResolvedValueOnce({
102
+ ok: true,
103
+ status: 200,
104
+ json: async () => ({ ...successPayload, authStatus: 'Failed' }),
105
+ })
106
+
107
+ await expect(changeContractToken('contract-1')).rejects.toThrow(
108
+ /Contract switch was not successful/
109
+ )
110
+ })
111
+
112
+ it('throws when authCookie is missing from a successful response', async () => {
113
+ mockFetch.mockResolvedValueOnce({
114
+ ok: true,
115
+ status: 200,
116
+ json: async () => ({
117
+ authStatus: 'Success',
118
+ authCookie: null,
119
+ }),
120
+ })
121
+
122
+ await expect(changeContractToken('contract-1')).rejects.toThrow(
123
+ /no auth cookie was returned/
124
+ )
125
+ })
126
+ })
127
+
128
+ describe('applyVtexAuthCookieFromSwitchResponse', () => {
129
+ beforeEach(() => {
130
+ document.cookie = ''
131
+ })
132
+
133
+ afterEach(() => {
134
+ document.cookie = ''
135
+ })
136
+
137
+ it('sets auth and account auth cookies when present', () => {
138
+ applyVtexAuthCookieFromSwitchResponse({
139
+ expiresIn: 3600,
140
+ authCookie: {
141
+ Name: 'VtexIdclientAutCookie_b2bfaststoredev',
142
+ Value: 'store-token',
143
+ },
144
+ accountAuthCookie: {
145
+ Name: 'VtexIdclientAutCookie_account',
146
+ Value: 'account-token',
147
+ },
148
+ })
149
+
150
+ expect(document.cookie).toContain(
151
+ 'VtexIdclientAutCookie_b2bfaststoredev=store-token'
152
+ )
153
+ expect(document.cookie).toContain(
154
+ 'VtexIdclientAutCookie_account=account-token'
155
+ )
156
+ })
157
+ })