11/**
22 * @vitest -environment jsdom
33 */
4- import { act } from 'react'
5- import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
6- import { createRoot } from 'react-dom/client'
7- import { afterEach , describe , expect , it , vi } from 'vitest'
4+ import { act , type ReactNode } from 'react'
5+ import { sleep } from '@sim/utils/helpers'
6+ import { QueryClient , QueryClientProvider , useQuery } from '@tanstack/react-query'
7+ import { createRoot , type Root } from 'react-dom/client'
8+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
89
9- const { mockFetchWorkspaceEnvironment } = vi . hoisted ( ( ) => ( {
10+ const { mockFetchWorkspaceEnvironment, mockRequestJson } = vi . hoisted ( ( ) => ( {
1011 mockFetchWorkspaceEnvironment : vi . fn ( ) ,
12+ mockRequestJson : vi . fn ( ) ,
1113} ) )
1214
15+ vi . mock ( '@/lib/api/client/request' , ( ) => ( { requestJson : mockRequestJson } ) )
16+
1317vi . mock ( '@/lib/environment/api' , ( ) => ( {
1418 fetchPersonalEnvironment : vi . fn ( ) ,
1519 fetchWorkspaceEnvironment : mockFetchWorkspaceEnvironment ,
1620} ) )
1721
18- import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
22+ import type { QueryKey } from '@tanstack/react-query'
23+ import {
24+ environmentKeys ,
25+ useRemoveWorkspaceEnvironment ,
26+ useSavePersonalEnvironment ,
27+ useUpsertWorkspaceEnvironment ,
28+ useWorkspaceEnvironment ,
29+ } from '@/hooks/queries/environment'
30+ import { environmentDependentSelectorKeys } from '@/hooks/selectors/cache-invalidation'
1931
2032function renderWorkspaceEnvironment ( workspaceId : string , enabled ?: boolean ) {
2133 ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
@@ -41,10 +53,112 @@ function renderWorkspaceEnvironment(workspaceId: string, enabled?: boolean) {
4153 return ( ) => act ( ( ) => root . unmount ( ) )
4254}
4355
44- afterEach ( ( ) => {
56+ interface CacheQueryFns {
57+ environment : ReturnType < typeof vi . fn >
58+ primary : ReturnType < typeof vi . fn >
59+ dynamicDetails : ReturnType < typeof vi . fn >
60+ workflowDetails : ReturnType < typeof vi . fn >
61+ workflowReplacementOptions : ReturnType < typeof vi . fn >
62+ unrelated : ReturnType < typeof vi . fn >
63+ }
64+
65+ function createCacheQueryFns ( ) : CacheQueryFns {
66+ return {
67+ environment : vi . fn ( ) . mockResolvedValue ( 'environment' ) ,
68+ primary : vi . fn ( ) . mockResolvedValue ( 'primary' ) ,
69+ dynamicDetails : vi . fn ( ) . mockResolvedValue ( 'dynamic' ) ,
70+ workflowDetails : vi . fn ( ) . mockResolvedValue ( 'workflow-detail' ) ,
71+ workflowReplacementOptions : vi . fn ( ) . mockResolvedValue ( 'workflow-options' ) ,
72+ unrelated : vi . fn ( ) . mockResolvedValue ( 'unrelated' ) ,
73+ }
74+ }
75+
76+ function renderMutationWithCaches < T > (
77+ useMutationHook : ( ) => T ,
78+ environmentQueryKey : QueryKey ,
79+ queryFns : CacheQueryFns
80+ ) : { result : ( ) => T ; unmount : ( ) => void } {
81+ ; ( globalThis as { IS_REACT_ACT_ENVIRONMENT ?: boolean } ) . IS_REACT_ACT_ENVIRONMENT = true
82+ const queryClient = new QueryClient ( {
83+ defaultOptions : {
84+ queries : { retry : false , staleTime : Number . POSITIVE_INFINITY } ,
85+ mutations : { retry : false } ,
86+ } ,
87+ } )
88+ const container = document . createElement ( 'div' )
89+ const root : Root = createRoot ( container )
90+ let latest : T
91+
92+ function Probe ( ) {
93+ useQuery ( { queryKey : environmentQueryKey , queryFn : queryFns . environment } )
94+ useQuery ( {
95+ queryKey : [ ...environmentDependentSelectorKeys . primary , 'jira.projects' , 'scope' ] ,
96+ queryFn : queryFns . primary ,
97+ } )
98+ useQuery ( {
99+ queryKey : [ ...environmentDependentSelectorKeys . dynamicDetails , 'jira.project' , 'scope' ] ,
100+ queryFn : queryFns . dynamicDetails ,
101+ } )
102+ useQuery ( {
103+ queryKey : [ ...environmentDependentSelectorKeys . workflowDetails , 'jira.projects' , 'scope' ] ,
104+ queryFn : queryFns . workflowDetails ,
105+ } )
106+ useQuery ( {
107+ queryKey : [
108+ ...environmentDependentSelectorKeys . workflowReplacementOptions ,
109+ 'jira.projects' ,
110+ 'scope' ,
111+ ] ,
112+ queryFn : queryFns . workflowReplacementOptions ,
113+ } )
114+ useQuery ( { queryKey : [ 'unrelated-user-data' ] , queryFn : queryFns . unrelated } )
115+ latest = useMutationHook ( )
116+ return null
117+ }
118+
119+ function Wrapper ( { children } : { children : ReactNode } ) {
120+ return < QueryClientProvider client = { queryClient } > { children } </ QueryClientProvider >
121+ }
122+
123+ act ( ( ) => {
124+ root . render (
125+ < Wrapper >
126+ < Probe />
127+ </ Wrapper >
128+ )
129+ } )
130+
131+ return {
132+ result : ( ) => latest ,
133+ unmount : ( ) => act ( ( ) => root . unmount ( ) ) ,
134+ }
135+ }
136+
137+ async function flush ( ) {
138+ await act ( async ( ) => {
139+ for ( let i = 0 ; i < 5 ; i ++ ) {
140+ await Promise . resolve ( )
141+ await sleep ( 0 )
142+ }
143+ } )
144+ }
145+
146+ function expectOnlyEnvironmentAndSelectorCachesRefetched ( queryFns : CacheQueryFns ) {
147+ expect ( queryFns . environment ) . toHaveBeenCalledTimes ( 2 )
148+ expect ( queryFns . primary ) . toHaveBeenCalledTimes ( 2 )
149+ expect ( queryFns . dynamicDetails ) . toHaveBeenCalledTimes ( 2 )
150+ expect ( queryFns . workflowDetails ) . toHaveBeenCalledTimes ( 2 )
151+ expect ( queryFns . workflowReplacementOptions ) . toHaveBeenCalledTimes ( 2 )
152+ expect ( queryFns . unrelated ) . toHaveBeenCalledTimes ( 1 )
153+ }
154+
155+ beforeEach ( ( ) => {
45156 vi . clearAllMocks ( )
157+ mockRequestJson . mockResolvedValue ( { success : true } )
46158} )
47159
160+ afterEach ( ( ) => vi . restoreAllMocks ( ) )
161+
48162describe ( 'useWorkspaceEnvironment' , ( ) => {
49163 it ( 'does not run without a workspace ID even when the caller enables it' , ( ) => {
50164 const unmount = renderWorkspaceEnvironment ( '' , true )
@@ -60,3 +174,86 @@ describe('useWorkspaceEnvironment', () => {
60174 unmount ( )
61175 } )
62176} )
177+
178+ describe ( 'environment mutation selector freshness' , ( ) => {
179+ it ( 'refetches every selector-bearing cache after a successful personal save' , async ( ) => {
180+ const queryFns = createCacheQueryFns ( )
181+ const view = renderMutationWithCaches (
182+ useSavePersonalEnvironment ,
183+ environmentKeys . personal ( ) ,
184+ queryFns
185+ )
186+ await flush ( )
187+
188+ await act ( async ( ) => {
189+ await view . result ( ) . mutateAsync ( { variables : { DOMAIN : 'new-value' } } )
190+ } )
191+ await flush ( )
192+
193+ expectOnlyEnvironmentAndSelectorCachesRefetched ( queryFns )
194+ view . unmount ( )
195+ } )
196+
197+ it . each ( [
198+ {
199+ name : 'upsert' ,
200+ useMutationHook : useUpsertWorkspaceEnvironment ,
201+ variables : { workspaceId : 'workspace-1' , variables : { DOMAIN : 'new-value' } } ,
202+ } ,
203+ {
204+ name : 'removal' ,
205+ useMutationHook : useRemoveWorkspaceEnvironment ,
206+ variables : { workspaceId : 'workspace-1' , keys : [ 'DOMAIN' ] } ,
207+ } ,
208+ ] ) ( 'refetches every selector-bearing cache after a successful workspace $name' , async ( test ) => {
209+ const queryFns = createCacheQueryFns ( )
210+ const view = renderMutationWithCaches (
211+ test . useMutationHook ,
212+ environmentKeys . workspace ( 'workspace-1' ) ,
213+ queryFns
214+ )
215+ await flush ( )
216+
217+ await act ( async ( ) => {
218+ const mutation = view . result ( ) as {
219+ mutateAsync : ( variables : unknown ) => Promise < unknown >
220+ }
221+ await mutation . mutateAsync ( test . variables )
222+ } )
223+ await flush ( )
224+
225+ expectOnlyEnvironmentAndSelectorCachesRefetched ( queryFns )
226+ view . unmount ( )
227+ } )
228+
229+ it ( 'does not invalidate selector caches after a failed environment mutation' , async ( ) => {
230+ const requestError = new Error ( 'save failed' )
231+ mockRequestJson . mockRejectedValueOnce ( requestError )
232+ const queryFns = createCacheQueryFns ( )
233+ const view = renderMutationWithCaches (
234+ useSavePersonalEnvironment ,
235+ environmentKeys . personal ( ) ,
236+ queryFns
237+ )
238+ await flush ( )
239+
240+ let caught : unknown
241+ await act ( async ( ) => {
242+ try {
243+ await view . result ( ) . mutateAsync ( { variables : { DOMAIN : 'new-value' } } )
244+ } catch ( error ) {
245+ caught = error
246+ }
247+ } )
248+ await flush ( )
249+
250+ expect ( caught ) . toBe ( requestError )
251+ expect ( queryFns . environment ) . toHaveBeenCalledTimes ( 2 )
252+ expect ( queryFns . primary ) . toHaveBeenCalledTimes ( 1 )
253+ expect ( queryFns . dynamicDetails ) . toHaveBeenCalledTimes ( 1 )
254+ expect ( queryFns . workflowDetails ) . toHaveBeenCalledTimes ( 1 )
255+ expect ( queryFns . workflowReplacementOptions ) . toHaveBeenCalledTimes ( 1 )
256+ expect ( queryFns . unrelated ) . toHaveBeenCalledTimes ( 1 )
257+ view . unmount ( )
258+ } )
259+ } )
0 commit comments