@@ -185,6 +185,110 @@ async fn child_task_inherits_parent_accountable_via_resolver_rule() {
185185 }
186186}
187187
188+ #[ tokio:: test( flavor = "multi_thread" ) ]
189+ async fn pin_handler_cascades_to_descendants_without_manual_remateriaise ( ) {
190+ // Drives the *real* HTTP pin handler, which now runs
191+ // `materialise_task_and_descendants` after writing the parent pin.
192+ // No manual child re-materialise β if the cascade is wired right,
193+ // the child's accountable updates automatically.
194+ let state = build_sqlite_app_state ( ) . await ;
195+ let user = "user-deep-cascade-pin" ;
196+ let ( project, wf, parent) = seed_world ( & state, user, "cascade-pin" ) ;
197+
198+ // Add a second user we can pin to.
199+ let pin_target_id = format ! ( "uuid-{}" , uuid:: Uuid :: new_v4( ) ) ;
200+ {
201+ let store = state. store . try_lock ( ) . expect ( "store" ) ;
202+ store
203+ . insert_user ( & make_user ( & pin_target_id, "Pin Target" ) )
204+ . unwrap ( ) ;
205+ store
206+ . add_project_member ( & project. id , "user" , & pin_target_id, Some ( "member" ) )
207+ . unwrap ( ) ;
208+ }
209+
210+ // Spawn a child + grandchild β both inherit accountable via parent_role.
211+ let mut child = Task :: new ( "child" , "bug" , & wf. id , "backlog" ) ;
212+ child. id = format ! ( "task-cas-c-{}" , uuid:: Uuid :: new_v4( ) ) ;
213+ child. project_id = Some ( project. id . clone ( ) ) ;
214+ child. parent_id = Some ( parent. id . clone ( ) ) ;
215+ child. assignees = vec ! [ oversight_models:: TaskAssignee :: executor( "agent:claude" ) ] ;
216+
217+ let mut grandchild = Task :: new ( "grandchild" , "bug" , & wf. id , "backlog" ) ;
218+ grandchild. id = format ! ( "task-cas-gc-{}" , uuid:: Uuid :: new_v4( ) ) ;
219+ grandchild. project_id = Some ( project. id . clone ( ) ) ;
220+ grandchild. parent_id = Some ( child. id . clone ( ) ) ;
221+ grandchild. assignees = vec ! [ oversight_models:: TaskAssignee :: executor( "agent:claude" ) ] ;
222+
223+ {
224+ let store = state. store . try_lock ( ) . expect ( "store" ) ;
225+ store. insert_task ( & child) . unwrap ( ) ;
226+ materialise ( & store, & child) ;
227+ store. insert_task ( & grandchild) . unwrap ( ) ;
228+ materialise ( & store, & grandchild) ;
229+
230+ // Sanity: both descendants currently inherit `user`.
231+ let c_acc = store
232+ . get_role_assignment ( & child. id , RoleName :: Accountable )
233+ . unwrap ( )
234+ . unwrap ( ) ;
235+ assert_eq ! ( c_acc. actor_id, user, "child starts inheriting from parent" ) ;
236+ let gc_acc = store
237+ . get_role_assignment ( & grandchild. id , RoleName :: Accountable )
238+ . unwrap ( )
239+ . unwrap ( ) ;
240+ assert_eq ! (
241+ gc_acc. actor_id, user,
242+ "grandchild starts inheriting transitively"
243+ ) ;
244+ }
245+
246+ // Drive the real handler.
247+ grant_admin ( & state, user) ;
248+ reload_authorizer ( & state) ;
249+ let app = build_api_router ( state. clone ( ) ) ;
250+ let resp = app
251+ . oneshot (
252+ Request :: builder ( )
253+ . method ( "POST" )
254+ . uri ( format ! ( "/api/tasks/{}/roles/accountable/pin" , parent. id) )
255+ . header ( "content-type" , "application/json" )
256+ . header ( "x-actor" , format ! ( "user:{user}" ) )
257+ . body ( Body :: from ( json ! ( { "actor_id" : pin_target_id} ) . to_string ( ) ) )
258+ . unwrap ( ) ,
259+ )
260+ . await
261+ . expect ( "pin" ) ;
262+ assert_eq ! ( resp. status( ) , StatusCode :: OK ) ;
263+
264+ // No manual materialise calls. Both descendants must already see the
265+ // new pin target.
266+ let store = state. store . try_lock ( ) . expect ( "store" ) ;
267+ let parent_acc = store
268+ . get_role_assignment ( & parent. id , RoleName :: Accountable )
269+ . unwrap ( )
270+ . unwrap ( ) ;
271+ assert_eq ! ( parent_acc. actor_id, pin_target_id) ;
272+
273+ let child_acc = store
274+ . get_role_assignment ( & child. id , RoleName :: Accountable )
275+ . unwrap ( )
276+ . unwrap ( ) ;
277+ assert_eq ! (
278+ child_acc. actor_id, pin_target_id,
279+ "cascade must update direct child without manual re-materialise"
280+ ) ;
281+
282+ let grand_acc = store
283+ . get_role_assignment ( & grandchild. id , RoleName :: Accountable )
284+ . unwrap ( )
285+ . unwrap ( ) ;
286+ assert_eq ! (
287+ grand_acc. actor_id, pin_target_id,
288+ "cascade must traverse the full subtree (parent β child β grandchild)"
289+ ) ;
290+ }
291+
188292#[ tokio:: test( flavor = "multi_thread" ) ]
189293async fn parent_pin_propagates_to_child_on_remateriaise ( ) {
190294 let state = build_sqlite_app_state ( ) . await ;
0 commit comments