|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace MongoDB\Tests\GridFS; |
| 4 | + |
| 5 | +use MongoDB\Collection; |
| 6 | +use MongoDB\BSON\Binary; |
| 7 | +use MongoDB\BSON\ObjectId; |
| 8 | +use MongoDB\BSON\UTCDateTime; |
| 9 | +use MongoDB\Exception\RuntimeException; |
| 10 | +use MongoDB\Operation\BulkWrite; |
| 11 | +use DateTime; |
| 12 | +use IteratorIterator; |
| 13 | +use LogicException; |
| 14 | +use MultipleIterator; |
| 15 | + |
| 16 | +/** |
| 17 | + * GridFS spec functional tests. |
| 18 | + * |
| 19 | + * @see https://github.com/mongodb/specifications/tree/master/source/gridfs/tests |
| 20 | + */ |
| 21 | +class SpecFunctionalTest extends FunctionalTestCase |
| 22 | +{ |
| 23 | + private $expectedChunksCollection; |
| 24 | + private $expectedFilesCollection; |
| 25 | + |
| 26 | + public function setUp() |
| 27 | + { |
| 28 | + parent::setUp(); |
| 29 | + |
| 30 | + $this->expectedFilesCollection = new Collection($this->manager, $this->getDatabaseName(), 'expected.files'); |
| 31 | + $this->expectedFilesCollection->drop(); |
| 32 | + |
| 33 | + $this->expectedChunksCollection = new Collection($this->manager, $this->getDatabaseName(), 'expected.chunks'); |
| 34 | + $this->expectedChunksCollection->drop(); |
| 35 | + } |
| 36 | + |
| 37 | + /** |
| 38 | + * @dataProvider provideSpecificationTests |
| 39 | + */ |
| 40 | + public function testSpecification(array $initialData, array $test) |
| 41 | + { |
| 42 | + $this->setName(str_replace(' ', '_', $test['description'])); |
| 43 | + $this->initializeData($initialData); |
| 44 | + |
| 45 | + if (isset($test['arrange'])) { |
| 46 | + foreach ($test['arrange']['data'] as $dataModification) { |
| 47 | + $this->executeDataModification($dataModification); |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + try { |
| 52 | + $result = $this->executeAct($test['act']); |
| 53 | + } catch (RuntimeException $e) { |
| 54 | + $result = $e; |
| 55 | + } |
| 56 | + |
| 57 | + if (isset($test['assert'])) { |
| 58 | + $this->executeAssert($test['assert'], $result); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + public function provideSpecificationTests() |
| 63 | + { |
| 64 | + $testArgs = []; |
| 65 | + |
| 66 | + foreach (glob(__DIR__ . '/spec-tests/*.json') as $filename) { |
| 67 | + $json = json_decode(file_get_contents($filename), true); |
| 68 | + |
| 69 | + foreach ($json['tests'] as $test) { |
| 70 | + $testArgs[] = [$json['data'], $test]; |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + return $testArgs; |
| 75 | + } |
| 76 | + |
| 77 | + /** |
| 78 | + * Assert that the collections contain equivalent documents. |
| 79 | + * |
| 80 | + * This method will resolve references within the expected collection's |
| 81 | + * documents before comparing documents. Occurrences of "*result" in the |
| 82 | + * expected collection's documents will be replaced with the actual result. |
| 83 | + * Occurrences of "*actual" in the expected collection's documents will be |
| 84 | + * replaced with the corresponding value in the actual collection's document |
| 85 | + * being compared. |
| 86 | + * |
| 87 | + * @param Collection $expectedCollection |
| 88 | + * @param Collection $actualCollection |
| 89 | + * @param mixed $actualResult |
| 90 | + */ |
| 91 | + private function assertEquivalentCollections($expectedCollection, $actualCollection, $actualResult) |
| 92 | + { |
| 93 | + $mi = new MultipleIterator; |
| 94 | + $mi->attachIterator(new IteratorIterator($expectedCollection->find())); |
| 95 | + $mi->attachIterator(new IteratorIterator($actualCollection->find())); |
| 96 | + |
| 97 | + foreach ($mi as $documents) { |
| 98 | + list($expectedDocument, $actualDocument) = $documents; |
| 99 | + |
| 100 | + array_walk($expectedDocument, function(&$value) use ($actualResult) { |
| 101 | + if ($value === '*result') { |
| 102 | + $value = $actualResult; |
| 103 | + } |
| 104 | + }); |
| 105 | + |
| 106 | + array_walk($expectedDocument, function(&$value, $key) use ($actualDocument) { |
| 107 | + if ( ! is_string($value)) { |
| 108 | + return; |
| 109 | + } |
| 110 | + |
| 111 | + if ( ! strncmp($value, '*actual_', 8)) { |
| 112 | + $value = $actualDocument[$key]; |
| 113 | + } |
| 114 | + }); |
| 115 | + |
| 116 | + $this->assertSameDocument($expectedDocument, $actualDocument); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * Convert encoded types in the array and return the modified array. |
| 122 | + * |
| 123 | + * Nested arrays with "$oid" and "$date" keys will be converted to ObjectID |
| 124 | + * and UTCDateTime instances, respectively. Nested arrays with "$hex" keys |
| 125 | + * will be converted to a string or Binary object. |
| 126 | + * |
| 127 | + * @param param $data |
| 128 | + * @param boolean $createBinary If true, convert "$hex" values to a Binary |
| 129 | + * @return array |
| 130 | + */ |
| 131 | + private function convertTypes(array $data, $createBinary = true) |
| 132 | + { |
| 133 | + /* array_walk_recursive() only visits leaf nodes within the array, so we |
| 134 | + * need to manually recurse. |
| 135 | + */ |
| 136 | + array_walk($data, function(&$value) use ($createBinary) { |
| 137 | + if ( ! is_array($value)) { |
| 138 | + return; |
| 139 | + } |
| 140 | + |
| 141 | + if (isset($value['$oid'])) { |
| 142 | + $value = new ObjectId($value['$oid']); |
| 143 | + return; |
| 144 | + } |
| 145 | + |
| 146 | + if (isset($value['$hex'])) { |
| 147 | + $value = $createBinary |
| 148 | + ? new Binary(hex2bin($value['$hex']), Binary::TYPE_GENERIC) |
| 149 | + : hex2bin($value['$hex']); |
| 150 | + |
| 151 | + return; |
| 152 | + } |
| 153 | + |
| 154 | + if (isset($value['$date'])) { |
| 155 | + // TODO: This is necessary until PHPC-536 is implemented |
| 156 | + $milliseconds = floor((new DateTime($value['$date']))->format('U.u') * 1000); |
| 157 | + $value = new UTCDateTime($milliseconds); |
| 158 | + return; |
| 159 | + } |
| 160 | + |
| 161 | + $value = $this->convertTypes($value, $createBinary); |
| 162 | + }); |
| 163 | + |
| 164 | + return $data; |
| 165 | + } |
| 166 | + |
| 167 | + /** |
| 168 | + * Executes an "act" block. |
| 169 | + * |
| 170 | + * @param array $act |
| 171 | + * @return mixed |
| 172 | + * @throws LogicException if the operation is unsupported |
| 173 | + */ |
| 174 | + private function executeAct(array $act) |
| 175 | + { |
| 176 | + $act = $this->convertTypes($act, false); |
| 177 | + |
| 178 | + switch ($act['operation']) { |
| 179 | + case 'delete': |
| 180 | + return $this->bucket->delete($act['arguments']['id']); |
| 181 | + |
| 182 | + case 'download': |
| 183 | + return stream_get_contents($this->bucket->openDownloadStream($act['arguments']['id'])); |
| 184 | + |
| 185 | + case 'download_by_name': |
| 186 | + return stream_get_contents($this->bucket->openDownloadStreamByName( |
| 187 | + $act['arguments']['filename'], |
| 188 | + isset($act['arguments']['options']) ? $act['arguments']['options'] : [] |
| 189 | + )); |
| 190 | + |
| 191 | + case 'upload': |
| 192 | + return $this->bucket->uploadFromStream( |
| 193 | + $act['arguments']['filename'], |
| 194 | + $this->createStream($act['arguments']['source']), |
| 195 | + isset($act['arguments']['options']) ? $act['arguments']['options'] : [] |
| 196 | + ); |
| 197 | + |
| 198 | + default: |
| 199 | + throw new LogicException('Unsupported act: ' . $act['operation']); |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + /** |
| 204 | + * Executes an "assert" block. |
| 205 | + * |
| 206 | + * @param array $assert |
| 207 | + * @param mixed $actualResult |
| 208 | + * @return mixed |
| 209 | + * @throws FileNotFoundException |
| 210 | + * @throws LogicException if the operation is unsupported |
| 211 | + */ |
| 212 | + private function executeAssert(array $assert, $actualResult) |
| 213 | + { |
| 214 | + if (isset($assert['error'])) { |
| 215 | + $this->assertInstanceOf($this->getExceptionClassForError($assert['error']), $actualResult); |
| 216 | + } |
| 217 | + |
| 218 | + if (isset($assert['result'])) { |
| 219 | + $this->executeAssertResult($assert['result'], $actualResult); |
| 220 | + } |
| 221 | + |
| 222 | + if ( ! isset($assert['data'])) { |
| 223 | + return; |
| 224 | + } |
| 225 | + |
| 226 | + /* Since "*actual" may be used for an expected document's "_id", append |
| 227 | + * a unique value to avoid duplicate key exceptions. |
| 228 | + */ |
| 229 | + array_walk_recursive($assert['data'], function(&$value) { |
| 230 | + if ($value === '*actual') { |
| 231 | + $value .= '_' . new ObjectId; |
| 232 | + } |
| 233 | + }); |
| 234 | + |
| 235 | + foreach ($assert['data'] as $dataModification) { |
| 236 | + $this->executeDataModification($dataModification); |
| 237 | + } |
| 238 | + |
| 239 | + $this->assertEquivalentCollections($this->expectedFilesCollection, $this->filesCollection, $actualResult); |
| 240 | + $this->assertEquivalentCollections($this->expectedChunksCollection, $this->chunksCollection, $actualResult); |
| 241 | + } |
| 242 | + |
| 243 | + /** |
| 244 | + * Executes the "result" section of an "assert" block. |
| 245 | + * |
| 246 | + * @param mixed $expectedResult |
| 247 | + * @param mixed $actualResult |
| 248 | + * @param array $data |
| 249 | + * @throws LogicException if the result assertion is unsupported |
| 250 | + */ |
| 251 | + private function executeAssertResult($expectedResult, $actualResult) |
| 252 | + { |
| 253 | + if ($expectedResult === 'void') { |
| 254 | + return $this->assertNull($actualResult); |
| 255 | + } |
| 256 | + |
| 257 | + if ($expectedResult === '&result') { |
| 258 | + // Do nothing; assertEquivalentCollections() will handle this |
| 259 | + return; |
| 260 | + } |
| 261 | + |
| 262 | + if (isset($expectedResult['$hex'])) { |
| 263 | + return $this->assertSame(hex2bin($expectedResult['$hex']), $actualResult); |
| 264 | + } |
| 265 | + |
| 266 | + throw new LogicException('Unsupported result assertion: ' . var_export($expectedResult, true)); |
| 267 | + } |
| 268 | + |
| 269 | + /** |
| 270 | + * Executes a data modification from an "arrange" or "assert" block. |
| 271 | + * |
| 272 | + * @param array $dataModification |
| 273 | + * @return mixed |
| 274 | + * @throws LogicException if the operation or collection is unsupported |
| 275 | + */ |
| 276 | + private function executeDataModification(array $dataModification) |
| 277 | + { |
| 278 | + foreach ($dataModification as $type => $collectionName) { |
| 279 | + break; |
| 280 | + } |
| 281 | + |
| 282 | + if ( ! in_array($collectionName, ['fs.files', 'fs.chunks', 'expected.files', 'expected.chunks'])) { |
| 283 | + throw new LogicException('Unsupported collection: ' . $collectionName); |
| 284 | + } |
| 285 | + |
| 286 | + $dataModification = $this->convertTypes($dataModification); |
| 287 | + $operations = []; |
| 288 | + |
| 289 | + switch ($type) { |
| 290 | + case 'delete': |
| 291 | + foreach ($dataModification['deletes'] as $delete) { |
| 292 | + $operations[] = [ ($delete['limit'] === 1 ? 'deleteOne' : 'deleteMany') => [ $delete['q'] ] ]; |
| 293 | + } |
| 294 | + |
| 295 | + break; |
| 296 | + |
| 297 | + case 'insert': |
| 298 | + foreach ($dataModification['documents'] as $document) { |
| 299 | + $operations[] = [ 'insertOne' => [ $document ] ]; |
| 300 | + } |
| 301 | + |
| 302 | + break; |
| 303 | + |
| 304 | + case 'update': |
| 305 | + foreach ($dataModification['updates'] as $update) { |
| 306 | + $operations[] = [ 'updateOne' => [ $update['q'], $update['u'] ] ]; |
| 307 | + } |
| 308 | + |
| 309 | + break; |
| 310 | + |
| 311 | + default: |
| 312 | + throw new LogicException('Unsupported arrangement: ' . $type); |
| 313 | + } |
| 314 | + |
| 315 | + $bulk = new BulkWrite($this->getDatabaseName(), $collectionName, $operations); |
| 316 | + |
| 317 | + return $bulk->execute($this->getPrimaryServer()); |
| 318 | + } |
| 319 | + |
| 320 | + /** |
| 321 | + * Returns the exception class for the "error" section of an "assert" block. |
| 322 | + * |
| 323 | + * @param string $error |
| 324 | + * @return string |
| 325 | + * @throws LogicException if the error is unsupported |
| 326 | + */ |
| 327 | + private function getExceptionClassForError($error) |
| 328 | + { |
| 329 | + switch ($error) { |
| 330 | + case 'FileNotFound': |
| 331 | + case 'RevisionNotFound': |
| 332 | + return 'MongoDB\GridFS\Exception\FileNotFoundException'; |
| 333 | + |
| 334 | + case 'ChunkIsMissing': |
| 335 | + case 'ChunkIsWrongSize': |
| 336 | + return 'MongoDB\GridFS\Exception\CorruptFileException'; |
| 337 | + |
| 338 | + default: |
| 339 | + throw new LogicException('Unsupported error: ' . $error); |
| 340 | + } |
| 341 | + } |
| 342 | + |
| 343 | + /** |
| 344 | + * Initializes data in the files and chunks collections. |
| 345 | + * |
| 346 | + * @param array $data |
| 347 | + */ |
| 348 | + private function initializeData(array $data) |
| 349 | + { |
| 350 | + $data = $this->convertTypes($data); |
| 351 | + |
| 352 | + if ( ! empty($data['files'])) { |
| 353 | + $this->filesCollection->insertMany($data['files']); |
| 354 | + $this->expectedFilesCollection->insertMany($data['files']); |
| 355 | + } |
| 356 | + |
| 357 | + if ( ! empty($data['chunks'])) { |
| 358 | + $this->chunksCollection->insertMany($data['chunks']); |
| 359 | + $this->expectedChunksCollection->insertMany($data['chunks']); |
| 360 | + } |
| 361 | + } |
| 362 | +} |
0 commit comments