diff --git a/src/Curl/Curl.php b/src/Curl/Curl.php index 47c4914..9f8b45c 100644 --- a/src/Curl/Curl.php +++ b/src/Curl/Curl.php @@ -305,7 +305,27 @@ class Curl $fh = tmpfile(); } else { $filename = $mixed_filename; - $fh = fopen($filename, 'wb'); + + // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing + // file has already fully completed downloading and a new download is started with the same destination save + // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid, + // but unsatisfiable. + $download_filename = $filename . '.pccdownload'; + + $mode = 'wb'; + // Attempt to resume download only when a temporary download file exists and is not empty. + if (file_exists($download_filename) && $filesize = filesize($download_filename)) { + $mode = 'ab'; + $first_byte_position = $filesize; + $range = $first_byte_position . '-'; + $this->setOpt(CURLOPT_RANGE, $range); + } + $fh = fopen($download_filename, $mode); + + // Move the downloaded temporary file to the destination save path. + $this->downloadCompleteFunction = function ($fh) use ($download_filename, $filename) { + rename($download_filename, $filename); + }; } $this->setOpt(CURLOPT_FILE, $fh); diff --git a/tests/PHPCurlClass/ContentRangeServer.php b/tests/PHPCurlClass/ContentRangeServer.php new file mode 100644 index 0000000..796d2c1 --- /dev/null +++ b/tests/PHPCurlClass/ContentRangeServer.php @@ -0,0 +1,42 @@ +getLength($filesize)); + header('Content-Range: ' . $range->getContentRangeHeader($filesize)); + + $start = $range->getFirstBytePosition($filesize); + if ($start > 0) { + fseek($fp, $start, SEEK_SET); + } + + $length = $range->getLength($filesize); + $chunk_size = 4096; + while ($length) { + $read = $length > $chunk_size ? $chunk_size : $length; + $length -= $read; + echo fread($fp, $read); + } + } + + fclose($fp); + } +} diff --git a/tests/PHPCurlClass/Helper.php b/tests/PHPCurlClass/Helper.php index 6d2a4f9..caa7739 100644 --- a/tests/PHPCurlClass/Helper.php +++ b/tests/PHPCurlClass/Helper.php @@ -1,4 +1,5 @@ server('upload_response', 'POST', array( + 'image' => '@' . $upload_file_path, + )); + $uploaded_file_path = $upload_test->curl->response->file_path; + + // Ensure files are not the same path. + assert(!($upload_file_path === $uploaded_file_path)); + + // Ensure file uploaded successfully. + assert(md5_file($upload_file_path) === $upload_test->curl->responseHeaders['ETag']); + + return $uploaded_file_path; +} + +function remove_file_from_server($uploaded_file_path) { + $download_test = new Test(); + + // Ensure file successfully removed. + assert('true' === $download_test->server('upload_cleanup', 'POST', array( + 'file_path' => $uploaded_file_path, + ))); + assert(file_exists($uploaded_file_path) === false); +} diff --git a/tests/PHPCurlClass/PHPCurlClassTest.php b/tests/PHPCurlClass/PHPCurlClassTest.php index 0ee82a6..3be3cb1 100644 --- a/tests/PHPCurlClass/PHPCurlClassTest.php +++ b/tests/PHPCurlClass/PHPCurlClassTest.php @@ -609,15 +609,9 @@ class CurlTest extends PHPUnit_Framework_TestCase public function testDownload() { - // Upload a file. + // Create and upload a file. $upload_file_path = Helper\get_png(); - $upload_test = new Test(); - $upload_test->server('upload_response', 'POST', array( - 'image' => '@' . $upload_file_path, - )); - $uploaded_file_path = $upload_test->curl->response->file_path; - $this->assertNotEquals($upload_file_path, $uploaded_file_path); - $this->assertEquals(md5_file($upload_file_path), $upload_test->curl->responseHeaders['ETag']); + $uploaded_file_path = Helper\upload_file_to_server($upload_file_path); // Download the file. $downloaded_file_path = tempnam('/tmp', 'php-curl-class.'); @@ -638,27 +632,19 @@ class CurlTest extends PHPUnit_Framework_TestCase $this->assertFalse(is_bool($download_test->curl->rawResponse)); // Remove server file. - $download_test = new Test(); - $this->assertEquals('true', $download_test->server('upload_cleanup', 'POST', array( - 'file_path' => $uploaded_file_path, - ))); + Helper\remove_file_from_server($uploaded_file_path); unlink($upload_file_path); unlink($downloaded_file_path); $this->assertFalse(file_exists($upload_file_path)); - $this->assertFalse(file_exists($uploaded_file_path)); $this->assertFalse(file_exists($downloaded_file_path)); } public function testDownloadCallback() { - // Upload a file. + // Create and upload a file. $upload_file_path = Helper\get_png(); - $upload_test = new Test(); - $upload_test->server('upload_response', 'POST', array( - 'image' => '@' . $upload_file_path, - )); - $uploaded_file_path = $upload_test->curl->response->file_path; + $uploaded_file_path = Helper\upload_file_to_server($upload_file_path); // Download the file. $callback_called = false; @@ -679,13 +665,101 @@ class CurlTest extends PHPUnit_Framework_TestCase $this->assertTrue($callback_called); // Remove server file. - $this->assertEquals('true', $upload_test->server('upload_cleanup', 'POST', array( - 'file_path' => $uploaded_file_path, - ))); + Helper\remove_file_from_server($uploaded_file_path); unlink($upload_file_path); $this->assertFalse(file_exists($upload_file_path)); - $this->assertFalse(file_exists($uploaded_file_path)); + } + + public function testDownloadRange() + { + // Create and upload a file. + $filename = Helper\get_png(); + $uploaded_file_path = Helper\upload_file_to_server($filename); + + $filesize = filesize($filename); + + foreach (array( + false, + 0, + 1, + 2, + 3, + 5, + 10, + 25, + 50, + $filesize - 3, + $filesize - 2, + $filesize - 1, + ) as $length) { + + $source = Test::TEST_URL; + $destination = Helper\get_tmp_file_path(); + + // Start with no file. + if ($length === false) { + $this->assertFalse(file_exists($destination)); + + // Start with $length bytes of file. + } else { + + // Simulate resuming partially downloaded temporary file. + $partial_filename = $destination . '.pccdownload'; + + if ($length === 0) { + $partial_content = ''; + } else { + $file = fopen($filename, 'rb'); + $partial_content = fread($file, $length); + fclose($file); + } + + // Partial content size should be $length bytes large for testing resume download behavior. + if ($length <= $filesize) { + $this->assertEquals($length, strlen($partial_content)); + + // Partial content should not be larger than the original file size. + } else { + $this->assertEquals($filesize, strlen($partial_content)); + } + + file_put_contents($partial_filename, $partial_content); + $this->assertEquals(strlen($partial_content), strlen(file_get_contents($partial_filename))); + } + + // Download (the remaining bytes of) the file. + $curl = new Curl(); + $curl->setHeader('X-DEBUG-TEST', 'download_file_range'); + $curl->download($source . '?' . http_build_query(array( + 'file_path' => $uploaded_file_path, + )), $destination); + + clearstatcache(); + + $expected_bytes_downloaded = $filesize - min($length, $filesize); + $bytes_downloaded = $curl->responseHeaders['content-length']; + if ($length === false || $length === 0) { + $expected_http_status_code = 200; // 200 OK + $this->assertEquals($expected_bytes_downloaded, $bytes_downloaded); + } elseif ($length >= $filesize) { + $expected_http_status_code = 416; // 416 Requested Range Not Satisfiable + } else { + $expected_http_status_code = 206; // 206 Partial Content + $this->assertEquals($expected_bytes_downloaded, $bytes_downloaded); + } + $this->assertEquals($expected_http_status_code, $curl->httpStatusCode); + $this->assertEquals($filesize, filesize($destination)); + + unlink($destination); + $this->assertFalse(file_exists($destination)); + } + + // Remove server file. + Helper\remove_file_from_server($uploaded_file_path); + + unlink($filename); + $this->assertFalse(file_exists($filename)); } public function testMaxFilesize() diff --git a/tests/PHPCurlClass/RangeHeader.php b/tests/PHPCurlClass/RangeHeader.php new file mode 100644 index 0000000..ff0c1cf --- /dev/null +++ b/tests/PHPCurlClass/RangeHeader.php @@ -0,0 +1,53 @@ +first_byte = isset($matches['1']) ? (int)$matches['1'] : null; + $this->last_byte = isset($matches['2']) ? (int)$matches['2'] : null; + } + + public function getFirstBytePosition($file_size) + { + $size = (int)$file_size; + + if ($this->first_byte === null) { + return $size - 1 - $this->last_byte; + } + + return $this->first_byte; + } + + public function getLastBytePosition($file_size) + { + $size = (int)$file_size; + + if ($this->last_byte === null) { + return $size - 1; + } + + return $this->last_byte; + } + + public function getLength($file_size) + { + $size = (int)$file_size; + + return $this->getLastBytePosition($size) - $this->getFirstBytePosition($size) + 1; + } + + public function getContentRangeHeader($file_size) + { + return + 'bytes ' . $this->getFirstBytePosition($file_size) . '-' . $this->getLastBytePosition($file_size) . '/' . + $file_size; + } +} diff --git a/tests/PHPCurlClass/server.php b/tests/PHPCurlClass/server.php index 43d63d2..5977eb6 100644 --- a/tests/PHPCurlClass/server.php +++ b/tests/PHPCurlClass/server.php @@ -1,4 +1,7 @@ serve($unsafe_file_path); + exit; } elseif ($test === 'timeout') { $unsafe_seconds = $_GET['seconds']; $start = time(); diff --git a/tests/run.sh b/tests/run.sh index 0220495..151b65b 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,8 +1,10 @@ set -x php -S 127.0.0.1:8000 -t PHPCurlClass/ &> /dev/null & pid="${!}" +extra_args="${@}" phpunit \ --configuration phpunit.xml \ --debug \ - --verbose + --verbose \ + ${extra_args} kill "${pid}"