LocalChunkUploadTest.php 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. <?php
  2. declare(strict_types=1);
  3. use Tinywan\Storage\Adapter\LocalAdapter;
  4. use Tinywan\Storage\Exception\StorageException;
  5. use Webman\Http\UploadFile;
  6. function makeChunkAdapter(string $root, array $overrides = []): LocalAdapter
  7. {
  8. return new class(array_merge([
  9. 'adapter' => LocalAdapter::class,
  10. 'root' => $root,
  11. 'dirname' => 'unit-test',
  12. 'domain' => 'http://127.0.0.1:8787',
  13. 'uri' => '/storage',
  14. 'algo' => 'sha1',
  15. 'chunk_size' => 1024,
  16. 'chunk_path' => $root . '.chunks',
  17. '_is_file_upload' => false,
  18. ], $overrides)) extends LocalAdapter {};
  19. }
  20. function makeChunkFile(string $content): UploadFile
  21. {
  22. $path = tempnam(sys_get_temp_dir(), 'storage_chunk_');
  23. file_put_contents($path, $content);
  24. return new UploadFile($path, 'chunk.part', 'application/octet-stream', UPLOAD_ERR_OK);
  25. }
  26. function makeChunkRoot(): string
  27. {
  28. return sys_get_temp_dir() . '/storage_chunk_' . uniqid() . '/';
  29. }
  30. test('分片上传成功落盘并返回标准信息', function () {
  31. $content = str_repeat('A', 512);
  32. $result = makeChunkAdapter(makeChunkRoot())->uploadChunk(makeChunkFile($content), 'demo-file', 0, md5($content));
  33. expect($result)
  34. ->toHaveKeys(['file_id', 'chunk_index', 'save_name', 'save_path', 'size', 'md5'])
  35. ->and($result['file_id'])
  36. ->toBe('demo-file')
  37. ->and($result['chunk_index'])
  38. ->toBe(0)
  39. ->and($result['save_name'])
  40. ->toBe('00000000.part')
  41. ->and($result['size'])
  42. ->toBe(512)
  43. ->and($result['md5'])
  44. ->toBe(md5($content))
  45. ->and($result['save_path'])
  46. ->toBeFile()
  47. ->and(file_get_contents($result['save_path']))
  48. ->toBe($content);
  49. });
  50. test('分片 md5 校验失败时抛异常且无残留文件', function () {
  51. $root = makeChunkRoot();
  52. $adapter = makeChunkAdapter($root);
  53. expect(fn() => $adapter->uploadChunk(makeChunkFile('hello'), 'demo-file', 0, 'invalid-md5'))
  54. ->toThrow(StorageException::class, 'md5');
  55. expect(is_dir($root . '.chunks/demo-file'))->toBeFalse();
  56. });
  57. test('file_id 非法时抛异常防止路径穿越', function (string $fileId) {
  58. $adapter = makeChunkAdapter(makeChunkRoot());
  59. expect(fn() => $adapter->chunkProgress($fileId, 4))->toThrow(StorageException::class, 'file_id');
  60. })->with(['../etc', '', str_repeat('a', 65), '-leading-dash', '中文id']);
  61. test('分片大小超出限制时抛异常', function () {
  62. $adapter = makeChunkAdapter(makeChunkRoot(), ['chunk_size' => 10]);
  63. $content = str_repeat('B', 32);
  64. expect(fn() => $adapter->uploadChunk(makeChunkFile($content), 'demo-file', 0, md5($content)))
  65. ->toThrow(StorageException::class, '分片大小超出限制');
  66. });
  67. test('断点续传查询返回已传与缺失分片', function () {
  68. $adapter = makeChunkAdapter(makeChunkRoot());
  69. $adapter->uploadChunk(makeChunkFile('aaa'), 'demo-file', 0, md5('aaa'));
  70. $adapter->uploadChunk(makeChunkFile('ccc'), 'demo-file', 2, md5('ccc'));
  71. $progress = $adapter->chunkProgress('demo-file', 4);
  72. expect($progress['total'])
  73. ->toBe(4)
  74. ->and($progress['uploaded'])
  75. ->toBe([0, 2])
  76. ->and($progress['missing'])
  77. ->toBe([1, 3])
  78. ->and($progress['finished'])
  79. ->toBeFalse();
  80. });
  81. test('未知 file_id 查询进度返回全量缺失', function () {
  82. $progress = makeChunkAdapter(makeChunkRoot())->chunkProgress('no-such-file', 3);
  83. expect($progress['uploaded'])
  84. ->toBe([])
  85. ->and($progress['missing'])
  86. ->toBe([0, 1, 2])
  87. ->and($progress['finished'])
  88. ->toBeFalse();
  89. });
  90. test('合并分片后内容与源文件逐字节一致', function () {
  91. $root = makeChunkRoot();
  92. $adapter = makeChunkAdapter($root, ['chunk_size' => 512]);
  93. $origin = str_repeat('webman-storage-', 100);
  94. $chunks = str_split($origin, 512);
  95. foreach ($chunks as $index => $chunk) {
  96. $adapter->uploadChunk(makeChunkFile($chunk), 'demo-file', $index, md5($chunk));
  97. }
  98. $result = $adapter->mergeChunk('demo-file', count($chunks), 'video.mp4');
  99. expect($result)
  100. ->toHaveKeys(['origin_name', 'save_name', 'save_path', 'url', 'unique_id', 'size', 'mime_type', 'extension'])
  101. ->and($result['origin_name'])
  102. ->toBe('video.mp4')
  103. ->and($result['extension'])
  104. ->toBe('mp4')
  105. ->and($result['size'])
  106. ->toBe(strlen($origin))
  107. ->and($result['unique_id'])
  108. ->toBe(sha1($origin))
  109. ->and($result['url'])
  110. ->toStartWith('http://127.0.0.1:8787/storage')
  111. ->and(file_get_contents($result['save_path']))
  112. ->toBe($origin)
  113. ->and(is_dir($root . '.chunks/demo-file'))
  114. ->toBeFalse();
  115. });
  116. test('缺少分片时合并失败并提示缺失序号', function () {
  117. $adapter = makeChunkAdapter(makeChunkRoot());
  118. $adapter->uploadChunk(makeChunkFile('aaa'), 'demo-file', 0, md5('aaa'));
  119. expect(fn() => $adapter->mergeChunk('demo-file', 3, 'demo.txt'))->toThrow(StorageException::class, '缺少分片:1,2');
  120. });
  121. test('合并时校验文件后缀白名单与黑名单', function () {
  122. $includeAdapter = makeChunkAdapter(makeChunkRoot(), ['include' => ['png']]);
  123. expect(fn() => $includeAdapter->mergeChunk('demo-file', 1, 'demo.txt'))
  124. ->toThrow(StorageException::class, '文件扩展名不合法');
  125. $excludeAdapter = makeChunkAdapter(makeChunkRoot(), ['exclude' => ['php']]);
  126. expect(fn() => $excludeAdapter->mergeChunk('demo-file', 1, 'shell.php'))
  127. ->toThrow(StorageException::class, '文件扩展名不合法');
  128. $adapter = makeChunkAdapter(makeChunkRoot());
  129. $adapter->uploadChunk(makeChunkFile('zip'), 'demo-file', 0, md5('zip'));
  130. expect($adapter->mergeChunk('demo-file', 1, 'any.zip')['extension'])->toBe('zip');
  131. });
  132. test('同一分片序号重传覆盖旧分片', function () {
  133. $adapter = makeChunkAdapter(makeChunkRoot());
  134. $adapter->uploadChunk(makeChunkFile('AAA'), 'demo-file', 0, md5('AAA'));
  135. $adapter->uploadChunk(makeChunkFile('BBB'), 'demo-file', 0, md5('BBB'));
  136. $result = $adapter->mergeChunk('demo-file', 1, 'demo.txt');
  137. expect(file_get_contents($result['save_path']))->toBe('BBB');
  138. });
  139. test('合并锁存在时抛异常', function () {
  140. $root = makeChunkRoot();
  141. $adapter = makeChunkAdapter($root);
  142. $adapter->uploadChunk(makeChunkFile('AAA'), 'demo-file', 0, md5('AAA'));
  143. $lockPath = $root . '.chunks/demo-file.lock';
  144. file_put_contents($lockPath, 'lock');
  145. expect(fn() => $adapter->mergeChunk('demo-file', 1, 'demo.txt'))->toThrow(StorageException::class, '正在合并中');
  146. @unlink($lockPath);
  147. });