@@ -237,10 +237,10 @@ def test_api_documents_move_authenticated_no_owner_user_and_team():
237237
238238 document .refresh_from_db ()
239239 assert list (document .get_children ()) == [child ]
240- assert document .accesses .count () == 3
240+
241+ assert document .accesses .count () == 2
241242 assert document .accesses .get (user__isnull = False , role = "owner" ).user == parent_owner
242243 assert document .accesses .get (user__isnull = True , role = "owner" ).team == "lasuite"
243- assert document .accesses .get (role = "administrator" ).user == user
244244
245245
246246def test_api_documents_move_authenticated_no_owner_same_user ():
@@ -304,8 +304,10 @@ def test_api_documents_move_authenticated_no_owner_same_team():
304304
305305 document .refresh_from_db ()
306306 assert list (document .get_children ()) == [child ]
307- assert document .accesses .count () == 2
308- assert document .accesses .get (user__isnull = False , role = "administrator" ).user == user
307+
308+ # The user's direct administrator access was wiped during the cross-tree move;
309+ # only the "lasuite" team remains, re-added as owner from the previous root.
310+ assert document .accesses .count () == 1
309311 assert document .accesses .get (user__isnull = True , role = "owner" ).team == "lasuite"
310312
311313
@@ -438,3 +440,269 @@ def test_api_documents_move_authenticated_deleted_target_as_sibling(position):
438440 # Verify that the document has not moved
439441 document .refresh_from_db ()
440442 assert document .is_root () is True
443+
444+
445+ def test_api_documents_move_root_deletes_accesses_and_invitations ():
446+ """
447+ Moving a root document should automatically delete all its direct accesses and
448+ invitations so it inherits the target's permissions.
449+ """
450+ user = factories .UserFactory ()
451+ client = APIClient ()
452+ client .force_login (user )
453+
454+ other_user = factories .UserFactory ()
455+ document = factories .DocumentFactory (
456+ users = [(user , "owner" ), (other_user , "editor" )]
457+ )
458+ factories .InvitationFactory (document = document )
459+ factories .InvitationFactory (document = document )
460+
461+ target = factories .DocumentFactory (users = [(user , "owner" )])
462+
463+ assert document .is_root ()
464+ assert document .accesses .count () == 2
465+ assert document .invitations .count () == 2
466+
467+ response = client .post (
468+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
469+ data = {"target_document_id" : str (target .id ), "position" : "first-child" },
470+ )
471+
472+ assert response .status_code == 200
473+ assert response .json () == {"message" : "Document moved successfully." }
474+
475+ document .refresh_from_db ()
476+ assert document .is_child_of (target )
477+ assert document .accesses .count () == 0
478+ assert document .invitations .count () == 0
479+
480+
481+ def test_api_documents_move_cross_tree_deletes_accesses_and_invitations ():
482+ """
483+ Moving a non-root document to a different tree should delete its direct accesses
484+ and invitations because it is leaving its current permission scope.
485+ """
486+ user = factories .UserFactory ()
487+ client = APIClient ()
488+ client .force_login (user )
489+
490+ other_user = factories .UserFactory ()
491+ source_root = factories .DocumentFactory (users = [(user , "owner" )])
492+ document = factories .DocumentFactory (
493+ parent = source_root , users = [(user , "owner" ), (other_user , "editor" )]
494+ )
495+ factories .InvitationFactory (document = document )
496+
497+ target_root = factories .DocumentFactory (users = [(user , "owner" )])
498+ target = factories .DocumentFactory (parent = target_root , users = [(user , "owner" )])
499+
500+ assert not document .is_root ()
501+ assert document .get_root () != target .get_root ()
502+ assert document .accesses .count () == 2
503+ assert document .invitations .count () == 1
504+
505+ response = client .post (
506+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
507+ data = {"target_document_id" : str (target .id ), "position" : "first-child" },
508+ )
509+
510+ assert response .status_code == 200
511+ assert response .json () == {"message" : "Document moved successfully." }
512+
513+ document .refresh_from_db ()
514+ assert document .is_child_of (target )
515+ assert document .accesses .count () == 0
516+ assert document .invitations .count () == 0
517+
518+
519+ def test_api_documents_move_same_tree_keeps_accesses_and_invitations ():
520+ """
521+ Moving a non-root document within the same tree should preserve its direct
522+ accesses and invitations since it stays in the same permission scope.
523+ """
524+ user = factories .UserFactory ()
525+ client = APIClient ()
526+ client .force_login (user )
527+
528+ other_user = factories .UserFactory ()
529+ root = factories .DocumentFactory (users = [(user , "owner" )])
530+ document = factories .DocumentFactory (
531+ parent = root , users = [(user , "owner" ), (other_user , "editor" )]
532+ )
533+ factories .InvitationFactory (document = document )
534+ target = factories .DocumentFactory (parent = root , users = [(user , "owner" )])
535+
536+ assert not document .is_root ()
537+ assert document .get_root () == target .get_root ()
538+ assert document .accesses .count () == 2
539+ assert document .invitations .count () == 1
540+
541+ response = client .post (
542+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
543+ data = {"target_document_id" : str (target .id ), "position" : "first-child" },
544+ )
545+
546+ assert response .status_code == 200
547+ assert response .json () == {"message" : "Document moved successfully." }
548+
549+ document .refresh_from_db ()
550+ assert document .is_child_of (target )
551+ assert document .accesses .count () == 2
552+ assert document .invitations .count () == 1
553+
554+
555+ @pytest .mark .parametrize ("position" , ["first-sibling" , "last-sibling" , "left" , "right" ])
556+ def test_api_documents_move_sub_document_to_root_deletes_accesses_and_invitations (
557+ position ,
558+ ):
559+ """
560+ Moving a sub-document to root level (as a sibling of a root document) changes its
561+ permission scope. Its direct accesses and invitations must be deleted.
562+ """
563+ user = factories .UserFactory ()
564+ client = APIClient ()
565+ client .force_login (user )
566+
567+ other_user = factories .UserFactory ()
568+ parent = factories .DocumentFactory (users = [(user , "owner" )])
569+ document = factories .DocumentFactory (
570+ parent = parent , users = [(user , "owner" ), (other_user , "editor" )]
571+ )
572+ factories .InvitationFactory (document = document )
573+
574+ # target is a root document; moving as its sibling promotes document to root level
575+ target = factories .DocumentFactory (users = [(user , "owner" )])
576+
577+ assert not document .is_root ()
578+ assert document .accesses .count () == 2
579+ assert document .invitations .count () == 1
580+
581+ response = client .post (
582+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
583+ data = {"target_document_id" : str (target .id ), "position" : position },
584+ )
585+
586+ assert response .status_code == 200
587+ document .refresh_from_db ()
588+ assert document .is_root ()
589+ # Direct accesses and invitations are wiped; the backend then ensures at least one
590+ # owner exists by inheriting the owner(s) from the previous root.
591+ assert document .accesses .count () == 1
592+ assert document .accesses .filter (role = "owner" ).exists ()
593+ assert document .invitations .count () == 0
594+
595+
596+ @pytest .mark .parametrize ("position" , ["first-sibling" , "last-sibling" , "left" , "right" ])
597+ def test_api_documents_move_sub_document_as_sibling_of_its_own_root_deletes_accesses (
598+ position ,
599+ ):
600+ """
601+ Moving a sub-document as a sibling of its current root promotes it to a
602+ new root. Even though before the move both share the same root, the document is
603+ leaving its permission scope and its direct accesses/invitations must be deleted.
604+ """
605+ user = factories .UserFactory ()
606+ client = APIClient ()
607+ client .force_login (user )
608+
609+ other_user = factories .UserFactory ()
610+ root = factories .DocumentFactory (users = [(user , "owner" )])
611+ document = factories .DocumentFactory (
612+ parent = root , users = [(user , "owner" ), (other_user , "editor" )]
613+ )
614+ factories .InvitationFactory (document = document )
615+
616+ assert not document .is_root ()
617+ assert document .get_root () == root
618+ assert document .accesses .count () == 2
619+ assert document .invitations .count () == 1
620+
621+ response = client .post (
622+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
623+ data = {"target_document_id" : str (root .id ), "position" : position },
624+ )
625+
626+ assert response .status_code == 200
627+ document .refresh_from_db ()
628+ assert document .is_root ()
629+ # Original accesses wiped; owner re-inherited from previous root ensures non-orphaned.
630+ assert document .accesses .count () == 1
631+ assert document .accesses .filter (role = "owner" ).exists ()
632+ assert document .invitations .count () == 0
633+
634+
635+ @pytest .mark .parametrize ("position" , ["first-sibling" , "last-sibling" , "left" , "right" ])
636+ def test_api_documents_move_root_as_sibling_of_root_preserves_owner (position ):
637+ """
638+ Moving a root document as sibling of another root, the owners
639+ collected from the previous root (which is the document itself) must survive the
640+ pre-move access deletion, so the document keeps at least one owner afterwards.
641+ """
642+ user = factories .UserFactory ()
643+ client = APIClient ()
644+ client .force_login (user )
645+
646+ other_user = factories .UserFactory ()
647+ document = factories .DocumentFactory (
648+ users = [(user , "owner" ), (other_user , "editor" )]
649+ )
650+ factories .InvitationFactory (document = document )
651+
652+ target = factories .DocumentFactory (users = [(user , "owner" )])
653+
654+ assert document .is_root ()
655+ assert target .is_root ()
656+
657+ response = client .post (
658+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
659+ data = {"target_document_id" : str (target .id ), "position" : position },
660+ )
661+
662+ assert response .status_code == 200
663+ document .refresh_from_db ()
664+ assert document .is_root ()
665+ # The original editor access and invitation are wiped; the previous root owner
666+ # is re-added so the document still has at least one owner.
667+ assert document .accesses .count () == 1
668+ assert document .accesses .get (role = "owner" ).user == user
669+ assert document .invitations .count () == 0
670+
671+
672+ def test_api_documents_move_scope_change_deletion_is_atomic (monkeypatch ):
673+ """
674+ When accesses/invitations are to be deleted (root or cross-tree move), both
675+ deletions and the tree move are atomic: if the move fails, deletions roll back.
676+ """
677+ user = factories .UserFactory ()
678+ client = APIClient ()
679+ client .force_login (user )
680+
681+ other_user = factories .UserFactory ()
682+ document = factories .DocumentFactory (
683+ users = [(user , "owner" ), (other_user , "editor" )]
684+ )
685+ factories .InvitationFactory (document = document )
686+ target = factories .DocumentFactory (users = [(user , "owner" )])
687+
688+ assert document .is_root ()
689+ assert document .accesses .count () == 2
690+ assert document .invitations .count () == 1
691+
692+ # Force Document.move to fail *after* the deletion block has already run,
693+ # so we actually exercise the rollback path rather than bailing out earlier.
694+ def failing_move (self , * args , ** kwargs ):
695+ raise RuntimeError ("Simulated move failure for atomicity test" )
696+
697+ monkeypatch .setattr (models .Document , "move" , failing_move )
698+
699+ with pytest .raises (RuntimeError , match = "Simulated move failure" ):
700+ client .post (
701+ f"/api/v1.0/documents/{ document .id !s} /move/" ,
702+ data = {"target_document_id" : str (target .id ), "position" : "first-sibling" },
703+ )
704+
705+ # Accesses and invitations must still exist due to transaction rollback
706+ document .refresh_from_db ()
707+ assert document .accesses .count () == 2
708+ assert document .invitations .count () == 1
0 commit comments