@@ -15,6 +15,8 @@ vi.mock('@/lib/api/client/request', () => ({
1515 requestJson : mockRequestJson ,
1616} ) )
1717
18+ import { ApiClientError } from '@/lib/api/client/errors'
19+ import { createPinnedItemContract , deletePinnedItemContract } from '@/lib/api/contracts'
1820import { useToggleWorkspacePin , workspaceKeys } from '@/hooks/queries/workspace'
1921
2022/** Trees rendered by a test, torn down in afterEach so observers do not leak across tests. */
@@ -62,6 +64,20 @@ async function flush() {
6264 } )
6365}
6466
67+ function seedList ( queryClient : QueryClient , pinnedWorkspaceIds : string [ ] ) {
68+ queryClient . setQueryData ( workspaceKeys . list ( 'active' ) , {
69+ workspaces : [ ] ,
70+ lastActiveWorkspaceId : null ,
71+ pinnedWorkspaceIds,
72+ creationPolicy : null ,
73+ } )
74+ }
75+
76+ function readPins ( queryClient : QueryClient ) : string [ ] | undefined {
77+ return queryClient . getQueryData < { pinnedWorkspaceIds : string [ ] } > ( workspaceKeys . list ( 'active' ) )
78+ ?. pinnedWorkspaceIds
79+ }
80+
6581afterEach ( ( ) => {
6682 act ( ( ) => {
6783 for ( const root of mountedRoots . splice ( 0 ) ) root . unmount ( )
@@ -73,96 +89,98 @@ beforeEach(() => {
7389} )
7490
7591describe ( 'useToggleWorkspacePin' , ( ) => {
76- /**
77- * Each request carries the whole pin list, so overlapping writes that the network
78- * delivers out of order would let the earlier click win. The hook chains them, so
79- * the second request must not be issued until the first has resolved.
80- */
81- it ( 'serializes overlapping writes so the last click is what persists' , async ( ) => {
82- const resolvers : Array < ( ) => void > = [ ]
83- mockRequestJson . mockImplementation (
84- ( ) =>
85- new Promise < { success : true } > ( ( resolve ) => {
86- resolvers . push ( ( ) => resolve ( { success : true } ) )
87- } )
88- )
89-
90- const { getResult } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
92+ it ( 'pins by creating a row addressed to the workspace itself' , async ( ) => {
93+ mockRequestJson . mockResolvedValue ( { pinnedItem : { } } )
94+ const { getResult, queryClient } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
95+ seedList ( queryClient , [ ] )
9196
9297 act ( ( ) => {
93- getResult ( ) . mutate ( { pinnedWorkspaceIds : [ 'ws-a' ] } )
94- getResult ( ) . mutate ( { pinnedWorkspaceIds : [ 'ws-a' , 'ws-b' ] } )
98+ getResult ( ) . mutate ( { workspaceId : 'ws-a' , pinned : true } )
9599 } )
96100 await flush ( )
97101
98- // Only the first write is on the wire; the second is queued behind it.
99- expect ( mockRequestJson ) . toHaveBeenCalledOnce ( )
100- expect ( mockRequestJson . mock . calls [ 0 ] [ 1 ] ) . toMatchObject ( {
101- body : { pinnedWorkspaceIds : [ 'ws-a' ] } ,
102+ expect ( mockRequestJson ) . toHaveBeenCalledWith ( createPinnedItemContract , {
103+ body : { workspaceId : 'ws-a' , resourceType : 'workspace' , resourceId : 'ws-a' } ,
102104 } )
105+ } )
106+
107+ it ( 'unpins by deleting that row' , async ( ) => {
108+ mockRequestJson . mockResolvedValue ( { success : true } )
109+ const { getResult, queryClient } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
110+ seedList ( queryClient , [ 'ws-a' ] )
103111
104- act ( ( ) => resolvers [ 0 ] ( ) )
112+ act ( ( ) => {
113+ getResult ( ) . mutate ( { workspaceId : 'ws-a' , pinned : false } )
114+ } )
105115 await flush ( )
106116
107- expect ( mockRequestJson ) . toHaveBeenCalledTimes ( 2 )
108- expect ( mockRequestJson . mock . calls [ 1 ] [ 1 ] ) . toMatchObject ( {
109- body : { pinnedWorkspaceIds : [ 'ws-a' , 'ws-b' ] } ,
117+ expect ( mockRequestJson ) . toHaveBeenCalledWith ( deletePinnedItemContract , {
118+ params : { resourceType : 'workspace' , resourceId : 'ws-a' } ,
110119 } )
120+ expect ( readPins ( queryClient ) ) . toEqual ( [ ] )
111121 } )
112122
113123 /**
114- * Refetching between two queued writes would render the server's pre-second-write
115- * state and visibly bounce the row out of the pinned group and back .
124+ * Two rapid toggles write different rows, so neither can overwrite the other and
125+ * both survive regardless of the order the requests land in .
116126 */
117- it ( 'reconciles only after the last queued write settles ' , async ( ) => {
127+ it ( 'keeps both pins when two toggles overlap ' , async ( ) => {
118128 const resolvers : Array < ( ) => void > = [ ]
119129 mockRequestJson . mockImplementation (
120- ( ) =>
121- new Promise < { success : true } > ( ( resolve ) => {
122- resolvers . push ( ( ) => resolve ( { success : true } ) )
123- } )
130+ ( ) => new Promise ( ( resolve ) => resolvers . push ( ( ) => resolve ( { pinnedItem : { } } ) ) )
124131 )
125-
126132 const { getResult, queryClient } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
127- const invalidateSpy = vi . spyOn ( queryClient , 'invalidateQueries' )
133+ seedList ( queryClient , [ ] )
128134
129135 act ( ( ) => {
130- getResult ( ) . mutate ( { pinnedWorkspaceIds : [ 'ws-a' ] } )
131- getResult ( ) . mutate ( { pinnedWorkspaceIds : [ 'ws-a ' , 'ws-b' ] } )
136+ getResult ( ) . mutate ( { workspaceId : 'ws-a' , pinned : true } )
137+ getResult ( ) . mutate ( { workspaceId : 'ws-b ' , pinned : true } )
132138 } )
133139 await flush ( )
134140
135- act ( ( ) => resolvers [ 0 ] ( ) )
141+ expect ( readPins ( queryClient ) ) . toEqual ( [ 'ws-a' , 'ws-b' ] )
142+
143+ // Resolve out of order — the older request landing last must not undo the newer.
144+ act ( ( ) => {
145+ resolvers [ 1 ] ( )
146+ resolvers [ 0 ] ( )
147+ } )
136148 await flush ( )
137149
138- // First write settled, second still outstanding — nothing reconciled yet.
139- expect ( invalidateSpy ) . not . toHaveBeenCalled ( )
150+ const bodies = mockRequestJson . mock . calls . map ( ( call ) => call [ 1 ] . body . resourceId )
151+ expect ( bodies ) . toEqual ( [ 'ws-a' , 'ws-b' ] )
152+ expect ( readPins ( queryClient ) ) . toEqual ( [ 'ws-a' , 'ws-b' ] )
153+ } )
140154
141- act ( ( ) => resolvers [ 1 ] ( ) )
155+ /** Re-pinning an already-pinned workspace is the desired end state, not a failure. */
156+ it ( 'treats a 409 from a duplicate pin as success' , async ( ) => {
157+ mockRequestJson . mockRejectedValue (
158+ new ApiClientError ( { status : 409 , message : 'This item is already pinned' , body : { } } )
159+ )
160+ const { getResult, queryClient } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
161+ seedList ( queryClient , [ ] )
162+
163+ act ( ( ) => {
164+ getResult ( ) . mutate ( { workspaceId : 'ws-a' , pinned : true } )
165+ } )
142166 await flush ( )
143167
144- expect ( invalidateSpy ) . toHaveBeenCalledWith ( { queryKey : workspaceKeys . lists ( ) } )
168+ expect ( getResult ( ) . isError ) . toBe ( false )
169+ expect ( readPins ( queryClient ) ) . toEqual ( [ 'ws-a' ] )
145170 } )
146171
147- it ( 'applies the pin optimistically before the request resolves' , async ( ) => {
148- mockRequestJson . mockImplementation ( ( ) => new Promise < { success : true } > ( ( ) => { } ) )
149-
172+ it ( 'rolls the optimistic pin back when the write fails' , async ( ) => {
173+ mockRequestJson . mockRejectedValue (
174+ new ApiClientError ( { status : 500 , message : 'boom' , body : { } } )
175+ )
150176 const { getResult, queryClient } = renderHookWithClient ( ( ) => useToggleWorkspacePin ( ) )
151- queryClient . setQueryData ( workspaceKeys . list ( 'active' ) , {
152- workspaces : [ ] ,
153- lastActiveWorkspaceId : null ,
154- pinnedWorkspaceIds : [ ] ,
155- creationPolicy : null ,
156- } )
177+ seedList ( queryClient , [ ] )
157178
158179 act ( ( ) => {
159- getResult ( ) . mutate ( { pinnedWorkspaceIds : [ 'ws-a' ] } )
180+ getResult ( ) . mutate ( { workspaceId : 'ws-a' , pinned : true } )
160181 } )
161182 await flush ( )
162183
163- expect (
164- queryClient . getQueryData < { pinnedWorkspaceIds : string [ ] } > ( workspaceKeys . list ( 'active' ) )
165- ?. pinnedWorkspaceIds
166- ) . toEqual ( [ 'ws-a' ] )
184+ expect ( readPins ( queryClient ) ) . toEqual ( [ ] )
167185 } )
168186} )
0 commit comments