@@ -125,6 +125,85 @@ class ImageDownloaderTests: XCTestCase {
125125 waitForExpectations ( timeout: 5 , handler: nil )
126126 }
127127
128+ // `imageDownloader(_:didFinishDownloadingImageForURL:with:error:)` must be called exactly once
129+ // per download. Historically two paths invoked it for the same completion (`onDownloadingFinished`
130+ // in `setupSessionHandler()` and `reportDidDownloadImageData` in `startDownloadTask`'s `onTaskDone`
131+ // handler), so success and network-error downloads notified the delegate twice, while a
132+ // data-modifying failure first reported a success (nil error) and then a failure.
133+ func testDidFinishDownloadingDelegateCalledOncePerSuccessfulDownload( ) {
134+ let exp = expectation ( description: #function)
135+
136+ let url = testURLs [ 0 ]
137+ stub ( url, data: testImageData)
138+
139+ let delegate = DownloadFinishCountingDelegate ( )
140+ downloader. delegate = delegate
141+
142+ downloader. downloadImage ( with: url) { result in
143+ XCTAssertNotNil ( result. value)
144+ XCTAssertEqual ( delegate. finishedEvents. count, 1 )
145+ exp. fulfill ( )
146+ }
147+ waitForExpectations ( timeout: 3 , handler: nil )
148+ _ = delegate // keep the weak delegate alive for the duration of the test
149+ }
150+
151+ func testDidFinishDownloadingDelegateCalledOncePerFailedDownload( ) {
152+ let exp = expectation ( description: #function)
153+
154+ let url = testURLs [ 0 ]
155+ stub ( url, data: testImageData, statusCode: 404 )
156+
157+ let delegate = DownloadFinishCountingDelegate ( )
158+ downloader. delegate = delegate
159+
160+ downloader. downloadImage ( with: url) { result in
161+ XCTAssertNotNil ( result. error)
162+ XCTAssertEqual ( delegate. finishedEvents. count, 1 )
163+ exp. fulfill ( )
164+ }
165+ waitForExpectations ( timeout: 3 , handler: nil )
166+ _ = delegate
167+ }
168+
169+ func testDidFinishDownloadingDelegateCalledOncePerErroredDownload( ) {
170+ let exp = expectation ( description: #function)
171+
172+ let url = testURLs [ 0 ]
173+ stub ( url, errorCode: NSURLErrorNotConnectedToInternet)
174+
175+ let delegate = DownloadFinishCountingDelegate ( )
176+ downloader. delegate = delegate
177+
178+ downloader. downloadImage ( with: url) { result in
179+ XCTAssertNotNil ( result. error)
180+ XCTAssertEqual ( delegate. finishedEvents. count, 1 )
181+ exp. fulfill ( )
182+ }
183+ waitForExpectations ( timeout: 3 , handler: nil )
184+ _ = delegate
185+ }
186+
187+ func testDidFinishDownloadingDelegateConsistentWhenDataModifyingFails( ) {
188+ let exp = expectation ( description: #function)
189+
190+ let url = testURLs [ 0 ]
191+ stub ( url, data: testImageData)
192+
193+ let delegate = NilModifierCountingDelegate ( )
194+ downloader. delegate = delegate
195+
196+ downloader. downloadImage ( with: url) { result in
197+ XCTAssertNotNil ( result. error)
198+ XCTAssertEqual ( delegate. finishedEvents. count, 1 )
199+ // The single event should report the failure, not a success.
200+ XCTAssertNotNil ( delegate. finishedEvents. first? . error)
201+ exp. fulfill ( )
202+ }
203+ waitForExpectations ( timeout: 3 , handler: nil )
204+ _ = delegate
205+ }
206+
128207 func testDownloadWithModifyingRequest( ) {
129208 let exp = expectation ( description: #function)
130209
@@ -794,6 +873,57 @@ class TaskResponseCompletion: ImageDownloaderDelegate {
794873 }
795874}
796875
876+ class DownloadFinishCountingDelegate : ImageDownloaderDelegate , @unchecked Sendable {
877+ private let lock = NSLock ( )
878+ private var _finishedEvents : [ ( url: URL , response: URLResponse ? , error: ( any Error ) ? ) ] = [ ]
879+
880+ var finishedEvents : [ ( url: URL , response: URLResponse ? , error: ( any Error ) ? ) ] {
881+ lock. lock ( )
882+ defer { lock. unlock ( ) }
883+ return _finishedEvents
884+ }
885+
886+ func imageDownloader(
887+ _ downloader: ImageDownloader ,
888+ didFinishDownloadingImageForURL url: URL ,
889+ with response: URLResponse ? ,
890+ error: ( any Error ) ? )
891+ {
892+ lock. lock ( )
893+ defer { lock. unlock ( ) }
894+ _finishedEvents. append ( ( url, response, error) )
895+ }
896+ }
897+
898+ // Implements the data-modifying method directly instead of subclassing
899+ // `DownloadFinishCountingDelegate`: a protocol requirement satisfied by a protocol extension in the
900+ // parent is not re-dispatched to a subclass override through the existential.
901+ class NilModifierCountingDelegate : ImageDownloaderDelegate , @unchecked Sendable {
902+ private let lock = NSLock ( )
903+ private var _finishedEvents : [ ( url: URL , response: URLResponse ? , error: ( any Error ) ? ) ] = [ ]
904+
905+ var finishedEvents : [ ( url: URL , response: URLResponse ? , error: ( any Error ) ? ) ] {
906+ lock. lock ( )
907+ defer { lock. unlock ( ) }
908+ return _finishedEvents
909+ }
910+
911+ func imageDownloader(
912+ _ downloader: ImageDownloader ,
913+ didFinishDownloadingImageForURL url: URL ,
914+ with response: URLResponse ? ,
915+ error: ( any Error ) ? )
916+ {
917+ lock. lock ( )
918+ defer { lock. unlock ( ) }
919+ _finishedEvents. append ( ( url, response, error) )
920+ }
921+
922+ func imageDownloader( _ downloader: ImageDownloader , didDownload data: Data , with task: SessionDataTask ) -> Data ? {
923+ return nil
924+ }
925+ }
926+
797927final class URLModifier : ImageDownloadRequestModifier {
798928 let url : URL ?
799929 init ( url: URL ? ) {
0 commit comments