Skip to content

Commit 3cf9944

Browse files
committed
Rewrite harness for GridFS spec tests
1 parent 583d65a commit 3cf9944

File tree

11 files changed

+362
-901
lines changed

11 files changed

+362
-901
lines changed

tests/GridFS/SpecFunctionalTest.php

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
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

Comments
 (0)