@@ -34,3 +34,148 @@ def test_download(self, patched_shutil):
3434 patched_shutil .return_value .free = 0
3535 response_3 = self .client .post ("/api/photos/download" , data = datadict )
3636 self .assertEqual (response_3 .status_code , 507 )
37+
38+
39+ class ZipListPhotosV2SelectAllTest (TestCase ):
40+ """The download endpoint also accepts the select_all + query payload
41+ shape that the other bulk mutations (favorite/hide/public/delete)
42+ already support, so that the frontend can offer Download when the
43+ user has done a server-side "Select All" without enumerating every
44+ image hash. The async zip task isn't exercised in these tests; we
45+ inspect the photo set handed to create_download_job instead."""
46+
47+ def setUp (self ):
48+ self .client = APIClient ()
49+ self .user = create_test_user ()
50+ self .other_user = create_test_user ()
51+ self .client .force_authenticate (user = self .user )
52+
53+ disk_patcher = patch ("shutil.disk_usage" )
54+ patched_disk = disk_patcher .start ()
55+ patched_disk .return_value .free = 500000000
56+ self .addCleanup (disk_patcher .stop )
57+
58+ # create_download_job enqueues a django-q AsyncTask; stub it out and
59+ # capture the photos that would have been zipped instead.
60+ job_patcher = patch ("api.views.views.create_download_job" )
61+ self .mock_create_job = job_patcher .start ()
62+ self .mock_create_job .return_value = "fake-job-id"
63+ self .addCleanup (job_patcher .stop )
64+
65+ def _queued_hashes (self ):
66+ self .assertTrue (
67+ self .mock_create_job .called ,
68+ msg = "create_download_job was never reached — the view returned early" ,
69+ )
70+ photos = self .mock_create_job .call_args .kwargs ["photos" ]
71+ return {photo .image_hash for photo in photos }
72+
73+ def test_select_all_with_empty_query_includes_all_user_photos (self ):
74+ photos = create_test_photos (number_of_photos = 3 , owner = self .user , size = 100 )
75+
76+ response = self .client .post (
77+ "/api/photos/download" ,
78+ data = {"select_all" : True , "query" : {}},
79+ format = "json" ,
80+ )
81+
82+ self .assertEqual (response .status_code , 200 )
83+ self .assertEqual (self ._queued_hashes (), {p .image_hash for p in photos })
84+
85+ def test_select_all_only_includes_requesting_users_photos (self ):
86+ mine = create_test_photos (number_of_photos = 2 , owner = self .user , size = 100 )
87+ create_test_photos (number_of_photos = 2 , owner = self .other_user , size = 100 )
88+
89+ response = self .client .post (
90+ "/api/photos/download" ,
91+ data = {"select_all" : True , "query" : {}},
92+ format = "json" ,
93+ )
94+
95+ self .assertEqual (response .status_code , 200 )
96+ self .assertEqual (self ._queued_hashes (), {p .image_hash for p in mine })
97+
98+ def test_select_all_respects_excluded_hashes (self ):
99+ photos = create_test_photos (number_of_photos = 4 , owner = self .user , size = 100 )
100+ excluded = [photos [0 ].image_hash , photos [1 ].image_hash ]
101+
102+ response = self .client .post (
103+ "/api/photos/download" ,
104+ data = {
105+ "select_all" : True ,
106+ "query" : {},
107+ "excluded_hashes" : excluded ,
108+ },
109+ format = "json" ,
110+ )
111+
112+ self .assertEqual (response .status_code , 200 )
113+ self .assertEqual (self ._queued_hashes (), {p .image_hash for p in photos [2 :]})
114+
115+ def test_select_all_applies_query_filters (self ):
116+ videos = create_test_photos (
117+ number_of_photos = 2 , owner = self .user , size = 100 , video = True
118+ )
119+ create_test_photos (number_of_photos = 3 , owner = self .user , size = 100 , video = False )
120+
121+ response = self .client .post (
122+ "/api/photos/download" ,
123+ data = {"select_all" : True , "query" : {"video" : True }},
124+ format = "json" ,
125+ )
126+
127+ self .assertEqual (response .status_code , 200 )
128+ self .assertEqual (self ._queued_hashes (), {p .image_hash for p in videos })
129+
130+ def test_select_all_with_no_matching_photos_returns_404 (self ):
131+ # Two videos in the library, but the user's filter says photos-only.
132+ create_test_photos (number_of_photos = 2 , owner = self .user , size = 100 , video = True )
133+
134+ response = self .client .post (
135+ "/api/photos/download" ,
136+ data = {"select_all" : True , "query" : {"photo" : True }},
137+ format = "json" ,
138+ )
139+
140+ self .assertEqual (response .status_code , 404 )
141+ self .assertFalse (self .mock_create_job .called )
142+
143+ def test_select_all_with_everything_excluded_returns_404 (self ):
144+ photos = create_test_photos (number_of_photos = 2 , owner = self .user , size = 100 )
145+
146+ response = self .client .post (
147+ "/api/photos/download" ,
148+ data = {
149+ "select_all" : True ,
150+ "query" : {},
151+ "excluded_hashes" : [p .image_hash for p in photos ],
152+ },
153+ format = "json" ,
154+ )
155+
156+ self .assertEqual (response .status_code , 404 )
157+ self .assertFalse (self .mock_create_job .called )
158+
159+ def test_missing_image_hashes_still_rejected_when_select_all_falsy (self ):
160+ # Without select_all the original contract is preserved.
161+ response = self .client .post (
162+ "/api/photos/download" , data = {"select_all" : False }, format = "json"
163+ )
164+ self .assertEqual (response .status_code , 400 )
165+ self .assertFalse (self .mock_create_job .called )
166+
167+ def test_storage_check_runs_in_select_all_mode (self ):
168+ # If free storage is smaller than the aggregated photo size, the
169+ # endpoint returns 507 regardless of which payload shape was used.
170+ create_test_photos (number_of_photos = 2 , owner = self .user , size = 10 ** 9 )
171+
172+ with patch ("shutil.disk_usage" ) as patched_disk :
173+ patched_disk .return_value .free = 0
174+ response = self .client .post (
175+ "/api/photos/download" ,
176+ data = {"select_all" : True , "query" : {}},
177+ format = "json" ,
178+ )
179+
180+ self .assertEqual (response .status_code , 507 )
181+ self .assertFalse (self .mock_create_job .called )
0 commit comments