@open-mercato/ui 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8

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.
@@ -7,6 +7,7 @@ import { act, screen, waitFor } from '@testing-library/react'
7
7
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
8
  import { apiCall } from '../../backend/utils/apiCall'
9
9
  import { AiAssistantLauncher, AI_ASSISTANT_LAUNCHER_OPEN_EVENT } from '../AiAssistantLauncher'
10
+ import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
10
11
 
11
12
  jest.mock('next/navigation', () => ({
12
13
  useRouter: () => ({ replace: jest.fn() }),
@@ -18,11 +19,18 @@ jest.mock('../../backend/utils/apiCall', () => ({
18
19
  apiCall: jest.fn(),
19
20
  }))
20
21
 
22
+ jest.mock('../useAiAssistantAvailable', () => ({
23
+ useAiAssistantAvailable: jest.fn(() => true),
24
+ }))
25
+
21
26
  const apiCallMock = apiCall as unknown as jest.Mock
27
+ const aiAvailableMock = useAiAssistantAvailable as jest.MockedFunction<typeof useAiAssistantAvailable>
22
28
 
23
29
  describe('<AiAssistantLauncher>', () => {
24
30
  beforeEach(() => {
25
31
  apiCallMock.mockReset()
32
+ aiAvailableMock.mockReset()
33
+ aiAvailableMock.mockReturnValue(true)
26
34
  apiCallMock.mockImplementation(async (url: string) => {
27
35
  if (url === '/api/ai_assistant/health') {
28
36
  return { ok: true, result: { healthy: true } }
@@ -61,4 +69,63 @@ describe('<AiAssistantLauncher>', () => {
61
69
  expect(await screen.findByRole('dialog', { name: 'AI assistants' })).toBeInTheDocument()
62
70
  expect(screen.getByText('Catalog Assistant')).toBeInTheDocument()
63
71
  }, 60_000)
72
+
73
+ it('renders nothing and probes no endpoint when the AI assistant is unavailable', async () => {
74
+ aiAvailableMock.mockReturnValue(false)
75
+
76
+ const { container } = renderWithProviders(<AiAssistantLauncher />)
77
+
78
+ expect(container.firstChild).toBeNull()
79
+
80
+ // Give the effects a chance to run before asserting they never did.
81
+ await act(async () => {
82
+ await Promise.resolve()
83
+ })
84
+
85
+ expect(apiCallMock).not.toHaveBeenCalled()
86
+ expect(screen.queryByRole('button', { name: 'Open AI assistant' })).toBeNull()
87
+ })
88
+
89
+ it('ignores the global launcher shortcut event when the AI assistant is unavailable', async () => {
90
+ aiAvailableMock.mockReturnValue(false)
91
+
92
+ renderWithProviders(<AiAssistantLauncher />)
93
+
94
+ act(() => {
95
+ window.dispatchEvent(new CustomEvent(AI_ASSISTANT_LAUNCHER_OPEN_EVENT))
96
+ })
97
+
98
+ await act(async () => {
99
+ await Promise.resolve()
100
+ })
101
+
102
+ expect(screen.queryByRole('dialog', { name: 'AI assistants' })).toBeNull()
103
+ expect(apiCallMock).not.toHaveBeenCalled()
104
+ })
105
+
106
+ // The gate is fail-closed until the backend chrome payload arrives, so on an
107
+ // enabled install the launcher always mounts unavailable first and only then
108
+ // flips. The probes must fire on that transition, and only once.
109
+ it('probes exactly once when the gate opens after mount', async () => {
110
+ aiAvailableMock.mockReturnValue(false)
111
+
112
+ const { rerender } = renderWithProviders(<AiAssistantLauncher />)
113
+
114
+ await act(async () => {
115
+ await Promise.resolve()
116
+ })
117
+ expect(apiCallMock).not.toHaveBeenCalled()
118
+
119
+ aiAvailableMock.mockReturnValue(true)
120
+ rerender(<AiAssistantLauncher />)
121
+
122
+ await waitFor(() => {
123
+ expect(screen.getAllByRole('button', { name: 'Open AI assistant' }).length).toBeGreaterThan(0)
124
+ })
125
+
126
+ const agentsCalls = apiCallMock.mock.calls.filter(
127
+ ([url]) => url === '/api/ai_assistant/ai/agents',
128
+ )
129
+ expect(agentsCalls).toHaveLength(1)
130
+ }, 60_000)
64
131
  })
@@ -24,6 +24,10 @@ jest.mock('../conversation-store', () => {
24
24
  }
25
25
  })
26
26
 
27
+ jest.mock('../useAiAssistantAvailable', () => ({
28
+ useAiAssistantAvailable: jest.fn(() => true),
29
+ }))
30
+
27
31
  jest.mock('@open-mercato/shared/lib/logger', () => {
28
32
  const mocked = {
29
33
  debug: jest.fn(),
@@ -41,9 +45,11 @@ import {
41
45
  listAiServerConversations,
42
46
  createAiServerConversation,
43
47
  } from '../conversation-store'
48
+ import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
44
49
 
45
50
  const listMock = listAiServerConversations as jest.MockedFunction<typeof listAiServerConversations>
46
51
  const createMock = createAiServerConversation as jest.MockedFunction<typeof createAiServerConversation>
52
+ const aiAvailableMock = useAiAssistantAvailable as jest.MockedFunction<typeof useAiAssistantAvailable>
47
53
  const loggerError = createLogger('ui').error as jest.Mock
48
54
  const loggerWarn = createLogger('ui').warn as jest.Mock
49
55
 
@@ -84,6 +90,8 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
84
90
  listMock.mockResolvedValue(null)
85
91
  createMock.mockReset()
86
92
  createMock.mockResolvedValue(null)
93
+ aiAvailableMock.mockReset()
94
+ aiAvailableMock.mockReturnValue(true)
87
95
  loggerError.mockClear()
88
96
  loggerWarn.mockClear()
89
97
  // Reset scope to a known starting point. The module-level state in
@@ -292,3 +300,72 @@ describe('<AiChatSessionsProvider> — tenant/org scope isolation', () => {
292
300
  expect(window.localStorage.getItem(scopedKey(null, null))).not.toBeNull()
293
301
  })
294
302
  })
303
+
304
+ describe('<AiChatSessionsProvider> — ai_assistant availability gate', () => {
305
+ beforeEach(() => {
306
+ window.localStorage.clear()
307
+ listMock.mockReset()
308
+ listMock.mockResolvedValue([])
309
+ createMock.mockReset()
310
+ createMock.mockResolvedValue(null)
311
+ aiAvailableMock.mockReset()
312
+ loggerError.mockClear()
313
+ loggerWarn.mockClear()
314
+ act(() => {
315
+ emitOrganizationScopeChanged({ tenantId: 'T1', organizationId: 'O1' })
316
+ })
317
+ })
318
+
319
+ afterEach(() => {
320
+ window.localStorage.clear()
321
+ })
322
+
323
+ it('skips the server conversation sync when the AI assistant is unavailable', async () => {
324
+ aiAvailableMock.mockReturnValue(false)
325
+
326
+ renderWithProviders(<Harness agentId="assistant" />)
327
+
328
+ // Children still render — the provider owns context every backend page needs.
329
+ await waitFor(() => {
330
+ expect(screen.getByTestId('session-count').textContent).toBe('0')
331
+ })
332
+
333
+ expect(listMock).not.toHaveBeenCalled()
334
+ expect(loggerWarn).not.toHaveBeenCalled()
335
+ expect(loggerError).not.toHaveBeenCalled()
336
+ })
337
+
338
+ it('syncs once when the AI assistant is available', async () => {
339
+ aiAvailableMock.mockReturnValue(true)
340
+
341
+ renderWithProviders(<Harness agentId="assistant" />)
342
+
343
+ await waitFor(() => {
344
+ expect(listMock).toHaveBeenCalledTimes(1)
345
+ })
346
+ expect(listMock).toHaveBeenCalledWith({ limit: 100 })
347
+ })
348
+
349
+ // The gate is fail-closed while the backend chrome payload is still null,
350
+ // which is the state at mount on every install. So on an enabled install the
351
+ // sync only ever happens on the re-run triggered by `aiAvailable` flipping
352
+ // true — this pins that dependency.
353
+ it('syncs once the availability gate opens after mount', async () => {
354
+ aiAvailableMock.mockReturnValue(false)
355
+
356
+ const { rerender } = renderWithProviders(<Harness agentId="assistant" />)
357
+
358
+ await waitFor(() => {
359
+ expect(screen.getByTestId('session-count').textContent).toBe('0')
360
+ })
361
+ expect(listMock).not.toHaveBeenCalled()
362
+
363
+ aiAvailableMock.mockReturnValue(true)
364
+ rerender(<Harness agentId="assistant" />)
365
+
366
+ await waitFor(() => {
367
+ expect(listMock).toHaveBeenCalledTimes(1)
368
+ })
369
+ expect(listMock).toHaveBeenCalledWith({ limit: 100 })
370
+ })
371
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { render, screen } from '@testing-library/react'
7
+ import type { BackendChromePayload } from '@open-mercato/shared/modules/navigation/backendChrome'
8
+ import { useBackendChrome } from '../../backend/BackendChromeProvider'
9
+ import { getEnabledModuleIds } from '@open-mercato/shared/modules/widgets/injection-loader'
10
+ import { useAiAssistantAvailable } from '../useAiAssistantAvailable'
11
+
12
+ jest.mock('../../backend/BackendChromeProvider', () => ({
13
+ useBackendChrome: jest.fn(),
14
+ }))
15
+
16
+ jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
17
+ getEnabledModuleIds: jest.fn(),
18
+ subscribeToInjectionRegistryChanges: () => () => {},
19
+ }))
20
+
21
+ const useBackendChromeMock = useBackendChrome as jest.MockedFunction<typeof useBackendChrome>
22
+ const getEnabledModuleIdsMock = getEnabledModuleIds as jest.MockedFunction<typeof getEnabledModuleIds>
23
+
24
+ function Probe() {
25
+ const available = useAiAssistantAvailable()
26
+ return <span data-testid="available">{String(available)}</span>
27
+ }
28
+
29
+ function setChrome(grantedFeatures: string[] | null) {
30
+ useBackendChromeMock.mockReturnValue({
31
+ payload: grantedFeatures === null
32
+ ? null
33
+ : ({ grantedFeatures } as unknown as BackendChromePayload),
34
+ isLoading: false,
35
+ isReady: true,
36
+ refresh: async () => {},
37
+ })
38
+ }
39
+
40
+ function renderProbe(): string | null {
41
+ render(<Probe />)
42
+ return screen.getByTestId('available').textContent
43
+ }
44
+
45
+ describe('useAiAssistantAvailable', () => {
46
+ beforeEach(() => {
47
+ useBackendChromeMock.mockReset()
48
+ getEnabledModuleIdsMock.mockReset()
49
+ })
50
+
51
+ it('is true when the module is enabled and the feature is granted', () => {
52
+ getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
53
+ setChrome(['auth.users.view', 'ai_assistant.view'])
54
+
55
+ expect(renderProbe()).toBe('true')
56
+ })
57
+
58
+ it('is false when the module is absent from the enabled registry', () => {
59
+ getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'customers']))
60
+ setChrome(['auth.users.view', 'ai_assistant.view'])
61
+
62
+ expect(renderProbe()).toBe('false')
63
+ })
64
+
65
+ it('is false when the feature is not granted', () => {
66
+ getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
67
+ setChrome(['auth.users.view'])
68
+
69
+ expect(renderProbe()).toBe('false')
70
+ })
71
+
72
+ it('is false while the backend chrome payload has not arrived', () => {
73
+ getEnabledModuleIdsMock.mockReturnValue(new Set(['auth', 'ai_assistant']))
74
+ setChrome(null)
75
+
76
+ expect(renderProbe()).toBe('false')
77
+ })
78
+
79
+ it('treats an unregistered module set as "not known yet" rather than disabled', () => {
80
+ // `registerEnabledModuleIds` runs from an async client-bootstrap import, so
81
+ // `null` here means the registry has not landed — not that the module is off.
82
+ getEnabledModuleIdsMock.mockReturnValue(null)
83
+ setChrome(['ai_assistant.view'])
84
+
85
+ expect(renderProbe()).toBe('true')
86
+ })
87
+
88
+ it('matches a module wildcard grant', () => {
89
+ // Superadmins carry `<module>.*`, expanded server-side for enabled modules
90
+ // only. A plain `includes()` would miss it.
91
+ getEnabledModuleIdsMock.mockReturnValue(new Set(['ai_assistant']))
92
+ setChrome(['ai_assistant.*'])
93
+
94
+ expect(renderProbe()).toBe('true')
95
+ })
96
+ })
package/src/ai/index.ts CHANGED
@@ -27,6 +27,11 @@ export {
27
27
  type UseAiChatInput,
28
28
  type UseAiChatResult,
29
29
  } from './useAiChat'
30
+ export {
31
+ useAiAssistantAvailable,
32
+ AI_ASSISTANT_MODULE_ID,
33
+ AI_ASSISTANT_VIEW_FEATURE,
34
+ } from './useAiAssistantAvailable'
30
35
  export {
31
36
  useAiShortcuts,
32
37
  type UseAiShortcutsOptions,
@@ -0,0 +1,50 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ getEnabledModuleIds,
6
+ subscribeToInjectionRegistryChanges,
7
+ } from '@open-mercato/shared/modules/widgets/injection-loader'
8
+ import { hasFeature } from '@open-mercato/shared/security/features'
9
+ import { useBackendChrome } from '../backend/BackendChromeProvider'
10
+
11
+ export const AI_ASSISTANT_MODULE_ID = 'ai_assistant'
12
+ export const AI_ASSISTANT_VIEW_FEATURE = 'ai_assistant.view'
13
+
14
+ function readEnabledModuleIds(): ReadonlySet<string> | null {
15
+ return getEnabledModuleIds()
16
+ }
17
+
18
+ function readEnabledModuleIdsOnServer(): ReadonlySet<string> | null {
19
+ return null
20
+ }
21
+
22
+ /**
23
+ * Whether the AI assistant surfaces should mount at all.
24
+ *
25
+ * Every `/api/ai_assistant/*` route is gated on `ai_assistant.view`, and an
26
+ * installation that does not enable the module has no such routes to answer.
27
+ * Mounting the launcher or the conversation sync in either case only produces
28
+ * 404s / 403s, so both call sites consult this first instead of probing.
29
+ *
30
+ * Two independent signals have to agree:
31
+ * - the client enabled-module registry, populated from the generated
32
+ * `enabled-module-ids.generated.ts` during client bootstrap. It registers
33
+ * asynchronously, so `null` means "not known yet", not "module absent".
34
+ * - `ai_assistant.view` in the backend chrome payload. The server already
35
+ * drops grants owned by disabled modules (and expands a superadmin `*`
36
+ * into enabled modules only), and the payload is fetched once and cached
37
+ * by `BackendChromeProvider`, so this costs no extra request. It stays
38
+ * fail-closed until the payload arrives, which is what keeps the cold-load
39
+ * window quiet.
40
+ */
41
+ export function useAiAssistantAvailable(): boolean {
42
+ const { payload } = useBackendChrome()
43
+ const enabledModuleIds = React.useSyncExternalStore(
44
+ subscribeToInjectionRegistryChanges,
45
+ readEnabledModuleIds,
46
+ readEnabledModuleIdsOnServer,
47
+ )
48
+ const moduleEnabled = enabledModuleIds === null || enabledModuleIds.has(AI_ASSISTANT_MODULE_ID)
49
+ return moduleEnabled && hasFeature(payload?.grantedFeatures, AI_ASSISTANT_VIEW_FEATURE)
50
+ }
@@ -0,0 +1,33 @@
1
+ import { resolveTaxIdLabel } from '../addressFormat'
2
+
3
+ // This module is a documented near-identical twin of `@open-mercato/core`'s `customers/utils/
4
+ // addressFormat`, and the spec forbids letting the two drift. The core copy has these cases; without
5
+ // them here, a change made on one side and forgotten on the other passes CI.
6
+
7
+ describe('resolveTaxIdLabel — the ui twin', () => {
8
+ const BY_TYPE = { plNip: 'NIP', euVat: 'EU VAT', other: 'Tax number' }
9
+
10
+ // The distinction the scheme exists for: `1234567890` and `PL1234567890` are the same business, so
11
+ // one flat label necessarily misnames one of them.
12
+ it('names a domestic identifier and an EU VAT number differently', () => {
13
+ expect(resolveTaxIdLabel(BY_TYPE, 'pl_nip')).toBe('NIP')
14
+ expect(resolveTaxIdLabel(BY_TYPE, 'eu_vat')).toBe('EU VAT')
15
+ })
16
+
17
+ // Naming a foreign number after a domestic scheme renames it, so an unknown scheme takes the
18
+ // neutral label rather than the nearest guess. This is also what lets the vocabulary widen.
19
+ it('falls back to the neutral label for unknown and missing schemes', () => {
20
+ expect(resolveTaxIdLabel(BY_TYPE, 'other')).toBe('Tax number')
21
+ expect(resolveTaxIdLabel(BY_TYPE, 'us_ein')).toBe('Tax number')
22
+ expect(resolveTaxIdLabel(BY_TYPE, null)).toBe('Tax number')
23
+ expect(resolveTaxIdLabel(BY_TYPE, undefined)).toBe('Tax number')
24
+ })
25
+
26
+ it('accepts a plain string, which names every scheme the same', () => {
27
+ expect(resolveTaxIdLabel('Tax ID', 'eu_vat')).toBe('Tax ID')
28
+ })
29
+
30
+ it('has no label to give when the caller supplies none', () => {
31
+ expect(resolveTaxIdLabel(undefined, 'pl_nip')).toBeUndefined()
32
+ })
33
+ })
@@ -12,6 +12,34 @@ export type AddressValue = {
12
12
  postalCode?: string | null
13
13
  country?: string | null
14
14
  companyName?: string | null
15
+ /**
16
+ * Contact details that belong to the ADDRESS rather than to the customer: who to call about this
17
+ * delivery, and the tax identifier this invoice address was billed under. They remain available to
18
+ * address editors and snapshot payloads, but are deliberately excluded from `formatAddressLines`
19
+ * and `AddressView`, whose existing contract remains postal-only.
20
+ *
21
+ * `taxIdType` interprets the value in Stripe's `{country}_{kind}` vocabulary (`pl_nip`, `eu_vat`,
22
+ * `other`, widened additively): `1234567890` and `PL1234567890` are the same business, and only the
23
+ * type tells a domestic identifier from an EU VAT number. It is metadata about `taxId`, never a
24
+ * displayed field of its own.
25
+ */
26
+ phone?: string | null
27
+ taxId?: string | null
28
+ taxIdType?: string | null
29
+ }
30
+
31
+ /**
32
+ * Tax-id labels keyed by `taxIdType`, for a caller that wants the identifier named correctly rather
33
+ * than generically.
34
+ *
35
+ * The stored scheme is chosen explicitly rather than inferred from the identifier. `other` also
36
+ * covers an address written before `taxIdType` existed. An unrecognised type takes the `other` route
37
+ * instead of guessing a domestic scheme.
38
+ */
39
+ export type TaxIdLabelByType = {
40
+ plNip: string
41
+ euVat: string
42
+ other: string
15
43
  }
16
44
 
17
45
  export type AddressJsonShape = {
@@ -99,6 +127,31 @@ export function formatAddressString(address: AddressValue, format: AddressFormat
99
127
  return formatAddressLines(address, format).filter(Boolean).join(separator)
100
128
  }
101
129
 
130
+ /**
131
+ * Which member of a label map names which scheme. Private, and deliberately not exhaustive: an
132
+ * unrecognised type resolves to `other`, so the vocabulary can widen without every caller being
133
+ * updated in the same release.
134
+ */
135
+ const TAX_ID_LABEL_KEY_BY_TYPE: Record<string, keyof TaxIdLabelByType> = {
136
+ pl_nip: 'plNip',
137
+ eu_vat: 'euVat',
138
+ }
139
+
140
+ /**
141
+ * The label a tax identifier should carry, given its type. Exported because the editor renders the
142
+ * same identifier as an input and must name it the same way this formatter does — two copies of the
143
+ * mapping is exactly how a foreign number ends up under a domestic scheme's name.
144
+ */
145
+ export function resolveTaxIdLabel(
146
+ label: string | TaxIdLabelByType | undefined,
147
+ taxIdType: string | null | undefined,
148
+ ): string | undefined {
149
+ if (!label) return undefined
150
+ if (typeof label === 'string') return label
151
+ const key = TAX_ID_LABEL_KEY_BY_TYPE[typeof taxIdType === 'string' ? taxIdType : ''] ?? 'other'
152
+ return label[key]
153
+ }
154
+
102
155
  type AddressViewProps = {
103
156
  address: AddressValue
104
157
  format: AddressFormatStrategy
@@ -106,7 +159,12 @@ type AddressViewProps = {
106
159
  lineClassName?: string
107
160
  }
108
161
 
109
- export function AddressView({ address, format, className, lineClassName }: AddressViewProps): React.ReactElement | null {
162
+ export function AddressView({
163
+ address,
164
+ format,
165
+ className,
166
+ lineClassName,
167
+ }: AddressViewProps): React.ReactElement | null {
110
168
  const lines = formatAddressLines(address, format)
111
169
  if (!lines.length) return null
112
170
  return (