@open-mercato/ui 0.6.2-develop.3446.1.bd060c6017 → 0.6.2-develop.3467.1.2a1818709d

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.
@@ -0,0 +1,244 @@
1
+ "use client"
2
+
3
+ import type { AiChatMessage, AiChatMessageUiPart } from './useAiChat'
4
+
5
+ const CONVERSATIONS_ENDPOINT = '/api/ai_assistant/ai/conversations'
6
+
7
+ export interface AiServerConversation {
8
+ conversationId: string
9
+ agentId: string
10
+ title: string | null
11
+ status: 'open' | 'closed'
12
+ visibility: 'private' | 'shared' | 'organization'
13
+ pageContext: Record<string, unknown> | null
14
+ createdAt: string
15
+ updatedAt: string
16
+ lastMessageAt: string | null
17
+ importedFromLocalAt: string | null
18
+ }
19
+
20
+ export interface AiServerMessage {
21
+ id: string
22
+ clientMessageId: string | null
23
+ role: 'user' | 'assistant' | 'system'
24
+ content: string
25
+ uiParts: unknown[]
26
+ attachmentIds: string[]
27
+ files: Array<Record<string, unknown>>
28
+ model: string | null
29
+ metadata: Record<string, unknown> | null
30
+ createdAt: string
31
+ }
32
+
33
+ export interface AiServerConversationListResponse {
34
+ items: AiServerConversation[]
35
+ nextCursor: string | null
36
+ }
37
+
38
+ export interface AiServerTranscriptResponse {
39
+ conversation: AiServerConversation
40
+ messages: AiServerMessage[]
41
+ nextCursor: string | null
42
+ }
43
+
44
+ export interface AiServerImportResponse {
45
+ conversation: AiServerConversation
46
+ importedMessageCount: number
47
+ skippedMessageCount: number
48
+ }
49
+
50
+ function readString(value: unknown): string | null {
51
+ return typeof value === 'string' && value.length > 0 ? value : null
52
+ }
53
+
54
+ function buildAttachmentImagePreviewUrl(attachmentId: string): string {
55
+ const params = new URLSearchParams({
56
+ width: '320',
57
+ height: '320',
58
+ cropType: 'contain',
59
+ })
60
+ return `/api/attachments/image/${encodeURIComponent(attachmentId)}?${params.toString()}`
61
+ }
62
+
63
+ function getFetch(): typeof fetch | null {
64
+ const fetchImpl = (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
65
+ return typeof fetchImpl === 'function' ? fetchImpl.bind(globalThis) : null
66
+ }
67
+
68
+ async function requestJson<T>(
69
+ input: string,
70
+ init?: RequestInit,
71
+ ): Promise<{ ok: true; status: number; data: T } | { ok: false; status: number; data: null }> {
72
+ const fetchImpl = getFetch()
73
+ if (!fetchImpl) return { ok: false, status: 0, data: null }
74
+ try {
75
+ const response = await fetchImpl(input, {
76
+ credentials: 'include',
77
+ ...init,
78
+ headers: {
79
+ Accept: 'application/json',
80
+ ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
81
+ ...(init?.headers ?? {}),
82
+ },
83
+ })
84
+ if (!response.ok) return { ok: false, status: response.status, data: null }
85
+ return { ok: true, status: response.status, data: (await response.json()) as T }
86
+ } catch {
87
+ return { ok: false, status: 0, data: null }
88
+ }
89
+ }
90
+
91
+ export async function listAiServerConversations(options: {
92
+ agentId?: string | null
93
+ status?: 'open' | 'closed' | null
94
+ limit?: number
95
+ } = {}): Promise<AiServerConversation[] | null> {
96
+ const params = new URLSearchParams()
97
+ if (options.agentId) params.set('agent', options.agentId)
98
+ if (options.status) params.set('status', options.status)
99
+ params.set('limit', String(options.limit ?? 100))
100
+ const result = await requestJson<AiServerConversationListResponse>(
101
+ `${CONVERSATIONS_ENDPOINT}?${params.toString()}`,
102
+ )
103
+ return result.ok ? result.data.items : null
104
+ }
105
+
106
+ export async function createAiServerConversation(input: {
107
+ agentId: string
108
+ conversationId: string
109
+ title?: string | null
110
+ pageContext?: Record<string, unknown> | null
111
+ }): Promise<AiServerConversation | null> {
112
+ const result = await requestJson<AiServerConversation>(CONVERSATIONS_ENDPOINT, {
113
+ method: 'POST',
114
+ body: JSON.stringify({
115
+ agentId: input.agentId,
116
+ conversationId: input.conversationId,
117
+ title: input.title ?? undefined,
118
+ pageContext: input.pageContext ?? undefined,
119
+ }),
120
+ })
121
+ return result.ok ? result.data : null
122
+ }
123
+
124
+ export async function updateAiServerConversation(
125
+ conversationId: string,
126
+ patch: {
127
+ title?: string | null
128
+ status?: 'open' | 'closed'
129
+ pageContext?: Record<string, unknown> | null
130
+ },
131
+ ): Promise<AiServerConversation | null> {
132
+ const result = await requestJson<AiServerConversation>(
133
+ `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}`,
134
+ {
135
+ method: 'PATCH',
136
+ body: JSON.stringify(patch),
137
+ },
138
+ )
139
+ return result.ok ? result.data : null
140
+ }
141
+
142
+ export async function loadAiServerTranscript(
143
+ conversationId: string,
144
+ options: { limit?: number } = {},
145
+ ): Promise<AiServerTranscriptResponse | null> {
146
+ const params = new URLSearchParams()
147
+ params.set('limit', String(options.limit ?? 100))
148
+ const result = await requestJson<AiServerTranscriptResponse>(
149
+ `${CONVERSATIONS_ENDPOINT}/${encodeURIComponent(conversationId)}?${params.toString()}`,
150
+ )
151
+ return result.ok ? result.data : null
152
+ }
153
+
154
+ export async function importAiLocalConversation(input: {
155
+ agentId: string
156
+ conversationId: string
157
+ title?: string | null
158
+ status?: 'open' | 'closed'
159
+ pageContext?: Record<string, unknown> | null
160
+ messages: AiChatMessage[]
161
+ }): Promise<AiServerImportResponse | null> {
162
+ const result = await requestJson<AiServerImportResponse>(`${CONVERSATIONS_ENDPOINT}/import`, {
163
+ method: 'POST',
164
+ body: JSON.stringify({
165
+ conversation: {
166
+ conversationId: input.conversationId,
167
+ agentId: input.agentId,
168
+ title: input.title ?? undefined,
169
+ status: input.status ?? 'open',
170
+ pageContext: input.pageContext ?? undefined,
171
+ },
172
+ messages: input.messages.slice(-100).map((message) => ({
173
+ clientMessageId: message.id,
174
+ role: message.role,
175
+ content: message.content,
176
+ uiParts: message.uiParts ?? [],
177
+ files: (message.files ?? []).map((file) => ({
178
+ id: file.id,
179
+ name: file.name,
180
+ mimeType: file.type,
181
+ })),
182
+ })),
183
+ }),
184
+ })
185
+ return result.ok ? result.data : null
186
+ }
187
+
188
+ export function serverMessageToChatMessage(message: AiServerMessage): AiChatMessage | null {
189
+ if (message.role !== 'user' && message.role !== 'assistant') return null
190
+ const uiParts: AiChatMessageUiPart[] = []
191
+ message.uiParts.forEach((part, index) => {
192
+ if (!part || typeof part !== 'object') return
193
+ const value = part as Record<string, unknown>
194
+ const componentId =
195
+ typeof value.componentId === 'string'
196
+ ? value.componentId
197
+ : typeof value.type === 'string'
198
+ ? value.type
199
+ : null
200
+ if (!componentId) return
201
+ uiParts.push({
202
+ componentId,
203
+ payload: value.payload ?? value.props,
204
+ pendingActionId:
205
+ typeof value.pendingActionId === 'string' ? value.pendingActionId : undefined,
206
+ key:
207
+ typeof value.key === 'string'
208
+ ? value.key
209
+ : `${message.id}:${index}:${componentId}`,
210
+ })
211
+ })
212
+ return {
213
+ id: message.clientMessageId ?? message.id,
214
+ role: message.role,
215
+ content: message.content,
216
+ files: message.files
217
+ .map((file, index) => {
218
+ const name = readString(file.name)
219
+ if (!name) return null
220
+ const type =
221
+ typeof file.mimeType === 'string'
222
+ ? file.mimeType
223
+ : typeof file.type === 'string'
224
+ ? file.type
225
+ : 'application/octet-stream'
226
+ const id = readString(file.id) ?? readString(message.attachmentIds[index])
227
+ const rawPreviewUrl =
228
+ readString(file.previewUrl) ??
229
+ readString(file.thumbnailUrl) ??
230
+ readString(file.url)
231
+ const previewUrl = type.startsWith('image/')
232
+ ? rawPreviewUrl ?? (id ? buildAttachmentImagePreviewUrl(id) : undefined)
233
+ : undefined
234
+ return {
235
+ ...(id ? { id } : {}),
236
+ name,
237
+ type,
238
+ ...(previewUrl ? { previewUrl } : {}),
239
+ }
240
+ })
241
+ .filter((file): file is NonNullable<AiChatMessage['files']>[number] => file !== null),
242
+ uiParts,
243
+ }
244
+ }
@@ -4,6 +4,12 @@ import * as React from 'react'
4
4
  import { createAiAgentTransport } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/agent-transport'
5
5
  import { apiFetch } from '../backend/utils/api'
6
6
  import type { LoopTracePanelTrace } from './LoopTracePanel'
7
+ import {
8
+ createAiServerConversation,
9
+ importAiLocalConversation,
10
+ loadAiServerTranscript,
11
+ serverMessageToChatMessage,
12
+ } from './conversation-store'
7
13
 
8
14
  /**
9
15
  * Chat message shape used by {@link AiChat}. Kept intentionally minimal so the
@@ -12,6 +18,7 @@ import type { LoopTracePanelTrace } from './LoopTracePanel'
12
18
  * shape for `messages`.
13
19
  */
14
20
  export interface AiChatMessageFile {
21
+ id?: string
15
22
  name: string
16
23
  type: string
17
24
  previewUrl?: string
@@ -166,6 +173,7 @@ function makeConversationId(): string {
166
173
  }
167
174
 
168
175
  const SESSION_STORAGE_PREFIX = 'om-ai-chat:'
176
+ const SESSION_IMPORT_MARKER_PREFIX = 'om-ai-chat-imported:'
169
177
  const SESSION_STORAGE_VERSION = 1
170
178
  const SESSION_UPDATED_EVENT = 'om-ai-chat-session-updated'
171
179
  const SESSION_STREAM_STATE_EVENT = 'om-ai-chat-session-stream-state'
@@ -247,19 +255,20 @@ function writePersistedSession(
247
255
  if (typeof window === 'undefined') return
248
256
  try {
249
257
  // Strip transient blob/object preview URLs before persisting (they would
250
- // not survive a reload). Self-contained `data:` URLs are kept so image
251
- // previews come back unchanged after the chat is reopened — public
252
- // attachment URLs are intentionally not used because the LLM provider
253
- // cannot reach a localhost origin and we want a single durable shape
254
- // that works for both transport and reload.
258
+ // not survive a reload). Self-contained `data:` URLs and same-origin
259
+ // attachment thumbnail URLs are durable enough for local fallback storage.
255
260
  const messages = session.messages.map((message) => {
256
261
  if (!message.files || message.files.length === 0) return message
257
- const safeFiles = message.files.map(({ name, type, previewUrl }) => {
262
+ const safeFiles = message.files.map(({ id, name, type, previewUrl }) => {
258
263
  const durable =
259
- typeof previewUrl === 'string' && previewUrl.startsWith('data:')
264
+ typeof previewUrl === 'string' &&
265
+ (previewUrl.startsWith('data:') ||
266
+ previewUrl.startsWith('/api/attachments/image/') ||
267
+ previewUrl.startsWith('/api/attachments/file/'))
260
268
  ? previewUrl
261
269
  : undefined
262
- return durable ? { name, type, previewUrl: durable } : { name, type }
270
+ const base = id ? { id, name, type } : { name, type }
271
+ return durable ? { ...base, previewUrl: durable } : base
263
272
  })
264
273
  return { ...message, files: safeFiles }
265
274
  })
@@ -286,6 +295,28 @@ function clearPersistedSession(agent: string, conversationId?: string | null): v
286
295
  }
287
296
  }
288
297
 
298
+ function getImportMarkerKey(agent: string, conversationId: string): string {
299
+ return `${SESSION_IMPORT_MARKER_PREFIX}${agent}:${conversationId}`
300
+ }
301
+
302
+ function hasImportMarker(agent: string, conversationId: string): boolean {
303
+ if (typeof window === 'undefined') return false
304
+ try {
305
+ return window.localStorage.getItem(getImportMarkerKey(agent, conversationId)) === '1'
306
+ } catch {
307
+ return false
308
+ }
309
+ }
310
+
311
+ function writeImportMarker(agent: string, conversationId: string): void {
312
+ if (typeof window === 'undefined') return
313
+ try {
314
+ window.localStorage.setItem(getImportMarkerKey(agent, conversationId), '1')
315
+ } catch {
316
+ // ignore local marker failures; the server import remains authoritative
317
+ }
318
+ }
319
+
289
320
  function getTransportEndpoint(
290
321
  agent: string,
291
322
  apiPath?: string,
@@ -802,14 +833,19 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
802
833
  // Without this, closing the dock or switching agents while the assistant
803
834
  // is "Thinking" abandons the partial reply (issue #1816).
804
835
  const latestMessagesRef = React.useRef<AiChatMessage[]>(messages)
836
+ const latestStatusRef = React.useRef<'idle' | 'submitting' | 'streaming'>(status)
805
837
  const persistKeyRef = React.useRef<string | null>(null)
806
838
  const agentRef = React.useRef<string>(agent)
839
+ const pageContextRef = React.useRef<Record<string, unknown> | undefined>(pageContext)
807
840
  const effectiveConversationIdRef = React.useRef<string>(effectiveConversationId)
808
841
  const sessionStorageKeyRef = React.useRef<string>(sessionStorageKey)
809
842
  const mountedRef = React.useRef(true)
810
843
  React.useEffect(() => {
811
844
  latestMessagesRef.current = messages
812
845
  }, [messages])
846
+ React.useEffect(() => {
847
+ latestStatusRef.current = status
848
+ }, [status])
813
849
  React.useEffect(() => {
814
850
  persistKeyRef.current =
815
851
  typeof conversationIdInput === 'string' && conversationIdInput.length > 0
@@ -819,6 +855,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
819
855
  React.useEffect(() => {
820
856
  agentRef.current = agent
821
857
  }, [agent])
858
+ React.useEffect(() => {
859
+ pageContextRef.current = pageContext
860
+ }, [pageContext])
822
861
  React.useEffect(() => {
823
862
  effectiveConversationIdRef.current = effectiveConversationId
824
863
  }, [effectiveConversationId])
@@ -874,6 +913,53 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
874
913
  [persistSnapshot],
875
914
  )
876
915
 
916
+ React.useEffect(() => {
917
+ let cancelled = false
918
+ const localCandidate = readPersistedSession(agent, persistKey)
919
+
920
+ async function hydrateFromServer(): Promise<void> {
921
+ const transcript = await loadAiServerTranscript(effectiveConversationId, { limit: 100 })
922
+ if (cancelled || latestStatusRef.current !== 'idle') return
923
+
924
+ if (transcript) {
925
+ const serverMessages = transcript.messages
926
+ .map(serverMessageToChatMessage)
927
+ .filter((message): message is AiChatMessage => message !== null)
928
+ if (serverMessages.length > 0 || !localCandidate || localCandidate.messages.length === 0) {
929
+ updateMessages(serverMessages)
930
+ if (serverMessages.length > 0) persistSnapshot(serverMessages, true)
931
+ return
932
+ }
933
+ }
934
+
935
+ if (!localCandidate || localCandidate.messages.length === 0) {
936
+ if (!transcript) {
937
+ void createAiServerConversation({
938
+ agentId: agent,
939
+ conversationId: effectiveConversationId,
940
+ pageContext: pageContextRef.current ?? null,
941
+ })
942
+ }
943
+ return
944
+ }
945
+
946
+ if (hasImportMarker(agent, effectiveConversationId)) return
947
+ const imported = await importAiLocalConversation({
948
+ agentId: agent,
949
+ conversationId: effectiveConversationId,
950
+ pageContext: pageContextRef.current ?? null,
951
+ messages: localCandidate.messages,
952
+ })
953
+ if (cancelled || !imported) return
954
+ writeImportMarker(agent, effectiveConversationId)
955
+ }
956
+
957
+ void hydrateFromServer()
958
+ return () => {
959
+ cancelled = true
960
+ }
961
+ }, [agent, effectiveConversationId, persistKey, persistSnapshot, updateMessages])
962
+
877
963
  const setStatus = React.useCallback((next: 'idle' | 'submitting' | 'streaming') => {
878
964
  if (mountedRef.current) {
879
965
  setStatusInternal(next)
@@ -961,8 +1047,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
961
1047
  const url = getTransportEndpoint(agent, apiPath, providerOverride, modelOverride)
962
1048
  const body = {
963
1049
  messages: outgoingHistory.map((message) => ({
1050
+ id: message.id,
964
1051
  role: message.role,
965
1052
  content: message.content,
1053
+ files: message.files,
1054
+ uiParts: message.uiParts,
966
1055
  })),
967
1056
  pageContext,
968
1057
  attachmentIds,
@@ -0,0 +1,114 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { apiCall } from './utils/apiCall'
4
+
5
+ export const AUTH_IDENTITY_STORAGE_KEY = 'om:auth:identity'
6
+ export const AUTH_IDENTITY_BROADCAST_CHANNEL = 'om-auth-identity'
7
+
8
+ type FeatureCheckResponse = {
9
+ ok: boolean
10
+ granted?: string[]
11
+ userId?: string
12
+ }
13
+
14
+ export type AuthSessionGuardProps = {
15
+ serverUserId: string | null
16
+ }
17
+
18
+ export const __reload = {
19
+ fn: (): void => {
20
+ if (typeof window !== 'undefined') window.location.reload()
21
+ },
22
+ }
23
+
24
+ export function AuthSessionGuard({ serverUserId }: AuthSessionGuardProps) {
25
+ React.useEffect(() => {
26
+ if (typeof window === 'undefined') return
27
+ let cancelled = false
28
+ let reloadScheduled = false
29
+
30
+ const triggerReload = () => {
31
+ if (reloadScheduled) return
32
+ reloadScheduled = true
33
+ __reload.fn()
34
+ }
35
+
36
+ const checkIdentity = async () => {
37
+ if (cancelled || reloadScheduled) return
38
+ try {
39
+ const res = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ features: [] }),
43
+ cache: 'no-store',
44
+ })
45
+ if (cancelled || reloadScheduled) return
46
+ if (res.status === 401) {
47
+ if (serverUserId) triggerReload()
48
+ return
49
+ }
50
+ if (!res.ok) return
51
+ const currentUserId = typeof res.result?.userId === 'string' ? res.result.userId : null
52
+ if (!currentUserId) return
53
+ if (currentUserId !== serverUserId) triggerReload()
54
+ } catch {
55
+ // network errors are ignored — next focus/storage event retries
56
+ }
57
+ }
58
+
59
+ const onVisibilityOrFocus = () => {
60
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
61
+ void checkIdentity()
62
+ }
63
+
64
+ const onStorage = (event: StorageEvent) => {
65
+ if (event.key !== AUTH_IDENTITY_STORAGE_KEY) return
66
+ void checkIdentity()
67
+ }
68
+
69
+ let broadcastChannel: BroadcastChannel | null = null
70
+ if (typeof BroadcastChannel !== 'undefined') {
71
+ try {
72
+ broadcastChannel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)
73
+ broadcastChannel.onmessage = () => { void checkIdentity() }
74
+ } catch {
75
+ broadcastChannel = null
76
+ }
77
+ }
78
+
79
+ window.addEventListener('focus', onVisibilityOrFocus)
80
+ document.addEventListener('visibilitychange', onVisibilityOrFocus)
81
+ window.addEventListener('storage', onStorage)
82
+
83
+ return () => {
84
+ cancelled = true
85
+ window.removeEventListener('focus', onVisibilityOrFocus)
86
+ document.removeEventListener('visibilitychange', onVisibilityOrFocus)
87
+ window.removeEventListener('storage', onStorage)
88
+ if (broadcastChannel) {
89
+ broadcastChannel.onmessage = null
90
+ broadcastChannel.close()
91
+ }
92
+ }
93
+ }, [serverUserId])
94
+
95
+ return null
96
+ }
97
+
98
+ export function notifyAuthIdentityChange(): void {
99
+ if (typeof window === 'undefined') return
100
+ try {
101
+ if (typeof BroadcastChannel !== 'undefined') {
102
+ const channel = new BroadcastChannel(AUTH_IDENTITY_BROADCAST_CHANNEL)
103
+ channel.postMessage('changed')
104
+ channel.close()
105
+ }
106
+ } catch {
107
+ // ignore — fall back to storage event below
108
+ }
109
+ try {
110
+ window.localStorage.setItem(AUTH_IDENTITY_STORAGE_KEY, String(Date.now()))
111
+ } catch {
112
+ // private mode / quota errors are non-fatal
113
+ }
114
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, render, waitFor } from '@testing-library/react'
7
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import {
9
+ __reload,
10
+ AUTH_IDENTITY_STORAGE_KEY,
11
+ AuthSessionGuard,
12
+ notifyAuthIdentityChange,
13
+ } from '../AuthSessionGuard'
14
+
15
+ jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
16
+ apiCall: jest.fn(),
17
+ }))
18
+
19
+ const apiCallMock = apiCall as jest.MockedFunction<typeof apiCall>
20
+
21
+ const originalReload = __reload.fn
22
+
23
+ function mockLocationReload() {
24
+ const reloadSpy = jest.fn()
25
+ __reload.fn = reloadSpy
26
+ return reloadSpy
27
+ }
28
+
29
+ function restoreLocation() {
30
+ __reload.fn = originalReload
31
+ }
32
+
33
+ function makeApiResult(payload: { status?: number; userId?: string | null }) {
34
+ const status = payload.status ?? 200
35
+ return {
36
+ ok: status >= 200 && status < 300,
37
+ status,
38
+ result: payload.userId !== undefined ? { ok: true, granted: [], userId: payload.userId } : null,
39
+ response: { ok: status < 400, status } as unknown as Response,
40
+ cacheStatus: null,
41
+ }
42
+ }
43
+
44
+ describe('AuthSessionGuard', () => {
45
+ beforeEach(() => {
46
+ apiCallMock.mockReset()
47
+ })
48
+
49
+ afterEach(() => {
50
+ restoreLocation()
51
+ })
52
+
53
+ it('reloads when the cookie identity differs from the server-rendered identity', async () => {
54
+ const reload = mockLocationReload()
55
+ apiCallMock.mockResolvedValue(makeApiResult({ userId: 'admin-id' }))
56
+
57
+ render(<AuthSessionGuard serverUserId="employee-id" />)
58
+
59
+ await act(async () => {
60
+ window.dispatchEvent(new Event('focus'))
61
+ })
62
+
63
+ await waitFor(() => {
64
+ expect(reload).toHaveBeenCalledTimes(1)
65
+ })
66
+ })
67
+
68
+ it('reloads when the cookie session has been cleared but the page still has a user', async () => {
69
+ const reload = mockLocationReload()
70
+ apiCallMock.mockResolvedValue(makeApiResult({ status: 401 }))
71
+
72
+ render(<AuthSessionGuard serverUserId="employee-id" />)
73
+
74
+ await act(async () => {
75
+ window.dispatchEvent(new Event('focus'))
76
+ })
77
+
78
+ await waitFor(() => {
79
+ expect(reload).toHaveBeenCalledTimes(1)
80
+ })
81
+ })
82
+
83
+ it('does not reload when the cookie identity still matches', async () => {
84
+ const reload = mockLocationReload()
85
+ apiCallMock.mockResolvedValue(makeApiResult({ userId: 'employee-id' }))
86
+
87
+ render(<AuthSessionGuard serverUserId="employee-id" />)
88
+
89
+ await act(async () => {
90
+ window.dispatchEvent(new Event('focus'))
91
+ })
92
+
93
+ await waitFor(() => {
94
+ expect(apiCallMock).toHaveBeenCalled()
95
+ })
96
+ expect(reload).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('does not poll while the tab is hidden', async () => {
100
+ mockLocationReload()
101
+ apiCallMock.mockResolvedValue(makeApiResult({ userId: 'employee-id' }))
102
+
103
+ Object.defineProperty(document, 'visibilityState', {
104
+ configurable: true,
105
+ get: () => 'hidden',
106
+ })
107
+
108
+ render(<AuthSessionGuard serverUserId="employee-id" />)
109
+
110
+ await act(async () => {
111
+ document.dispatchEvent(new Event('visibilitychange'))
112
+ })
113
+
114
+ expect(apiCallMock).not.toHaveBeenCalled()
115
+
116
+ Object.defineProperty(document, 'visibilityState', {
117
+ configurable: true,
118
+ get: () => 'visible',
119
+ })
120
+ })
121
+
122
+ it('reacts to cross-tab storage broadcasts', async () => {
123
+ const reload = mockLocationReload()
124
+ apiCallMock.mockResolvedValue(makeApiResult({ userId: 'admin-id' }))
125
+
126
+ render(<AuthSessionGuard serverUserId="employee-id" />)
127
+
128
+ await act(async () => {
129
+ window.dispatchEvent(
130
+ new StorageEvent('storage', {
131
+ key: AUTH_IDENTITY_STORAGE_KEY,
132
+ newValue: String(Date.now()),
133
+ }),
134
+ )
135
+ })
136
+
137
+ await waitFor(() => {
138
+ expect(reload).toHaveBeenCalledTimes(1)
139
+ })
140
+ })
141
+ })
142
+
143
+ describe('notifyAuthIdentityChange', () => {
144
+ it('writes a sentinel value to localStorage so other tabs receive a storage event', () => {
145
+ const setItemSpy = jest.spyOn(window.localStorage.__proto__, 'setItem')
146
+ notifyAuthIdentityChange()
147
+ expect(setItemSpy).toHaveBeenCalledWith(AUTH_IDENTITY_STORAGE_KEY, expect.any(String))
148
+ setItemSpy.mockRestore()
149
+ })
150
+ })