(https://apsl.tech)
diff --git a/fs_attachment_s3/static/description/index.html b/fs_attachment_s3/static/description/index.html
index 233c7217f1..cf5e577569 100644
--- a/fs_attachment_s3/static/description/index.html
+++ b/fs_attachment_s3/static/description/index.html
@@ -372,7 +372,7 @@ Fs Attachment S3
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:c01d32f225802fc30d7d79a6130c073016c0d98c69ba20dd6d6c0d1213e9f92d
+!! source digest: sha256:4d28167cd71224963df5f53ad5a7672299c585cf6eda6903e5bb499c3aee1718
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

This module extends the functionality of
@@ -521,6 +521,7 @@
diff --git a/fs_attachment_s3/tests/__init__.py b/fs_attachment_s3/tests/__init__.py
index a788fc4772..7102627b74 100644
--- a/fs_attachment_s3/tests/__init__.py
+++ b/fs_attachment_s3/tests/__init__.py
@@ -1 +1,2 @@
from . import test_fs_attachment_s3
+from . import test_fs_file_gc
diff --git a/fs_attachment_s3/tests/test_fs_file_gc.py b/fs_attachment_s3/tests/test_fs_file_gc.py
new file mode 100644
index 0000000000..27b0132f58
--- /dev/null
+++ b/fs_attachment_s3/tests/test_fs_file_gc.py
@@ -0,0 +1,77 @@
+# Copyright 2026 APSL-Nagarro Antoni Marroig
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from .common import TestFSAttachmentS3Common
+
+
+class TestFsFileGcS3(TestFSAttachmentS3Common):
+ def setUp(self):
+ super().setUp()
+ self.gc_file_model = self.env["fs.file.gc"]
+
+ def _mark_for_gc(self, *store_fnames):
+ for store_fname in store_fnames:
+ self.gc_file_model._mark_for_gc(store_fname)
+
+ def test_gc_s3_bulk_delete_removes_orphaned_files(self):
+ orphan_1 = "s3tst://dir/sub/orphan_1.txt"
+ orphan_2 = "s3tst://dir/sub/orphan_2.txt"
+ referenced = self.fake_attachment_s3.store_fname
+ self._mark_for_gc(orphan_1, orphan_2, referenced)
+
+ s3_client = MagicMock()
+ root_fs = SimpleNamespace(s3=s3_client)
+
+ storage_class = type(self.s3_backend)
+ with (
+ patch.object(
+ storage_class,
+ "is_s3_storage",
+ new=property(lambda storage: storage.code == self.s3_backend.code),
+ ),
+ patch.object(storage_class, "_get_root_filesystem", return_value=root_fs),
+ patch.object(type(self.env.cr), "commit", return_value=None),
+ ):
+ self.gc_file_model._gc_s3_bulk_delete()
+
+ s3_client.delete_objects.assert_called_once()
+ _, kwargs = s3_client.delete_objects.call_args
+ self.assertEqual(kwargs["Bucket"], "test-bucket")
+ self.assertCountEqual(
+ kwargs["Delete"]["Objects"],
+ [
+ {"Key": "dir/sub/orphan_1.txt"},
+ {"Key": "dir/sub/orphan_2.txt"},
+ ],
+ )
+ remaining_files = self.gc_file_model.search(
+ [("store_fname", "in", [orphan_1, orphan_2, referenced])]
+ ).mapped("store_fname")
+ self.assertNotIn(orphan_1, remaining_files)
+ self.assertNotIn(orphan_2, remaining_files)
+ self.assertIn(referenced, remaining_files)
+
+ def test_gc_s3_bulk_delete_keeps_rows_when_s3_delete_fails(self):
+ orphan = "s3tst://dir/sub/orphan.txt"
+ self._mark_for_gc(orphan)
+
+ s3_client = MagicMock()
+ s3_client.delete_objects.side_effect = Exception("S3 is unavailable")
+ root_fs = SimpleNamespace(s3=s3_client)
+
+ storage_class = type(self.s3_backend)
+ with (
+ patch.object(
+ storage_class,
+ "is_s3_storage",
+ new=property(lambda storage: storage.code == self.s3_backend.code),
+ ),
+ patch.object(storage_class, "_get_root_filesystem", return_value=root_fs),
+ patch.object(type(self.env.cr), "commit", return_value=None),
+ ):
+ self.gc_file_model._gc_s3_bulk_delete()
+
+ self.assertTrue(self.gc_file_model.search_count([("store_fname", "=", orphan)]))