diff --git a/composer.json b/composer.json index 8be1e8616..ea4b30a58 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "jean85/pretty-package-versions": "^1.2" }, "require-dev": { - "phpunit/phpunit": "^6.4", + "phpunit/phpunit": "^6.5", "sebastian/comparator": "^2.0 || ^3.0", "squizlabs/php_codesniffer": "^3.5, <3.5.5", "symfony/phpunit-bridge": "^4.4@dev" @@ -26,7 +26,10 @@ "files": [ "src/functions.php" ] }, "autoload-dev": { - "psr-4": { "MongoDB\\Tests\\": "tests/" } + "psr-4": { "MongoDB\\Tests\\": "tests/" }, + "// Manually include assertion functions for PHPUnit 8.x and earlier ":"", + "// See: https://github.com/sebastianbergmann/phpunit/issues/3746 ":"", + "files": [ "vendor/phpunit/phpunit/src/Framework/Assert/Functions.php" ] }, "extra": { "branch-alias": { diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 778a259e6..03d389677 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -308,11 +308,7 @@ protected function isReplicaSet() protected function isShardedCluster() { - if ($this->getPrimaryServer()->getType() == Server::TYPE_MONGOS) { - return true; - } - - return false; + return $this->getPrimaryServer()->getType() == Server::TYPE_MONGOS; } protected function isShardedClusterUsingReplicasets() diff --git a/tests/UnifiedSpecTests/CollectionData.php b/tests/UnifiedSpecTests/CollectionData.php new file mode 100644 index 000000000..eaa61ed0c --- /dev/null +++ b/tests/UnifiedSpecTests/CollectionData.php @@ -0,0 +1,90 @@ +collectionName); + $this->collectionName = $o->collectionName; + + assertInternalType('string', $o->databaseName); + $this->databaseName = $o->databaseName; + + assertInternalType('array', $o->documents); + assertContainsOnly('object', $o->documents); + $this->documents = $o->documents; + } + + public function prepareInitialData(Client $client) + { + $database = $client->selectDatabase( + $this->databaseName, + ['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)] + ); + + $database->dropCollection($this->collectionName); + + if (empty($this->documents)) { + $database->createCollection($this->collectionName); + + return; + } + + $database->selectCollection($this->collectionName)->insertMany($this->documents); + } + + public function assertOutcome(Client $client) + { + $collection = $client->selectCollection( + $this->databaseName, + $this->collectionName, + [ + 'readConcern' => new ReadConcern(ReadConcern::LOCAL), + 'readPreference' => new ReadPreference(ReadPreference::PRIMARY), + ] + ); + + $cursor = $collection->find([], ['sort' => ['_id' => 1]]); + + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($this->documents)); + $mi->attachIterator(new IteratorIterator($cursor)); + + foreach ($mi as $i => $documents) { + list($expectedDocument, $actualDocument) = $documents; + assertNotNull($expectedDocument); + assertNotNull($actualDocument); + + /* Prohibit extra root keys and disable operators to enforce exact + * matching of documents. Key order variation is still allowed. */ + $constraint = new Matches($expectedDocument, null, false, false); + assertThat($actualDocument, $constraint, sprintf('documents[%d] match', $i)); + } + } +} diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonType.php b/tests/UnifiedSpecTests/Constraint/IsBsonType.php new file mode 100644 index 000000000..76dbe4278 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/IsBsonType.php @@ -0,0 +1,210 @@ + is not a valid type', self::class, $type)); + } + + $this->type = $type; + } + + public static function any() : LogicalOr + { + return self::anyOf(...self::$types); + } + + public static function anyOf(string ...$types) : Constraint + { + if (count($types) === 1) { + return new self(...$types); + } + + return LogicalOr::fromConstraints(...array_map(function ($type) { + return new self($type); + }, $types)); + } + + private function doMatches($other) : bool + { + switch ($this->type) { + case 'double': + return is_float($other); + case 'string': + return is_string($other); + case 'object': + return self::isObject($other); + case 'array': + return self::isArray($other); + case 'binData': + return $other instanceof BinaryInterface; + case 'undefined': + return $other instanceof Undefined; + case 'objectId': + return $other instanceof ObjectIdInterface; + case 'bool': + return is_bool($other); + case 'date': + return $other instanceof UTCDateTimeInterface; + case 'null': + return $other === null; + case 'regex': + return $other instanceof RegexInterface; + case 'dbPointer': + return $other instanceof DBPointer; + case 'javascript': + return $other instanceof JavascriptInterface && $other->getScope() === null; + case 'symbol': + return $other instanceof Symbol; + case 'javascriptWithScope': + return $other instanceof JavascriptInterface && $other->getScope() !== null; + case 'int': + return is_int($other); + case 'timestamp': + return $other instanceof TimestampInterface; + case 'long': + if (PHP_INT_SIZE == 4) { + return $other instanceof Int64; + } + + return is_int($other); + case 'decimal': + return $other instanceof Decimal128Interface; + case 'minKey': + return $other instanceof MinKeyInterface; + case 'maxKey': + return $other instanceof MaxKeyInterface; + default: + // This should already have been caught in the constructor + throw new LogicException('Unsupported type: ' . $this->type); + } + } + + private function doToString() : string + { + return sprintf('is of BSON type "%s"', $this->type); + } + + private static function isArray($other) : bool + { + if ($other instanceof BSONArray) { + return true; + } + + // Serializable can produce an array or object, so recurse on its output + if ($other instanceof Serializable) { + return self::isArray($other->bsonSerialize()); + } + + if (! is_array($other)) { + return false; + } + + // Empty and indexed arrays serialize as BSON arrays + return self::isArrayEmptyOrIndexed($other); + } + + private static function isObject($other) : bool + { + if ($other instanceof BSONDocument) { + return true; + } + + // Serializable can produce an array or object, so recurse on its output + if ($other instanceof Serializable) { + return self::isObject($other->bsonSerialize()); + } + + // Non-empty, associative arrays serialize as BSON objects + if (is_array($other)) { + return ! self::isArrayEmptyOrIndexed($other); + } + + if (! is_object($other)) { + return false; + } + + /* Serializable has already been handled, so any remaining instances of + * Type will not serialize as BSON objects */ + return ! $other instanceof Type; + } + + private static function isArrayEmptyOrIndexed(array $a) : bool + { + if (empty($a)) { + return true; + } + + return array_keys($a) === range(0, count($a) - 1); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php new file mode 100644 index 000000000..302e63c3b --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/IsBsonTypeTest.php @@ -0,0 +1,177 @@ +assertResult(true, new IsBsonType($type), $value, $this->dataName() . ' is ' . $type); + } + + public function provideTypes() + { + $undefined = toPHP(fromJSON('{ "undefined": {"$undefined": true} }')); + $symbol = toPHP(fromJSON('{ "symbol": {"$symbol": "test"} }')); + $dbPointer = toPHP(fromJSON('{ "dbPointer": {"$dbPointer": {"$ref": "phongo.test", "$id" : { "$oid" : "5a2e78accd485d55b405ac12" } }} }')); + + return [ + 'double' => ['double', 1.4], + 'string' => ['string', 'foo'], + // Note: additional tests in testTypeObject + 'object(stdClass)' => ['object', new stdClass()], + 'object(BSONDocument)' => ['object', new BSONDocument()], + // Note: additional tests tests in testTypeArray + 'array(indexed array)' => ['array', ['foo']], + 'array(BSONArray)' => ['array', new BSONArray()], + 'binData' => ['binData', new Binary('', 0)], + 'undefined' => ['undefined', $undefined->undefined], + 'objectId' => ['objectId', new ObjectId()], + 'bool' => ['bool', true], + 'date' => ['date', new UTCDateTime()], + 'null' => ['null', null], + 'regex' => ['regex', new Regex('.*')], + 'dbPointer' => ['dbPointer', $dbPointer->dbPointer], + 'javascript' => ['javascript', new Javascript('foo = 1;')], + 'symbol' => ['symbol', $symbol->symbol], + 'javascriptWithScope' => ['javascriptWithScope', new Javascript('foo = 1;', ['x' => 1])], + 'int' => ['int', 1], + 'timestamp' => ['timestamp', new Timestamp(0, 0)], + 'long' => ['long', PHP_INT_SIZE == 4 ? unserialize('C:18:"MongoDB\BSON\Int64":38:{a:1:{s:7:"integer";s:10:"4294967296";}}') : 4294967296], + 'decimal' => ['decimal', new Decimal128('18446744073709551616')], + 'minKey' => ['minKey', new MinKey()], + 'maxKey' => ['maxKey', new MaxKey()], + ]; + } + + /** + * @dataProvider provideTypes + */ + public function testAny($type, $value) + { + $this->assertResult(true, IsBsonType::any(), $value, $this->dataName() . ' is a BSON type'); + } + + public function testAnyExcludesStream() + { + $this->assertResult(false, IsBsonType::any(), fopen('php://temp', 'w+b'), 'stream is not a BSON type'); + } + + public function testAnyOf() + { + $c = IsBsonType::anyOf('double', 'int'); + + $this->assertResult(true, $c, 1, 'int is double or int'); + $this->assertResult(true, $c, 1.4, 'int is double or int'); + $this->assertResult(false, $c, 'foo', 'string is not double or int'); + } + + public function testErrorMessage() + { + $c = new IsBsonType('string'); + + try { + $c->evaluate(1); + $this->fail('Expected a comparison failure'); + } catch (ExpectationFailedException $e) { + $this->assertStringMatchesFormat('Failed asserting that %s is of BSON type "string".', $e->getMessage()); + } + } + + public function testTypeArray() + { + $c = new IsBsonType('array'); + + $this->assertResult(true, $c, [], 'empty array is array'); + $this->assertResult(true, $c, ['foo'], 'indexed array is array'); + $this->assertResult(true, $c, new BSONArray(), 'BSONArray is array'); + $this->assertResult(true, $c, new SerializableArray(), 'SerializableArray is array'); + + $this->assertResult(false, $c, 1, 'integer is not array'); + $this->assertResult(false, $c, ['x' => 1], 'associative array is not array'); + $this->assertResult(false, $c, new BSONDocument(), 'BSONDocument is not array'); + $this->assertResult(false, $c, new SerializableObject(), 'SerializableObject is not array'); + } + + public function testTypeObject() + { + $c = new IsBsonType('object'); + + $this->assertResult(true, $c, new stdClass(), 'stdClass is object'); + $this->assertResult(true, $c, new BSONDocument(), 'BSONDocument is object'); + $this->assertResult(true, $c, ['x' => 1], 'associative array is object'); + $this->assertResult(true, $c, new SerializableObject(), 'SerializableObject is object'); + + $this->assertResult(false, $c, 1, 'integer is not object'); + $this->assertResult(false, $c, [], 'empty array is not object'); + $this->assertResult(false, $c, ['foo'], 'indexed array is not object'); + $this->assertResult(false, $c, new BSONArray(), 'BSONArray is not object'); + $this->assertResult(false, $c, new SerializableArray(), 'SerializableArray is not object'); + $this->assertResult(false, $c, new ObjectId(), 'Type other than Serializable is not object'); + } + + public function testTypeJavascript() + { + $c = new IsBsonType('javascript'); + + $this->assertResult(false, $c, 1, 'integer is not javascript'); + $this->assertResult(false, $c, new Javascript('foo = 1;', ['x' => 1]), 'javascriptWithScope is not javascript'); + } + + public function testTypeJavascriptWithScope() + { + $c = new IsBsonType('javascriptWithScope'); + + $this->assertResult(false, $c, 1, 'integer is not javascriptWithScope'); + $this->assertResult(false, $c, new Javascript('foo = 1;'), 'javascript is not javascriptWithScope'); + } + + private function assertResult($expected, Constraint $constraint, $value, string $message = '') + { + $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); + } +} + +// phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses +// phpcs:disable Squiz.Classes.ClassFileName.NoMatch +class SerializableArray implements Serializable +{ + public function bsonSerialize() + { + return ['foo']; + } +} + +class SerializableObject implements Serializable +{ + public function bsonSerialize() + { + return ['x' => 1]; + } +} +// phpcs:enable diff --git a/tests/UnifiedSpecTests/Constraint/Matches.php b/tests/UnifiedSpecTests/Constraint/Matches.php new file mode 100644 index 000000000..0b6faaaf1 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/Matches.php @@ -0,0 +1,453 @@ +value = self::prepare($value); + $this->entityMap = $entityMap; + $this->allowExtraRootKeys = $allowExtraRootKeys; + $this->allowOperators = $allowOperators; + $this->comparatorFactory = Factory::getInstance(); + } + + public function evaluate($other, $description = '', $returnResult = false) + { + $other = self::prepare($other); + $success = false; + $this->lastFailure = null; + + try { + $this->assertMatches($this->value, $other); + $success = true; + } catch (ExpectationFailedException $e) { + /* Rethrow internal assertion failures (e.g. operator type checks, + * EntityMap errors), which are logical errors in the code/test. */ + throw $e; + } catch (RuntimeException $e) { + /* This will generally catch internal errors from failAt(), which + * include a key path to pinpoint the failure. */ + $this->lastFailure = new ComparisonFailure( + $this->value, + $other, + /* TODO: Improve the exporter to canonicalize documents by + * sorting keys and remove spl_object_hash from output. */ + $this->exporter()->export($this->value), + $this->exporter()->export($other), + false, + $e->getMessage() + ); + } + + if ($returnResult) { + return $success; + } + + if (! $success) { + $this->fail($other, $description, $this->lastFailure); + } + } + + private function assertEquals($expected, $actual, string $keyPath) + { + $expectedType = is_object($expected) ? get_class($expected) : gettype($expected); + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + + /* Early check to work around ObjectComparator printing the entire value + * for a failed type comparison. Avoid doing this if either value is + * numeric to allow for flexible numeric comparisons (e.g. 1 == 1.0). */ + if ($expectedType !== $actualType && ! (self::isNumeric($expected) || self::isNumeric($actual))) { + self::failAt(sprintf('%s is not expected type "%s"', $actualType, $expectedType), $keyPath); + } + + try { + $this->comparatorFactory->getComparatorFor($expected, $actual)->assertEquals($expected, $actual); + } catch (ComparisonFailure $e) { + /* Disregard other ComparisonFailure fields, as evaluate() only uses + * the message when creating its own ComparisonFailure. */ + self::failAt($e->getMessage(), $keyPath); + } + } + + private function assertMatches($expected, $actual, string $keyPath = '') + { + if ($expected instanceof BSONArray) { + $this->assertMatchesArray($expected, $actual, $keyPath); + + return; + } + + if ($expected instanceof BSONDocument) { + $this->assertMatchesDocument($expected, $actual, $keyPath); + + return; + } + + $this->assertEquals($expected, $actual, $keyPath); + } + + private function assertMatchesArray(BSONArray $expected, $actual, string $keyPath) + { + if (! $actual instanceof BSONArray) { + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONArray::class), $keyPath); + } + + if (count($expected) !== count($actual)) { + self::failAt(sprintf('$actual count is %d, expected %d', count($actual), count($expected)), $keyPath); + } + + foreach ($expected as $key => $expectedValue) { + $this->assertMatches( + $expectedValue, + $actual[$key], + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); + } + } + + private function assertMatchesDocument(BSONDocument $expected, $actual, string $keyPath) + { + if ($this->allowOperators && self::isOperator($expected)) { + $this->assertMatchesOperator($expected, $actual, $keyPath); + + return; + } + + if (! $actual instanceof BSONDocument) { + $actualType = is_object($actual) ? get_class($actual) : gettype($actual); + self::failAt(sprintf('%s is not instance of expected class "%s"', $actualType, BSONDocument::class), $keyPath); + } + + foreach ($expected as $key => $expectedValue) { + $actualKeyExists = $actual->offsetExists($key); + + if ($this->allowOperators && $expectedValue instanceof BSONDocument && self::isOperator($expectedValue)) { + $operatorName = self::getOperatorName($expectedValue); + + if ($operatorName === '$$exists') { + assertInternalType('bool', $expectedValue['$$exists'], '$$exists requires bool'); + + if ($expectedValue['$$exists'] && ! $actualKeyExists) { + self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath); + } + + if (! $expectedValue['$$exists'] && $actualKeyExists) { + self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath); + } + + continue; + } + + if ($operatorName === '$$unsetOrMatches') { + if (! $actualKeyExists) { + continue; + } + + $expectedValue = $expectedValue['$$unsetOrMatches']; + } + } + + if (! $actualKeyExists) { + self::failAt(sprintf('$actual does not have expected key "%s"', $key), $keyPath); + } + + $this->assertMatches( + $expectedValue, + $actual[$key], + (empty($keyPath) ? $key : $keyPath . '.' . $key) + ); + } + + // Ignore extra keys in root documents + if ($this->allowExtraRootKeys && empty($keyPath)) { + return; + } + + foreach ($actual as $key => $_) { + if (! $expected->offsetExists($key)) { + self::failAt(sprintf('$actual has unexpected key "%s"', $key), $keyPath); + } + } + } + + private function assertMatchesOperator(BSONDocument $operator, $actual, string $keyPath) + { + $name = self::getOperatorName($operator); + + if ($name === '$$type') { + assertThat( + $operator['$$type'], + logicalOr(isType('string'), logicalAnd(isInstanceOf(BSONArray::class), containsOnly('string'))), + '$$type requires string or string[]' + ); + + $constraint = IsBsonType::anyOf(...(array) $operator['$$type']); + + if (! $constraint->evaluate($actual, '', true)) { + self::failAt(sprintf('%s is not an expected BSON type: %s', $this->exporter()->shortenedExport($actual), implode(', ', $types)), $keyPath); + } + + return; + } + + if ($name === '$$matchesEntity') { + assertNotNull($this->entityMap, '$$matchesEntity requires EntityMap'); + assertInternalType('string', $operator['$$matchesEntity'], '$$matchesEntity requires string'); + + /* TODO: Consider including the entity ID in any error message to + * assist with diagnosing errors. Also consider disabling operators + * within this match, since entities are unlikely to use them. */ + $this->assertMatches( + self::prepare($this->entityMap[$operator['$$matchesEntity']]), + $actual, + $keyPath + ); + + return; + } + + if ($name === '$$matchesHexBytes') { + assertInternalType('string', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires string'); + assertRegExp('/^([0-9a-fA-F]{2})*$/', $operator['$$matchesHexBytes'], '$$matchesHexBytes requires pairs of hex chars'); + assertInternalType('string', $actual); + + if ($actual !== hex2bin($operator['$$matchesHexBytes'])) { + self::failAt(sprintf('%s does not match expected hex bytes: %s', $this->exporter()->shortenedExport($actual), $operator['$$matchesHexBytes']), $keyPath); + } + + return; + } + + if ($name === '$$unsetOrMatches') { + /* If the operator is used at the top level, consider null values + * for $actual to be unset. If the operator is nested, this check is + * done later during document iteration. */ + if ($keyPath === '' && $actual === null) { + return; + } + + $this->assertMatches( + self::prepare($operator['$$unsetOrMatches']), + $actual, + $keyPath + ); + + return; + } + + if ($name === '$$sessionLsid') { + assertNotNull($this->entityMap, '$$sessionLsid requires EntityMap'); + assertInternalType('string', $operator['$$sessionLsid'], '$$sessionLsid requires string'); + $lsid = $this->entityMap->getLogicalSessionId($operator['$$sessionLsid']); + + $this->assertEquals(self::prepare($lsid), $actual, $keyPath); + + return; + } + + throw new LogicException('unsupported operator: ' . $name); + } + + /** @see ConstraintTrait */ + private function doAdditionalFailureDescription($other) + { + if ($this->lastFailure === null) { + return ''; + } + + return $this->lastFailure->getMessage(); + } + + /** @see ConstraintTrait */ + private function doFailureDescription($other) + { + return 'expected value matches actual value'; + } + + /** @see ConstraintTrait */ + private function doMatches($other) + { + $other = self::prepare($other); + + try { + $this->assertMatches($this->value, $other); + } catch (RuntimeException $e) { + return false; + } + + return true; + } + + /** @see ConstraintTrait */ + private function doToString() + { + return 'matches ' . $this->exporter()->export($this->value); + } + + private static function failAt(string $message, string $keyPath) + { + $prefix = empty($keyPath) ? '' : sprintf('Field path "%s": ', $keyPath); + + throw new RuntimeException($prefix . $message); + } + + private static function getOperatorName(BSONDocument $document) : string + { + foreach ($document as $key => $_) { + if (strpos((string) $key, '$$') === 0) { + return $key; + } + } + + throw new LogicException('should not reach this point'); + } + + private static function isNumeric($value) + { + return is_int($value) || is_float($value) || $value instanceof Int64; + } + + private static function isOperator(BSONDocument $document) : bool + { + if (count($document) !== 1) { + return false; + } + + foreach ($document as $key => $_) { + return strpos((string) $key, '$$') === 0; + } + + throw new LogicException('should not reach this point'); + } + + /** + * Prepare a value for comparison. + * + * If the value is an array or object, it will be converted to a BSONArray + * or BSONDocument. If $value is an array and $isRoot is true, it will be + * converted to a BSONDocument; otherwise, it will be converted to a + * BSONArray or BSONDocument based on its keys. Each value within an array + * or document will then be prepared recursively. + * + * @param mixed $bson + * @return mixed + */ + private static function prepare($bson) + { + if (! is_array($bson) && ! is_object($bson)) { + return $bson; + } + + /* Convert Int64 objects to integers on 64-bit platforms for + * compatibility reasons. */ + if ($bson instanceof Int64 && PHP_INT_SIZE != 4) { + return (int) ((string) $bson); + } + + /* TODO: Convert Int64 objects to integers on 32-bit platforms if they + * can be expressed as such. This is necessary to handle flexible + * numeric comparisons if the server returns 32-bit value as a 64-bit + * integer (e.g. cursor ID). */ + + // Serializable can produce an array or object, so recurse on its output + if ($bson instanceof Serializable) { + return self::prepare($bson->bsonSerialize()); + } + + /* Serializable has already been handled, so any remaining instances of + * Type will not serialize as BSON arrays or objects */ + if ($bson instanceof Type) { + return $bson; + } + + if (is_array($bson) && self::isArrayEmptyOrIndexed($bson)) { + $bson = new BSONArray($bson); + } + + if (! $bson instanceof BSONArray && ! $bson instanceof BSONDocument) { + /* If $bson is an object, any numeric keys may become inaccessible. + * We can work around this by casting back to an array. */ + $bson = new BSONDocument((array) $bson); + } + + foreach ($bson as $key => $value) { + if (is_array($value) || is_object($value)) { + $bson[$key] = self::prepare($value); + } + } + + return $bson; + } + + private static function isArrayEmptyOrIndexed(array $a) : bool + { + if (empty($a)) { + return true; + } + + return array_keys($a) === range(0, count($a) - 1); + } +} diff --git a/tests/UnifiedSpecTests/Constraint/MatchesTest.php b/tests/UnifiedSpecTests/Constraint/MatchesTest.php new file mode 100644 index 000000000..aa825a6c3 --- /dev/null +++ b/tests/UnifiedSpecTests/Constraint/MatchesTest.php @@ -0,0 +1,290 @@ + 1, 'y' => ['a' => 1, 'b' => 2]]); + $this->assertResult(false, $c, ['x' => 1, 'y' => 2], 'Incorrect value'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2]], 'Exact match'); + $this->assertResult(true, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2], 'z' => 3], 'Extra keys in root are permitted'); + $this->assertResult(false, $c, ['x' => 1, 'y' => ['a' => 1, 'b' => 2, 'c' => 3]], 'Extra keys in embedded are not permitted'); + $this->assertResult(true, $c, ['y' => ['b' => 2, 'a' => 1], 'x' => 1], 'Root and embedded key order is not significant'); + } + + public function testDoNotAllowExtraRootKeys() + { + $c = new Matches(['x' => 1], null, false); + $this->assertResult(false, $c, ['x' => 1, 'y' => 1], 'Extra keys in root are prohibited'); + } + + public function testDoNotAllowOperators() + { + $c = new Matches(['x' => ['$$exists' => true]], null, true, false); + $this->assertResult(false, $c, ['x' => 1], 'Operators are not processed'); + $this->assertResult(true, $c, ['x' => ['$$exists' => true]], 'Operators are not processed but compared as-is'); + } + + public function testOperatorExists() + { + $c = new Matches(['x' => ['$$exists' => true]]); + $this->assertResult(true, $c, ['x' => '1'], 'root-level key exists'); + $this->assertResult(false, $c, new stdClass(), 'root-level key missing'); + $this->assertResult(true, $c, ['x' => '1', 'y' => 1], 'root-level key exists (extra key)'); + $this->assertResult(false, $c, ['y' => 1], 'root-level key missing (extra key)'); + + $c = new Matches(['x' => ['$$exists' => false]]); + $this->assertResult(false, $c, ['x' => '1'], 'root-level key exists'); + $this->assertResult(true, $c, new stdClass(), 'root-level key missing'); + $this->assertResult(false, $c, ['x' => '1', 'y' => 1], 'root-level key exists (extra key)'); + $this->assertResult(true, $c, ['y' => 1], 'root-level key missing (extra key)'); + + $c = new Matches(['x' => ['y' => ['$$exists' => true]]]); + $this->assertResult(true, $c, ['x' => ['y' => '1']], 'embedded key exists'); + $this->assertResult(false, $c, ['x' => new stdClass()], 'embedded key missing'); + + $c = new Matches(['x' => ['y' => ['$$exists' => false]]]); + $this->assertResult(false, $c, ['x' => ['y' => 1]], 'embedded key exists'); + $this->assertResult(true, $c, ['x' => new stdClass()], 'embedded key missing'); + } + + public function testOperatorType() + { + $c = new Matches(['x' => ['$$type' => 'string']]); + $this->assertResult(true, $c, ['x' => 'foo'], 'string matches string type'); + $this->assertResult(false, $c, ['x' => 1], 'integer does not match string type'); + + $c = new Matches(['x' => ['$$type' => ['string', 'bool']]]); + $this->assertResult(true, $c, ['x' => 'foo'], 'string matches [string,bool] type'); + $this->assertResult(true, $c, ['x' => true], 'bool matches [string,bool] type'); + $this->assertResult(false, $c, ['x' => 1], 'integer does not match [string,bool] type'); + } + + public function testOperatorMatchesEntity() + { + $entityMap = new EntityMap(); + $entityMap->set('integer', 1); + $entityMap->set('object', ['y' => 1]); + + $c = new Matches(['x' => ['$$matchesEntity' => 'integer']], $entityMap); + $this->assertResult(true, $c, ['x' => 1], 'value matches integer entity (embedded)'); + $this->assertResult(false, $c, ['x' => 2], 'value does not match integer entity (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1]], 'value does not match integer entity (embedded)'); + + $c = new Matches(['x' => ['$$matchesEntity' => 'object']], $entityMap); + $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches object entity (embedded)'); + $this->assertResult(false, $c, ['x' => 1], 'value does not match object entity (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (embedded)'); + + $c = new Matches(['$$matchesEntity' => 'object'], $entityMap); + $this->assertResult(true, $c, ['y' => 1], 'value matches object entity (root-level)'); + $this->assertResult(true, $c, ['x' => 2, 'y' => 1], 'value matches object entity (root-level)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match object entity (root-level)'); + } + + public function testOperatorMatchesHexBytes() + { + $c = new Matches(['$$matchesHexBytes' => 'DEADBEEF']); + $this->assertResult(true, $c, hex2bin('DEADBEEF'), 'value matches hex bytes (root-level)'); + $this->assertResult(false, $c, hex2bin('90ABCDEF'), 'value does not match hex bytes (root-level)'); + + $c = new Matches(['x' => ['$$matchesHexBytes' => '90ABCDEF']]); + $this->assertResult(true, $c, ['x' => hex2bin('90ABCDEF')], 'value matches hex bytes (embedded)'); + $this->assertResult(false, $c, ['x' => hex2bin('DEADBEEF')], 'value does not match hex bytes (embedded)'); + } + + public function testOperatorUnsetOrMatches() + { + $c = new Matches(['$$unsetOrMatches' => ['x' => 1]]); + $this->assertResult(true, $c, null, 'null value is considered unset (root-level)'); + $this->assertResult(true, $c, ['x' => 1], 'value matches (root-level)'); + $this->assertResult(true, $c, ['x' => 1, 'y' => 1], 'value matches (root-level)'); + $this->assertResult(false, $c, ['x' => 2], 'value does not match (root-level)'); + + $c = new Matches(['x' => ['$$unsetOrMatches' => ['y' => 1]]]); + $this->assertResult(true, $c, new stdClass(), 'missing value is considered unset (embedded)'); + $this->assertResult(false, $c, ['x' => null], 'null value is not considered unset (embedded)'); + $this->assertResult(true, $c, ['x' => ['y' => 1]], 'value matches (embedded)'); + $this->assertResult(false, $c, ['x' => ['y' => 1, 'z' => 2]], 'value does not match (embedded)'); + } + + public function testOperatorSessionLsid() + { + if (version_compare($this->getFeatureCompatibilityVersion(), '3.6', '<')) { + $this->markTestSkipped('startSession() is only supported on FCV 3.6 or higher'); + } + + $session = $this->manager->startSession(); + + $entityMap = new EntityMap(); + $entityMap->set('session', $session); + + $lsidWithWrongId = ['id' => new Binary('0123456789ABCDEF', Binary::TYPE_UUID)]; + $lsidWithExtraField = (array) $session->getLogicalSessionId() + ['y' => 1]; + + $c = new Matches(['$$sessionLsid' => 'session'], $entityMap); + $this->assertResult(true, $c, $session->getLogicalSessionId(), 'session LSID matches (root-level)'); + $this->assertResult(false, $c, $lsidWithWrongId, 'session LSID does not match (root-level)'); + $this->assertResult(false, $c, $lsidWithExtraField, 'session LSID does not match (root-level)'); + $this->assertResult(false, $c, 1, 'session LSID does not match (root-level)'); + + $c = new Matches(['x' => ['$$sessionLsid' => 'session']], $entityMap); + $this->assertResult(true, $c, ['x' => $session->getLogicalSessionId()], 'session LSID matches (embedded)'); + $this->assertResult(false, $c, ['x' => $lsidWithWrongId], 'session LSID does not match (embedded)'); + $this->assertResult(false, $c, ['x' => $lsidWithExtraField], 'session LSID does not match (embedded)'); + $this->assertResult(false, $c, ['x' => 1], 'session LSID does not match (embedded)'); + } + + /** + * @dataProvider errorMessageProvider + */ + public function testErrorMessages($expectedMessagePart, Matches $constraint, $actualValue) + { + try { + $constraint->evaluate($actualValue); + $this->fail('Expected a comparison failure'); + } catch (ExpectationFailedException $e) { + $this->assertStringContainsString('Failed asserting that expected value matches actual value.', $e->getMessage()); + $this->assertStringContainsString($expectedMessagePart, $e->getMessage()); + } + } + + public function errorMessageProvider() + { + return [ + 'assertEquals: type check (root-level)' => [ + 'boolean is not expected type "string"', + new Matches('foo'), + true, + ], + 'assertEquals: type check (embedded)' => [ + 'Field path "x": boolean is not expected type "string"', + new Matches(['x' => 'foo']), + ['x' => true], + ], + 'assertEquals: comparison failure (root-level)' => [ + 'Failed asserting that two strings are equal.', + new Matches('foo'), + 'bar', + ], + 'assertEquals: comparison failure (embedded)' => [ + 'Field path "x": Failed asserting that two strings are equal.', + new Matches(['x' => 'foo']), + ['x' => 'bar'], + ], + 'assertMatchesArray: type check (root-level)' => [ + 'MongoDB\Model\BSONDocument is not instance of expected class "MongoDB\Model\BSONArray"', + new Matches([1, 2, 3]), + ['x' => 1], + ], + 'assertMatchesArray: type check (embedded)' => [ + 'Field path "x": integer is not instance of expected class "MongoDB\Model\BSONArray"', + new Matches(['x' => [1, 2, 3]]), + ['x' => 1], + ], + 'assertMatchesArray: count check (root-level)' => [ + '$actual count is 2, expected 3', + new Matches(['x' => [1, 2, 3]]), + ['x' => [1, 2]], + ], + 'assertMatchesArray: count check (embedded)' => [ + 'Field path "x": $actual count is 2, expected 3', + new Matches(['x' => [1, 2, 3]]), + ['x' => [1, 2]], + ], + 'assertMatchesDocument: type check (root-level)' => [ + 'integer is not instance of expected class "MongoDB\Model\BSONDocument"', + new Matches(['x' => 1]), + 1, + ], + 'assertMatchesDocument: type check (embedded)' => [ + 'Field path "x": integer is not instance of expected class "MongoDB\Model\BSONDocument"', + new Matches(['x' => ['y' => 1]]), + ['x' => 1], + ], + 'assertMatchesDocument: expected key missing (root-level)' => [ + '$actual does not have expected key "x"', + new Matches(['x' => 1]), + new stdClass(), + ], + 'assertMatchesDocument: expected key missing (embedded)' => [ + 'Field path "x": $actual does not have expected key "y"', + new Matches(['x' => ['y' => 1]]), + ['x' => new stdClass()], + ], + 'assertMatchesDocument: unexpected key present (embedded)' => [ + 'Field path "x": $actual has unexpected key "y', + new Matches(['x' => new stdClass()]), + ['x' => ['y' => 1]], + ], + ]; + } + + /** + * @dataProvider operatorErrorMessageProvider + */ + public function testOperatorSyntaxValidation($expectedMessage, Matches $constraint) + { + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage($expectedMessage); + + $constraint->evaluate(['x' => 1], '', true); + } + + public function operatorErrorMessageProvider() + { + return [ + '$$exists type' => [ + '$$exists requires bool', + new Matches(['x' => ['$$exists' => 1]]), + ], + '$$type type (string)' => [ + '$$type requires string or string[]', + new Matches(['x' => ['$$type' => 1]]), + ], + '$$type type (string[])' => [ + '$$type requires string or string[]', + new Matches(['x' => ['$$type' => [1]]]), + ], + '$$matchesEntity requires EntityMap' => [ + '$$matchesEntity requires EntityMap', + new Matches(['x' => ['$$matchesEntity' => 'foo']]), + ], + '$$matchesEntity type' => [ + '$$matchesEntity requires string', + new Matches(['x' => ['$$matchesEntity' => 1]], new EntityMap()), + ], + '$$matchesHexBytes type' => [ + '$$matchesHexBytes requires string', + new Matches(['$$matchesHexBytes' => 1]), + ], + '$$matchesHexBytes string format' => [ + '$$matchesHexBytes requires pairs of hex chars', + new Matches(['$$matchesHexBytes' => 'f00']), + ], + '$$sessionLsid requires EntityMap' => [ + '$$sessionLsid requires EntityMap', + new Matches(['x' => ['$$sessionLsid' => 'foo']]), + ], + '$$sessionLsid type' => [ + '$$sessionLsid requires string', + new Matches(['x' => ['$$sessionLsid' => 1]], new EntityMap()), + ], + ]; + } + + private function assertResult($expected, Matches $constraint, $value, string $message = '') + { + $this->assertSame($expected, $constraint->evaluate($value, '', true), $message); + } +} diff --git a/tests/UnifiedSpecTests/Context.php b/tests/UnifiedSpecTests/Context.php new file mode 100644 index 000000000..f52787c9f --- /dev/null +++ b/tests/UnifiedSpecTests/Context.php @@ -0,0 +1,448 @@ +entityMap = new EntityMap(); + $this->internalClient = $internalClient; + $this->uri = $uri; + } + + /** + * Create entities for "createEntities". + * + * @param array $createEntities + */ + public function createEntities(array $entities) + { + foreach ($entities as $entity) { + assertInternalType('object', $entity); + $entity = (array) $entity; + assertCount(1, $entity); + + $type = key($entity); + $def = current($entity); + assertInternalType('object', $def); + + $id = $def->id ?? null; + assertInternalType('string', $id); + + switch ($type) { + case 'client': + $this->createClient($id, $def); + break; + + case 'database': + $this->createDatabase($id, $def); + break; + + case 'collection': + $this->createCollection($id, $def); + break; + + case 'session': + $this->createSession($id, $def); + break; + + case 'bucket': + $this->createBucket($id, $def); + break; + + default: + throw new LogicException('Unsupported entity type: ' . $type); + } + } + } + + public function getEntityMap() : EntityMap + { + return $this->entityMap; + } + + public function getInternalClient() : Client + { + return $this->internalClient; + } + + public function isDirtySession(string $sessionId) : bool + { + return in_array($sessionId, $this->dirtySessions); + } + + public function markDirtySession(string $sessionId) + { + if ($this->isDirtySession($sessionId)) { + return; + } + + $this->dirtySessions[] = $sessionId; + } + + public function isActiveClient(string $clientId) : bool + { + return $this->activeClient === $clientId; + } + + public function setActiveClient(string $clientId = null) + { + $this->activeClient = $clientId; + } + + public function assertExpectedEventsForClients(array $expectedEventsForClients) + { + assertNotEmpty($expectedEventsForClients); + + foreach ($expectedEventsForClients as $expectedEventsForClient) { + assertInternalType('object', $expectedEventsForClient); + Util::assertHasOnlyKeys($expectedEventsForClient, ['client', 'events']); + + $client = $expectedEventsForClient->client ?? null; + $expectedEvents = $expectedEventsForClient->events ?? null; + + assertInternalType('string', $client); + assertArrayHasKey($client, $this->eventObserversByClient); + assertInternalType('array', $expectedEvents); + + $this->eventObserversByClient[$client]->assert($expectedEvents); + } + } + + public function startEventObservers() + { + foreach ($this->eventObserversByClient as $eventObserver) { + $eventObserver->start(); + } + } + + public function stopEventObservers() + { + foreach ($this->eventObserversByClient as $eventObserver) { + $eventObserver->stop(); + } + } + + public function getEventObserverForClient(string $id) : EventObserver + { + assertArrayHasKey($id, $this->eventObserversByClient); + + return $this->eventObserversByClient[$id]; + } + + /** @param string|array $readPreferenceTags */ + private function convertReadPreferenceTags($readPreferenceTags) : array + { + return array_map( + static function (string $readPreferenceTagSet) : array { + $tags = explode(',', $readPreferenceTagSet); + + return array_map( + static function (string $tag) : array { + list($key, $value) = explode(':', $tag); + + return [$key => $value]; + }, + $tags + ); + }, + (array) $readPreferenceTags + ); + } + + private function createClient(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'uriOptions', 'useMultipleMongoses', 'observeEvents', 'ignoreCommandMonitoringEvents']); + + $useMultipleMongoses = $o->useMultipleMongoses ?? null; + $observeEvents = $o->observeEvents ?? null; + $ignoreCommandMonitoringEvents = $o->ignoreCommandMonitoringEvents ?? []; + + $uri = $this->uri; + + if (isset($useMultipleMongoses)) { + assertInternalType('bool', $useMultipleMongoses); + + if ($useMultipleMongoses) { + self::requireMultipleMongoses($uri); + } else { + $uri = self::removeMultipleMongoses($uri); + } + } + + $uriOptions = []; + + if (isset($o->uriOptions)) { + assertInternalType('object', $o->uriOptions); + $uriOptions = (array) $o->uriOptions; + + if (! empty($uriOptions['readPreferenceTags'])) { + /* readPreferenceTags may take the following form: + * + * 1. A string containing multiple tags: "dc:ny,rack:1". + * Expected result: [["dc" => "ny", "rack" => "1"]] + * 2. An array containing multiple strings as above: ["dc:ny,rack:1", "dc:la"]. + * Expected result: [["dc" => "ny", "rack" => "1"], ["dc" => "la"]] + */ + $uriOptions['readPreferenceTags'] = $this->convertReadPreferenceTags($uriOptions['readPreferenceTags']); + } + } + + if (isset($observeEvents)) { + assertInternalType('array', $observeEvents); + assertInternalType('array', $ignoreCommandMonitoringEvents); + + $this->eventObserversByClient[$id] = new EventObserver($observeEvents, $ignoreCommandMonitoringEvents, $id, $this); + } + + /* TODO: Remove this once PHPC-1645 is implemented. Each client needs + * its own libmongoc client to facilitate txnNumber assertions. */ + static $i = 0; + $driverOptions = isset($observeEvents) ? ['i' => $i++] : []; + + $this->entityMap->set($id, new Client($uri, $uriOptions, $driverOptions)); + } + + private function createCollection(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'database', 'collectionName', 'collectionOptions']); + + $collectionName = $o->collectionName ?? null; + $databaseId = $o->database ?? null; + + assertInternalType('string', $collectionName); + assertInternalType('string', $databaseId); + + $database = $this->entityMap->getDatabase($databaseId); + + $options = []; + + if (isset($o->collectionOptions)) { + assertInternalType('object', $o->collectionOptions); + $options = self::prepareCollectionOrDatabaseOptions((array) $o->collectionOptions); + } + + $this->entityMap->set($id, $database->selectCollection($o->collectionName, $options), $databaseId); + } + + private function createDatabase(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'client', 'databaseName', 'databaseOptions']); + + $databaseName = $o->databaseName ?? null; + $clientId = $o->client ?? null; + + assertInternalType('string', $databaseName); + assertInternalType('string', $clientId); + + $client = $this->entityMap->getClient($clientId); + + $options = []; + + if (isset($o->databaseOptions)) { + assertInternalType('object', $o->databaseOptions); + $options = self::prepareCollectionOrDatabaseOptions((array) $o->databaseOptions); + } + + $this->entityMap->set($id, $client->selectDatabase($databaseName, $options), $clientId); + } + + private function createSession(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'client', 'sessionOptions']); + + $clientId = $o->client ?? null; + assertInternalType('string', $clientId); + $client = $this->entityMap->getClient($clientId); + + $options = []; + + if (isset($o->sessionOptions)) { + assertInternalType('object', $o->sessionOptions); + $options = self::prepareSessionOptions((array) $o->sessionOptions); + } + + $this->entityMap->set($id, $client->startSession($options), $clientId); + } + + private function createBucket(string $id, stdClass $o) + { + Util::assertHasOnlyKeys($o, ['id', 'database', 'bucketOptions']); + + $databaseId = $o->database ?? null; + assertInternalType('string', $databaseId); + $database = $this->entityMap->getDatabase($databaseId); + + $options = []; + + if (isset($o->bucketOptions)) { + assertInternalType('object', $o->bucketOptions); + $options = self::prepareBucketOptions((array) $o->bucketOptions); + } + + $this->entityMap->set($id, $database->selectGridFSBucket($options), $databaseId); + } + + private static function prepareCollectionOrDatabaseOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['readConcern', 'readPreference', 'writeConcern']); + + return Util::prepareCommonOptions($options); + } + + private static function prepareBucketOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['bucketName', 'chunkSizeBytes', 'disableMD5', 'readConcern', 'readPreference', 'writeConcern']); + + if (array_key_exists('bucketName', $options)) { + assertInternalType('string', $options['bucketName']); + } + + if (array_key_exists('chunkSizeBytes', $options)) { + assertInternalType('int', $options['chunkSizeBytes']); + } + + if (array_key_exists('disableMD5', $options)) { + assertInternalType('bool', $options['disableMD5']); + } + + return Util::prepareCommonOptions($options); + } + + private static function prepareSessionOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['causalConsistency', 'defaultTransactionOptions']); + + if (array_key_exists('causalConsistency', $options)) { + assertInternalType('bool', $options['causalConsistency']); + } + + if (array_key_exists('defaultTransactionOptions', $options)) { + assertInternalType('object', $options['defaultTransactionOptions']); + $options['defaultTransactionOptions'] = self::prepareDefaultTransactionOptions((array) $options['defaultTransactionOptions']); + } + + return $options; + } + + private static function prepareDefaultTransactionOptions(array $options) : array + { + Util::assertHasOnlyKeys($options, ['maxCommitTimeMS', 'readConcern', 'readPreference', 'writeConcern']); + + if (array_key_exists('maxCommitTimeMS', $options)) { + assertInternalType('int', $options['maxCommitTimeMS']); + } + + return Util::prepareCommonOptions($options); + } + + /** + * Removes mongos hosts beyond the first if the URI refers to a sharded + * cluster. Otherwise, the URI is returned as-is. + */ + private static function removeMultipleMongoses(string $uri) : string + { + assertStringStartsWith('mongodb://', $uri); + + $manager = new Manager($uri); + + // Nothing to do if the URI does not refer to a sharded cluster + if ($manager->selectServer(new ReadPreference(ReadPreference::PRIMARY))->getType() !== Server::TYPE_MONGOS) { + return $uri; + } + + $parts = parse_url($uri); + + assertInternalType('array', $parts); + + $hosts = explode(',', $parts['host']); + + // Nothing to do if the URI already has a single mongos host + if (count($hosts) === 1) { + return $uri; + } + + // Re-append port to last host + if (isset($parts['port'])) { + $hosts[count($hosts) - 1] .= ':' . $parts['port']; + } + + $singleHost = $hosts[0]; + $multipleHosts = implode(',', $hosts); + + $pos = strpos($uri, $multipleHosts); + + assertNotFalse($pos); + + return substr_replace($uri, $singleHost, $pos, strlen($multipleHosts)); + } + + /** + * Requires multiple mongos hosts if the URI refers to a sharded cluster. + */ + private static function requireMultipleMongoses(string $uri) + { + assertStringStartsWith('mongodb://', $uri); + + $manager = new Manager($uri); + + // Nothing to do if the URI does not refer to a sharded cluster + if ($manager->selectServer(new ReadPreference(ReadPreference::PRIMARY))->getType() !== Server::TYPE_MONGOS) { + return; + } + + assertContains(',', parse_url($uri, PHP_URL_HOST)); + } +} diff --git a/tests/UnifiedSpecTests/DirtySessionObserver.php b/tests/UnifiedSpecTests/DirtySessionObserver.php new file mode 100644 index 000000000..e69f7e534 --- /dev/null +++ b/tests/UnifiedSpecTests/DirtySessionObserver.php @@ -0,0 +1,83 @@ +lsid = $lsid; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandfailed.php + */ + public function commandFailed(CommandFailedEvent $event) + { + if (! in_array($event->getRequestId(), $this->requestIds)) { + return; + } + + if ($event->getError() instanceof ConnectionException) { + $this->observedNetworkError = true; + } + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandstarted.php + */ + public function commandStarted(CommandStartedEvent $event) + { + if ($this->lsid == ($event->getCommand()->lsid ?? null)) { + $this->requestIds[] = $event->getRequestId(); + } + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + } + + public function observedNetworkError() : bool + { + return $this->observedNetworkError; + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } +} diff --git a/tests/UnifiedSpecTests/EntityMap.php b/tests/UnifiedSpecTests/EntityMap.php new file mode 100644 index 000000000..3329c94f3 --- /dev/null +++ b/tests/UnifiedSpecTests/EntityMap.php @@ -0,0 +1,179 @@ +map as $entity) { + if ($entity->value instanceof Session) { + $entity->value->endSession(); + } + } + } + + /** + * @see http://php.net/arrayaccess.offsetexists + */ + public function offsetExists($id) + { + assertInternalType('string', $id); + + return array_key_exists($id, $this->map); + } + + /** + * @see http://php.net/arrayaccess.offsetget + */ + public function offsetGet($id) + { + assertInternalType('string', $id); + assertArrayHasKey($id, $this->map, sprintf('No entity is defined for "%s"', $id)); + + return $this->map[$id]->value; + } + + /** + * @see http://php.net/arrayaccess.offsetset + */ + public function offsetSet($id, $value) + { + Assert::fail('Entities can only be set via register()'); + } + + /** + * @see http://php.net/arrayaccess.offsetunset + */ + public function offsetUnset($id) + { + Assert::fail('Entities cannot be removed from the map'); + } + + public function set(string $id, $value, string $parentId = null) + { + assertArrayNotHasKey($id, $this->map, sprintf('Entity already exists for "%s" and cannot be replaced', $id)); + assertThat($value, self::isSupportedType()); + + if ($value instanceof Session) { + $this->lsidsBySession[$id] = $value->getLogicalSessionId(); + } + + $parent = $parentId === null ? null : $this->map[$parentId]; + + $this->map[$id] = new class ($id, $value, $parent) { + /** @var string */ + public $id; + /** @var mixed */ + public $value; + /** @var self */ + public $parent; + + public function __construct(string $id, $value, self $parent = null) + { + $this->id = $id; + $this->value = $value; + $this->parent = $parent; + } + + public function getRoot() : self + { + $root = $this; + + while ($root->parent !== null) { + $root = $root->parent; + } + + return $root; + } + }; + } + + public function getClient(string $clientId) : Client + { + return $this[$clientId]; + } + + public function getCollection(string $collectionId) : Collection + { + return $this[$collectionId]; + } + + public function getDatabase(string $databaseId) : Database + { + return $this[$databaseId]; + } + + public function getSession(string $sessionId) : Session + { + return $this[$sessionId]; + } + + public function getLogicalSessionId(string $sessionId) : stdClass + { + return $this->lsidsBySession[$sessionId]; + } + + public function getRootClientIdOf(string $id) + { + $root = $this->map[$id]->getRoot(); + + return $root->value instanceof Client ? $root->id : null; + } + + private static function isSupportedType() : Constraint + { + if (self::$isSupportedType === null) { + self::$isSupportedType = logicalOr( + isInstanceOf(Client::class), + isInstanceOf(Database::class), + isInstanceOf(Collection::class), + isInstanceOf(Session::class), + isInstanceOf(Bucket::class), + isInstanceOf(ChangeStream::class), + IsBsonType::any() + ); + } + + return self::$isSupportedType; + } +} diff --git a/tests/UnifiedSpecTests/EventObserver.php b/tests/UnifiedSpecTests/EventObserver.php new file mode 100644 index 000000000..b9551030e --- /dev/null +++ b/tests/UnifiedSpecTests/EventObserver.php @@ -0,0 +1,262 @@ + CommandStartedEvent::class, + 'commandSucceededEvent' => CommandSucceededEvent::class, + 'commandFailedEvent' => CommandFailedEvent::class, + ]; + + /** @var array */ + private $actualEvents = []; + + /** @var string */ + private $clientId; + + /** @var Context */ + private $context; + + /** @var array */ + private $ignoreCommands = []; + + /** @var array */ + private $observeEvents = []; + + public function __construct(array $observeEvents, array $ignoreCommands, string $clientId, Context $context) + { + assertNotEmpty($observeEvents); + + foreach ($observeEvents as $event) { + assertInternalType('string', $event); + assertArrayHasKey($event, self::$supportedEvents); + $this->observeEvents[self::$supportedEvents[$event]] = 1; + } + + $this->ignoreCommands = array_fill_keys(self::$defaultIgnoreCommands, 1); + + foreach ($ignoreCommands as $command) { + assertInternalType('string', $command); + $this->ignoreCommands[$command] = 1; + } + + $this->clientId = $clientId; + $this->context = $context; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandfailed.php + */ + public function commandFailed(CommandFailedEvent $event) + { + $this->handleEvent($event); + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandstarted.php + */ + public function commandStarted(CommandStartedEvent $event) + { + $this->handleEvent($event); + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + $this->handleEvent($event); + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } + + public function getLsidsOnLastTwoCommands() : array + { + $lsids = []; + + foreach (array_reverse($this->actualEvents) as $event) { + if (! $event instanceof CommandStartedEvent) { + continue; + } + + $command = $event->getCommand(); + assertObjectHasAttribute('lsid', $command); + $lsids[] = $command->lsid; + + if (count($lsids) === 2) { + return $lsids; + } + } + + Assert::fail('Not enough CommandStartedEvents observed'); + } + + public function assert(array $expectedEvents) + { + assertCount(count($expectedEvents), $this->actualEvents); + + $mi = new MultipleIterator(MultipleIterator::MIT_NEED_ANY); + $mi->attachIterator(new ArrayIterator($expectedEvents)); + $mi->attachIterator(new ArrayIterator($this->actualEvents)); + + foreach ($mi as $keys => $events) { + list($expectedEvent, $actualEvent) = $events; + + assertInternalType('object', $expectedEvent); + $expectedEvent = (array) $expectedEvent; + assertCount(1, $expectedEvent); + + $type = key($expectedEvent); + assertArrayHasKey($type, self::$supportedEvents); + $data = current($expectedEvent); + assertInternalType('object', $data); + + // Message is used for actual event assertions (not test structure) + $message = sprintf('%s event[%d]', $this->clientId, $keys[0]); + + assertInstanceOf(self::$supportedEvents[$type], $actualEvent, $message . ': type matches'); + $this->assertEvent($actualEvent, $data, $message); + } + } + + private function assertEvent($actual, stdClass $expected, string $message) + { + assertInternalType('object', $actual); + + switch (get_class($actual)) { + case CommandStartedEvent::class: + return $this->assertCommandStartedEvent($actual, $expected, $message); + case CommandSucceededEvent::class: + return $this->assertCommandSucceededEvent($actual, $expected, $message); + case CommandFailedEvent::class: + return $this->assertCommandFailedEvent($actual, $expected, $message); + default: + Assert::fail($message . ': Unsupported event type: ' . get_class($actual)); + } + } + + private function assertCommandStartedEvent(CommandStartedEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['command', 'commandName', 'databaseName']); + + if (isset($expected->command)) { + assertInternalType('object', $expected->command); + $constraint = new Matches($expected->command, $this->context->getEntityMap()); + assertThat($actual->getCommand(), $constraint, $message . ': command matches'); + } + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + + if (isset($expected->databaseName)) { + assertInternalType('string', $expected->databaseName); + assertSame($actual->getDatabaseName(), $expected->databaseName, $message . ': databaseName matches'); + } + } + + private function assertCommandSucceededEvent(CommandSucceededEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['reply', 'commandName']); + + if (isset($expected->reply)) { + assertInternalType('object', $expected->reply); + $constraint = new Matches($expected->reply, $this->context->getEntityMap()); + assertThat($actual->getReply(), $constraint, $message . ': reply matches'); + } + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + } + + private function assertCommandFailedEvent(CommandFailedEvent $actual, stdClass $expected, string $message) + { + Util::assertHasOnlyKeys($expected, ['commandName']); + + if (isset($expected->commandName)) { + assertInternalType('string', $expected->commandName); + assertSame($actual->getCommandName(), $expected->commandName, $message . ': commandName matches'); + } + } + + /** @param CommandStartedEvent|CommandSucceededEvent|CommandFailedEvent $event */ + private function handleEvent($event) + { + if (! $this->context->isActiveClient($this->clientId)) { + return; + } + + if (! is_object($event)) { + return; + } + + if (! isset($this->observeEvents[get_class($event)])) { + return; + } + + if (isset($this->ignoreCommands[$event->getCommandName()])) { + return; + } + + $this->actualEvents[] = $event; + } +} diff --git a/tests/UnifiedSpecTests/ExpectedError.php b/tests/UnifiedSpecTests/ExpectedError.php new file mode 100644 index 000000000..46ea86129 --- /dev/null +++ b/tests/UnifiedSpecTests/ExpectedError.php @@ -0,0 +1,191 @@ + 11601, + 'MaxTimeMSExpired' => 50, + 'NoSuchTransaction' => 251, + 'OperationNotSupportedInTransaction' => 263, + 'WriteConflict' => 112, + ]; + + /** @var bool */ + private $isError = false; + + /** @var bool|null */ + private $isClientError; + + /** @var string|null */ + private $messageContains; + + /** @var int|null */ + private $code; + + /** @var string|null */ + private $codeName; + + /** @var array */ + private $includedLabels = []; + + /** @var array */ + private $excludedLabels = []; + + /** @var ExpectedResult|null */ + private $expectedResult; + + public function __construct(stdClass $o = null, EntityMap $entityMap) + { + if ($o === null) { + return; + } + + $this->isError = true; + + if (isset($o->isError)) { + assertTrue($o->isError); + } + + if (isset($o->isClientError)) { + assertInternalType('bool', $o->isClientError); + $this->isClientError = $o->isClientError; + } + + if (isset($o->errorContains)) { + assertInternalType('string', $o->errorContains); + $this->messageContains = $o->errorContains; + } + + if (isset($o->errorCode)) { + assertInternalType('int', $o->errorCode); + $this->code = $o->errorCode; + } + + if (isset($o->errorCodeName)) { + assertInternalType('string', $o->errorCodeName); + $this->codeName = $o->errorCodeName; + } + + if (isset($o->errorLabelsContain)) { + assertInternalType('array', $o->errorLabelsContain); + assertContainsOnly('string', $o->errorLabelsContain); + $this->includedLabels = $o->errorLabelsContain; + } + + if (isset($o->errorLabelsOmit)) { + assertInternalType('array', $o->errorLabelsOmit); + assertContainsOnly('string', $o->errorLabelsOmit); + $this->excludedLabels = $o->errorLabelsOmit; + } + + if (property_exists($o, 'expectResult')) { + $this->expectedResult = new ExpectedResult($o, $entityMap); + } + } + + /** + * Assert the outcome of an operation. + * + * @param Throwable|null $e Exception (if any) from executing an operation + */ + public function assert(Throwable $e = null) + { + if (! $this->isError && $e !== null) { + Assert::fail(sprintf("Operation threw unexpected %s: %s\n%s", get_class($e), $e->getMessage(), $e->getTraceAsString())); + } + + if (! $this->isError) { + assertNull($e); + + return; + } + + assertNotNull($e); + + if (isset($this->messageContains)) { + assertStringContainsStringIgnoringCase($this->messageContains, $e->getMessage()); + } + + if (isset($this->code)) { + assertInstanceOf(ServerException::class, $e); + assertSame($this->code, $e->getCode()); + } + + if (isset($this->codeName)) { + assertInstanceOf(ServerException::class, $e); + $this->assertCodeName($e); + } + + if (! empty($this->excludedLabels) || ! empty($this->includedLabels)) { + assertInstanceOf(RuntimeException::class, $e); + + foreach ($this->excludedLabels as $label) { + assertFalse($e->hasErrorLabel($label), 'Exception should not have error label: ' . $label); + } + + foreach ($this->includedLabels as $label) { + assertTrue($e->hasErrorLabel($label), 'Exception should have error label: ' . $label); + } + } + + if (isset($this->expectedResult)) { + assertInstanceOf(BulkWriteException::class, $e); + $this->expectedResult->assert($e->getWriteResult()); + } + } + + private function assertCodeName(ServerException $e) + { + /* BulkWriteException and ExecutionTimeoutException do not expose + * codeName. Work around this by translating it to a numeric code. + * + * TODO: Remove this once PHPC-1386 is resolved. */ + if ($e instanceof BulkWriteException || $e instanceof ExecutionTimeoutException) { + assertArrayHasKey($this->codeName, self::$codeNameMap); + assertSame(self::$codeNameMap[$this->codeName], $e->getCode()); + + return; + } + + assertInstanceOf(CommandException::class, $e); + $result = $e->getResultDocument(); + + if (isset($result->writeConcernError)) { + assertObjectHasAttribute('codeName', $result->writeConcernError); + assertSame($this->codeName, $result->writeConcernError->codeName); + + return; + } + + assertObjectHasAttribute('codeName', $result); + assertSame($this->codeName, $result->codeName); + } +} diff --git a/tests/UnifiedSpecTests/ExpectedResult.php b/tests/UnifiedSpecTests/ExpectedResult.php new file mode 100644 index 000000000..6c4defab0 --- /dev/null +++ b/tests/UnifiedSpecTests/ExpectedResult.php @@ -0,0 +1,123 @@ +constraint = new Matches($o->expectResult, $entityMap); + } + + $this->entityMap = $entityMap; + $this->yieldingEntityId = $yieldingEntityId; + } + + public function assert($actual, string $saveResultAsEntity = null) + { + if ($this->constraint === null && $saveResultAsEntity === null) { + return; + } + + $actual = self::prepare($actual); + + if ($this->constraint !== null) { + assertThat($actual, $this->constraint); + } + + if ($saveResultAsEntity !== null) { + $this->entityMap->set($saveResultAsEntity, $actual, $this->yieldingEntityId); + } + } + + private static function prepare($value) + { + if (! is_object($value)) { + return $value; + } + + if ($value instanceof BulkWriteResult || + $value instanceof WriteResult || + $value instanceof DeleteResult || + $value instanceof InsertOneResult || + $value instanceof InsertManyResult || + $value instanceof UpdateResult) { + return self::prepareWriteResult($value); + } + + return $value; + } + + private static function prepareWriteResult($value) + { + $result = ['acknowledged' => $value->isAcknowledged()]; + + if (! $result['acknowledged']) { + return $result; + } + + if ($value instanceof BulkWriteResult || $value instanceof WriteResult) { + $result['deletedCount'] = $value->getDeletedCount(); + $result['insertedCount'] = $value->getInsertedCount(); + $result['matchedCount'] = $value->getMatchedCount(); + $result['modifiedCount'] = $value->getModifiedCount(); + $result['upsertedCount'] = $value->getUpsertedCount(); + $result['upsertedIds'] = (object) $value->getUpsertedIds(); + } + + // WriteResult does not provide insertedIds (see: PHPLIB-428) + if ($value instanceof BulkWriteResult) { + $result['insertedIds'] = (object) $value->getInsertedIds(); + } + + if ($value instanceof DeleteResult) { + $result['deletedCount'] = $value->getDeletedCount(); + } + + if ($value instanceof InsertManyResult) { + $result['insertedCount'] = $value->getInsertedCount(); + $result['insertedIds'] = (object) $value->getInsertedIds(); + } + + if ($value instanceof InsertOneResult) { + $result['insertedCount'] = $value->getInsertedCount(); + $result['insertedId'] = $value->getInsertedId(); + } + + if ($value instanceof UpdateResult) { + $result['matchedCount'] = $value->getMatchedCount(); + $result['modifiedCount'] = $value->getModifiedCount(); + $result['upsertedCount'] = $value->getUpsertedCount(); + $result['upsertedId'] = $value->getUpsertedId(); + } + + return $result; + } +} diff --git a/tests/UnifiedSpecTests/FailPointObserver.php b/tests/UnifiedSpecTests/FailPointObserver.php new file mode 100644 index 000000000..f7794f7fc --- /dev/null +++ b/tests/UnifiedSpecTests/FailPointObserver.php @@ -0,0 +1,69 @@ +getCommand(); + + if (! isset($command->configureFailPoint)) { + return; + } + + if (isset($command->mode) && $command->mode === 'off') { + return; + } + + $this->failPointsAndServers[] = [$command->configureFailPoint, $event->getServer()]; + } + + /** + * @see https://www.php.net/manual/en/mongodb-driver-monitoring-commandsubscriber.commandsucceeded.php + */ + public function commandSucceeded(CommandSucceededEvent $event) + { + } + + public function disableFailPoints() + { + foreach ($this->failPointsAndServers as list($failPoint, $server)) { + $operation = new DatabaseCommand('admin', ['configureFailPoint' => $failPoint, 'mode' => 'off']); + $operation->execute($server); + } + + $this->failPointsAndServers = []; + } + + public function start() + { + addSubscriber($this); + } + + public function stop() + { + removeSubscriber($this); + } +} diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php new file mode 100644 index 000000000..b8cfca6bf --- /dev/null +++ b/tests/UnifiedSpecTests/Operation.php @@ -0,0 +1,657 @@ +context =$context; + $this->entityMap = $context->getEntityMap(); + + assertInternalType('string', $o->name); + $this->name = $o->name; + + assertInternalType('string', $o->object); + $this->isTestRunnerOperation = $o->object === self::OBJECT_TEST_RUNNER; + $this->object = $this->isTestRunnerOperation ? null : $o->object; + + if (isset($o->arguments)) { + assertInternalType('object', $o->arguments); + $this->arguments = (array) $o->arguments; + } + + if (isset($o->expectError) && (property_exists($o, 'expectResult') || isset($o->saveResultAsEntity))) { + Assert::fail('expectError is mutually exclusive with expectResult and saveResultAsEntity'); + } + + $this->expectError = new ExpectedError($o->expectError ?? null, $this->entityMap); + $this->expectResult = new ExpectedResult($o, $this->entityMap, $this->object); + + if (isset($o->saveResultAsEntity)) { + assertInternalType('string', $o->saveResultAsEntity); + $this->saveResultAsEntity = $o->saveResultAsEntity; + } + } + + /** + * Execute the operation and assert its outcome. + */ + public function assert(bool $rethrowExceptions = false) + { + $error = null; + $result = null; + $saveResultAsEntity = null; + + if (isset($this->arguments['session'])) { + $dirtySessionObserver = new DirtySessionObserver($this->entityMap->getLogicalSessionId($this->arguments['session'])); + $dirtySessionObserver->start(); + } + + try { + $result = $this->execute(); + $saveResultAsEntity = $this->saveResultAsEntity; + } catch (Throwable $e) { + /* Note: we must be selective about what PHPUnit exceptions to pass + * through, as PHPUnit's Warning exception must be considered for + * expectError in GridFS tests (see: PHPLIB-592). + * + * TODO: Consider adding operation details (e.g. operations[] index) + * to the exception message. Alternatively, throw a new exception + * and include this as the previous, since PHPUnit will render the + * chain when reporting a test failure. */ + if ($e instanceof AssertionFailedError) { + throw $e; + } + + $error = $e; + } + + if (isset($dirtySessionObserver)) { + $dirtySessionObserver->stop(); + + if ($dirtySessionObserver->observedNetworkError()) { + $this->context->markDirtySession($this->arguments['session']); + } + } + + $this->expectError->assert($error); + $this->expectResult->assert($result, $saveResultAsEntity); + + // Rethrowing is primarily used for withTransaction callbacks + if ($error && $rethrowExceptions) { + throw $error; + } + } + + private function execute() + { + $this->context->setActiveClient(null); + + if ($this->isTestRunnerOperation) { + return $this->executeForTestRunner(); + } + + $object = $this->entityMap[$this->object]; + assertInternalType('object', $object); + + $this->context->setActiveClient($this->entityMap->getRootClientIdOf($this->object)); + + switch (get_class($object)) { + case Client::class: + $result = $this->executeForClient($object); + break; + case Database::class: + $result = $this->executeForDatabase($object); + break; + case Collection::class: + $result = $this->executeForCollection($object); + break; + case ChangeStream::class: + $result = $this->executeForChangeStream($object); + break; + case Session::class: + $result = $this->executeForSession($object); + break; + case Bucket::class: + $result = $this->executeForBucket($object); + break; + default: + Assert::fail('Unsupported entity type: ' . get_class($object)); + } + + if ($result instanceof Traversable && ! $result instanceof ChangeStream) { + return iterator_to_array($result); + } + + return $result; + } + + private function executeForChangeStream(ChangeStream $changeStream) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'iterateUntilDocumentOrError': + /* Note: the first iteration should use rewind, otherwise we may + * miss a document from the initial batch (possible if using a + * resume token). We can infer this from a null key; however, + * if a test ever calls this operation consecutively to expect + * multiple errors from the same ChangeStream we will need a + * different approach (e.g. examining internal hasAdvanced + * property on the ChangeStream). */ + if ($changeStream->key() === null) { + $changeStream->rewind(); + + if ($changeStream->valid()) { + return $changeStream->current(); + } + } + + do { + $changeStream->next(); + } while (! $changeStream->valid()); + + return $changeStream->current(); + default: + Assert::fail('Unsupported client operation: ' . $this->name); + } + } + + private function executeForClient(Client $client) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'createChangeStream': + $changeStream = $client->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + $changeStream->rewind(); + + return $changeStream; + case 'listDatabaseNames': + return $client->listDatabaseNames($args); + case 'listDatabases': + return $client->listDatabases($args); + default: + Assert::fail('Unsupported client operation: ' . $this->name); + } + } + + private function executeForCollection(Collection $collection) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'aggregate': + return $collection->aggregate( + $args['pipeline'], + array_diff_key($args, ['pipeline' => 1]) + ); + case 'bulkWrite': + return $collection->bulkWrite( + array_map('self::prepareBulkWriteRequest', $args['requests']), + array_diff_key($args, ['requests' => 1]) + ); + case 'createChangeStream': + $changeStream = $collection->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + $changeStream->rewind(); + + return $changeStream; + case 'createIndex': + return $collection->createIndex( + $args['keys'], + array_diff_key($args, ['keys' => 1]) + ); + case 'dropIndex': + return $collection->dropIndex( + $args['name'], + array_diff_key($args, ['name' => 1]) + ); + case 'count': + case 'countDocuments': + case 'find': + return $collection->{$this->name}( + $args['filter'] ?? [], + array_diff_key($args, ['filter' => 1]) + ); + case 'estimatedDocumentCount': + return $collection->estimatedDocumentCount($args); + case 'deleteMany': + case 'deleteOne': + case 'findOneAndDelete': + return $collection->{$this->name}( + $args['filter'], + array_diff_key($args, ['filter' => 1]) + ); + case 'distinct': + if (isset($args['session']) && $args['session']->isInTransaction()) { + // Transaction, but sharded cluster? + $collection->distinct('foo'); + } + + return $collection->distinct( + $args['fieldName'], + $args['filter'] ?? [], + array_diff_key($args, ['fieldName' => 1, 'filter' => 1]) + ); + case 'drop': + return $collection->drop($args); + case 'findOne': + return $collection->findOne($args['filter'], array_diff_key($args, ['filter' => 1])); + case 'findOneAndReplace': + if (isset($args['returnDocument'])) { + $args['returnDocument'] = strtolower($args['returnDocument']); + assertThat($args['returnDocument'], logicalOr(equalTo('after'), equalTo('before'))); + + $args['returnDocument'] = 'after' === $args['returnDocument'] + ? FindOneAndReplace::RETURN_DOCUMENT_AFTER + : FindOneAndReplace::RETURN_DOCUMENT_BEFORE; + } + // Fall through + + case 'replaceOne': + return $collection->{$this->name}( + $args['filter'], + $args['replacement'], + array_diff_key($args, ['filter' => 1, 'replacement' => 1]) + ); + case 'findOneAndUpdate': + if (isset($args['returnDocument'])) { + $args['returnDocument'] = strtolower($args['returnDocument']); + assertThat($args['returnDocument'], logicalOr(equalTo('after'), equalTo('before'))); + + $args['returnDocument'] = 'after' === $args['returnDocument'] + ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER + : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE; + } + // Fall through + + case 'updateMany': + case 'updateOne': + return $collection->{$this->name}( + $args['filter'], + $args['update'], + array_diff_key($args, ['filter' => 1, 'update' => 1]) + ); + case 'insertMany': + // Merge nested and top-level options (see: SPEC-1158) + $options = isset($args['options']) ? (array) $args['options'] : []; + $options += array_diff_key($args, ['documents' => 1]); + + return $collection->insertMany( + $args['documents'], + $options + ); + case 'insertOne': + return $collection->insertOne( + $args['document'], + array_diff_key($args, ['document' => 1]) + ); + case 'listIndexes': + return $collection->listIndexes($args); + case 'mapReduce': + return $collection->mapReduce( + $args['map'], + $args['reduce'], + $args['out'], + array_diff_key($args, ['map' => 1, 'reduce' => 1, 'out' => 1]) + ); + default: + Assert::fail('Unsupported collection operation: ' . $this->name); + } + } + + private function executeForDatabase(Database $database) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'aggregate': + return $database->aggregate( + $args['pipeline'], + array_diff_key($args, ['pipeline' => 1]) + ); + case 'createChangeStream': + $changeStream = $database->watch( + $args['pipeline'] ?? [], + array_diff_key($args, ['pipeline' => 1]) + ); + $changeStream->rewind(); + + return $changeStream; + case 'createCollection': + return $database->createCollection( + $args['collection'], + array_diff_key($args, ['collection' => 1]) + ); + case 'dropCollection': + return $database->dropCollection( + $args['collection'], + array_diff_key($args, ['collection' => 1]) + ); + case 'listCollectionNames': + return $database->listCollectionNames($args); + case 'listCollections': + return $database->listCollections($args); + case 'runCommand': + return $database->command( + $args['command'], + array_diff_key($args, ['command' => 1]) + )->toArray()[0]; + default: + Assert::fail('Unsupported database operation: ' . $this->name); + } + } + + private function executeForSession(Session $session) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'abortTransaction': + return $session->abortTransaction(); + case 'commitTransaction': + return $session->commitTransaction(); + case 'endSession': + return $session->endSession(); + case 'startTransaction': + return $session->startTransaction($args); + case 'withTransaction': + assertInternalType('array', $args['callback']); + + $operations = array_map(function ($o) { + assertInternalType('object', $o); + + return new Operation($o, $this->context); + }, $args['callback']); + + $callback = function () use ($operations) { + foreach ($operations as $operation) { + $operation->assert(true); // rethrow exceptions + } + }; + + return with_transaction($session, $callback, array_diff_key($args, ['callback' => 1])); + default: + Assert::fail('Unsupported session operation: ' . $this->name); + } + } + + private function executeForBucket(Bucket $bucket) + { + $args = $this->prepareArguments(); + + switch ($this->name) { + case 'delete': + return $bucket->delete($args['id']); + case 'downloadByName': + return stream_get_contents($bucket->openDownloadStream( + $args['filename'], + array_diff_key($args, ['filename' => 1]) + )); + case 'download': + return stream_get_contents($bucket->openDownloadStream($args['id'])); + case 'uploadWithId': + $args['_id'] = $args['id']; + unset($args['id']); + + // Fall through + + case 'upload': + $args = self::prepareUploadArguments($args); + + return $bucket->uploadFromStream( + $args['filename'], + $args['source'], + array_diff_key($args, ['filename' => 1, 'source' => 1]) + ); + default: + Assert::fail('Unsupported bucket operation: ' . $this->name); + } + } + + private function executeForTestRunner() + { + $args = $this->prepareArguments(); + + if (array_key_exists('client', $args)) { + assertInternalType('string', $args['client']); + $args['client'] = $this->entityMap->getClient($args['client']); + } + + switch ($this->name) { + case 'assertCollectionExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); + assertContains($args['collectionName'], $database->listCollectionNames()); + break; + case 'assertCollectionNotExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + $database = $this->context->getInternalClient()->selectDatabase($args['databaseName']); + assertNotContains($args['collectionName'], $database->listCollectionNames()); + break; + case 'assertIndexExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + assertInternalType('string', $args['indexName']); + assertContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); + break; + case 'assertIndexNotExists': + assertInternalType('string', $args['databaseName']); + assertInternalType('string', $args['collectionName']); + assertInternalType('string', $args['indexName']); + assertNotContains($args['indexName'], $this->getIndexNames($args['databaseName'], $args['collectionName'])); + break; + case 'assertSameLsidOnLastTwoCommands': + $eventObserver = $this->context->getEventObserverForClient($this->arguments['client']); + assertEquals(...$eventObserver->getLsidsOnLastTwoCommands()); + break; + case 'assertDifferentLsidOnLastTwoCommands': + $eventObserver = $this->context->getEventObserverForClient($this->arguments['client']); + assertNotEquals(...$eventObserver->getLsidsOnLastTwoCommands()); + break; + case 'assertSessionDirty': + assertTrue($this->context->isDirtySession($this->arguments['session'])); + break; + case 'assertSessionNotDirty': + assertFalse($this->context->isDirtySession($this->arguments['session'])); + break; + case 'assertSessionPinned': + assertInstanceOf(Session::class, $args['session']); + assertInstanceOf(Server::class, $args['session']->getServer()); + break; + case 'assertSessionTransactionState': + assertInstanceOf(Session::class, $args['session']); + assertSame($this->arguments['state'], $args['session']->getTransactionState()); + break; + case 'assertSessionUnpinned': + assertInstanceOf(Session::class, $args['session']); + assertNull($args['session']->getServer()); + break; + case 'failPoint': + assertInstanceOf(stdClass::class, $args['failPoint']); + $args['client']->selectDatabase('admin')->command($args['failPoint']); + break; + case 'targetedFailPoint': + assertInstanceOf(Session::class, $args['session']); + assertInstanceOf(stdClass::class, $args['failPoint']); + assertNotNull($args['session']->getServer(), 'Session is pinned'); + $operation = new DatabaseCommand('admin', $args['failPoint']); + $operation->execute($args['session']->getServer()); + break; + default: + Assert::fail('Unsupported test runner operation: ' . $this->name); + } + } + + private function getIndexNames(string $databaseName, string $collectionName) : array + { + return array_map( + function (IndexInfo $indexInfo) { + return $indexInfo->getName(); + }, + iterator_to_array($this->context->getInternalClient()->selectCollection($databaseName, $collectionName)->listIndexes()) + ); + } + + private function prepareArguments() : array + { + $args = $this->arguments; + + if (array_key_exists('session', $args)) { + assertInternalType('string', $args['session']); + $args['session'] = $this->entityMap->getSession($args['session']); + } + + // Prepare readConcern, readPreference, and writeConcern + return Util::prepareCommonOptions($args); + } + + private static function prepareBulkWriteRequest(stdClass $request) : array + { + $request = (array) $request; + assertCount(1, $request); + + $type = key($request); + $args = current($request); + assertInternalType('object', $args); + $args = (array) $args; + + switch ($type) { + case 'deleteMany': + case 'deleteOne': + return [ + $type => [ + $args['filter'], + array_diff_key($args, ['filter' => 1]), + ], + ]; + case 'insertOne': + return [ 'insertOne' => [ $args['document']]]; + case 'replaceOne': + return [ + 'replaceOne' => [ + $args['filter'], + $args['replacement'], + array_diff_key($args, ['filter' => 1, 'replacement' => 1]), + ], + ]; + case 'updateMany': + case 'updateOne': + return [ + $type => [ + $args['filter'], + $args['update'], + array_diff_key($args, ['filter' => 1, 'update' => 1]), + ], + ]; + default: + Assert::fail('Unsupported bulk write request: ' . $type); + } + } + + private static function prepareUploadArguments(array $args) : array + { + $source = $args['source'] ?? null; + assertInternalType('object', $source); + assertObjectHasAttribute('$$hexBytes', $source); + Util::assertHasOnlyKeys($source, ['$$hexBytes']); + $hexBytes = $source->{'$$hexBytes'}; + assertInternalType('string', $hexBytes); + assertRegExp('/^([0-9a-fA-F]{2})*$/', $hexBytes); + + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, hex2bin($hexBytes)); + rewind($stream); + + $args['source'] = $stream; + + return $args; + } +} diff --git a/tests/UnifiedSpecTests/RunOnRequirement.php b/tests/UnifiedSpecTests/RunOnRequirement.php new file mode 100644 index 000000000..06c435fb0 --- /dev/null +++ b/tests/UnifiedSpecTests/RunOnRequirement.php @@ -0,0 +1,77 @@ +minServerVersion)) { + assertInternalType('string', $o->minServerVersion); + assertRegExp(self::VERSION_PATTERN, $o->minServerVersion); + $this->minServerVersion = $o->minServerVersion; + } + + if (isset($o->maxServerVersion)) { + assertInternalType('string', $o->maxServerVersion); + assertRegExp(self::VERSION_PATTERN, $o->maxServerVersion); + $this->maxServerVersion = $o->maxServerVersion; + } + + if (isset($o->topologies)) { + assertInternalType('array', $o->topologies); + assertContainsOnly('string', $o->topologies); + $this->topologies = $o->topologies; + } + } + + public function isSatisfied(string $serverVersion, string $topology) : bool + { + if (isset($this->minServerVersion) && version_compare($serverVersion, $this->minServerVersion, '<')) { + return false; + } + + if (isset($this->maxServerVersion) && version_compare($serverVersion, $this->maxServerVersion, '>')) { + return false; + } + + if (isset($this->topologies)) { + if (in_array($topology, $this->topologies)) { + return true; + } + + /* Ensure "sharded-replicaset" is also accepted for topologies that + * only include "sharded" (agnostic about the shard topology) */ + if ($topology === self::TOPOLOGY_SHARDED_REPLICASET && in_array(self::TOPOLOGY_SHARDED, $this->topologies)) { + return true; + } + + return false; + } + + return true; + } +} diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php new file mode 100644 index 000000000..e8cbeeb2f --- /dev/null +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -0,0 +1,389 @@ +failPointObserver = new FailPointObserver(); + $this->failPointObserver->start(); + } + + private function doTearDown() + { + if ($this->hasFailed()) { + self::killAllSessions(); + } + + $this->failPointObserver->stop(); + $this->failPointObserver->disableFailPoints(); + + /* Manually invoking garbage collection since each test is prone to + * create cycles (perhaps due to EntityMap), which can leak and prevent + * sessions from being released back into the pool. */ + gc_collect_cycles(); + + parent::tearDown(); + } + + /** + * @dataProvider providePassingTests + */ + public function testPassingTests(stdClass $test, string $schemaVersion, array $runOnRequirements = null, array $createEntities = null, array $initialData = null) + { + if (! $this->isSchemaVersionSupported($schemaVersion)) { + $this->markTestIncomplete(sprintf('Test format schema version "%s" is not supported', $schemaVersion)); + } + + if (isset($runOnRequirements)) { + $this->checkRunOnRequirements($runOnRequirements); + } + + if (isset($test->skipReason)) { + $this->assertInternalType('string', $test->skipReason); + $this->markTestSkipped($test->skipReason); + } + + if (isset($test->runOnRequirements)) { + $this->assertInternalType('array', $test->runOnRequirements); + $this->checkRunOnRequirements($test->runOnRequirements); + } + + if (isset($initialData)) { + $this->prepareInitialData($initialData); + } + + // Give Context unmodified URI so it can enforce useMultipleMongoses + $context = new Context(self::$internalClient, static::getUri(true)); + + if (isset($createEntities)) { + $context->createEntities($createEntities); + } + + $this->assertInternalType('array', $test->operations); + $this->preventStaleDbVersionError($test->operations, $context); + + $context->startEventObservers(); + + foreach ($test->operations as $o) { + $operation = new Operation($o, $context); + $operation->assert(); + } + + $context->stopEventObservers(); + + if (isset($test->expectEvents)) { + $this->assertInternalType('array', $test->expectEvents); + $context->assertExpectedEventsForClients($test->expectEvents); + } + + if (isset($test->outcome)) { + $this->assertInternalType('array', $test->outcome); + $this->assertOutcome($test->outcome); + } + } + + public function providePassingTests() + { + return $this->provideTests(__DIR__ . '/valid-pass'); + } + + /** + * @dataProvider provideFailingTests + */ + public function testFailingTests(...$args) + { + // Cannot use expectException(), as it ignores PHPUnit Exceptions + $failed = false; + + try { + $this->testCase(...$args); + } catch (Throwable $e) { + $failed = true; + } + + assertTrue($failed, 'Expected test to throw an exception'); + } + + public function provideFailingTests() + { + return $this->provideTests(__DIR__ . '/valid-fail'); + } + + private function provideTests(string $dir) + { + $testArgs = []; + + foreach (glob($dir . '/*.json') as $filename) { + /* Decode the file through the driver's extended JSON parser to + * ensure proper handling of special types. */ + $json = toPHP(fromJSON(file_get_contents($filename))); + + $description = $json->description; + $schemaVersion = $json->schemaVersion; + $runOnRequirements = $json->runOnRequirements ?? null; + $createEntities = $json->createEntities ?? null; + $initialData = $json->initialData ?? null; + $tests = $json->tests; + + /* Assertions in data providers do not count towards test assertions + * but failures will interrupt the test suite with a warning. */ + $message = 'Invalid test file: ' . $filename; + $this->assertInternalType('string', $description, $message); + $this->assertInternalType('string', $schemaVersion, $message); + $this->assertInternalType('array', $tests, $message); + + foreach ($json->tests as $test) { + $this->assertInternalType('object', $test, $message); + $this->assertInternalType('string', $test->description, $message); + + $name = $description . ': ' . $test->description; + $testArgs[$name] = [$test, $schemaVersion, $runOnRequirements, $createEntities, $initialData]; + } + } + + return $testArgs; + } + + /** + * Checks server version and topology requirements. + * + * @param array $runOnRequirements + * @throws SkippedTest unless one or more runOnRequirements are met + */ + private function checkRunOnRequirements(array $runOnRequirements) + { + $this->assertNotEmpty($runOnRequirements); + $this->assertContainsOnly('object', $runOnRequirements); + + $serverVersion = $this->getCachedServerVersion(); + $topology = $this->getCachedTopology(); + + foreach ($runOnRequirements as $o) { + $runOnRequirement = new RunOnRequirement($o); + if ($runOnRequirement->isSatisfied($serverVersion, $topology)) { + return; + } + } + + $this->markTestSkipped(sprintf('Server version "%s" and topology "%s" do not meet test requirements', $serverVersion, $topology)); + } + + /** + * Return the server version (cached for subsequent calls). + * + * @return string + */ + private function getCachedServerVersion() + { + static $cachedServerVersion; + + if (isset($cachedServerVersion)) { + return $cachedServerVersion; + } + + $cachedServerVersion = $this->getServerVersion(); + + return $cachedServerVersion; + } + + /** + * Return the topology type (cached for subsequent calls). + * + * @return string + * @throws UnexpectedValueException if topology is neither single nor RS nor sharded + */ + private function getCachedTopology() + { + static $cachedTopology = null; + + if (isset($cachedTopology)) { + return $cachedTopology; + } + + switch ($this->getPrimaryServer()->getType()) { + case Server::TYPE_STANDALONE: + $cachedTopology = RunOnRequirement::TOPOLOGY_SINGLE; + break; + + case Server::TYPE_RS_PRIMARY: + $cachedTopology = RunOnRequirement::TOPOLOGY_REPLICASET; + break; + + case Server::TYPE_MONGOS: + $cachedTopology = $this->isShardedClusterUsingReplicasets() + ? RunOnRequirement::TOPOLOGY_SHARDED_REPLICASET + : RunOnRequirement::TOPOLOGY_SHARDED; + break; + + default: + throw new UnexpectedValueException('Toplogy is neither single nor RS nor sharded'); + } + + return $cachedTopology; + } + + /** + * Checks is a test format schema version is supported. + * + * @param string $schemaVersion + * @return boolean + */ + private function isSchemaVersionSupported($schemaVersion) + { + return version_compare($schemaVersion, self::MIN_SCHEMA_VERSION, '>=') && version_compare($schemaVersion, self::MAX_SCHEMA_VERSION, '<'); + } + + /** + * Kill all sessions on the cluster. + * + * This will clean up any open transactions that may remain from a + * previously failed test. For sharded clusters, this command will be run + * on all mongos nodes. + */ + private static function killAllSessions() + { + $manager = self::$internalClient->getManager(); + $primary = $manager->selectServer(new ReadPreference(ReadPreference::PRIMARY)); + $servers = $primary->getType() === Server::TYPE_MONGOS ? $manager->getServers() : [$primary]; + + foreach ($servers as $server) { + try { + // Skip servers that do not support sessions + if (! isset($server->getInfo()['logicalSessionTimeoutMinutes'])) { + continue; + } + + $command = new DatabaseCommand('admin', ['killAllSessions' => []]); + $command->execute($server); + } catch (ServerException $e) { + // Interrupted error is safe to ignore (see: SERVER-38335) + if ($e->getCode() != self::SERVER_ERROR_INTERRUPTED) { + throw $e; + } + } + } + } + + private function assertOutcome(array $outcome) + { + $this->assertNotEmpty($outcome); + $this->assertContainsOnly('object', $outcome); + + foreach ($outcome as $data) { + $collectionData = new CollectionData($data); + $collectionData->assertOutcome(self::$internalClient); + } + } + + private function prepareInitialData(array $initialData) + { + $this->assertNotEmpty($initialData); + $this->assertContainsOnly('object', $initialData); + + foreach ($initialData as $data) { + $collectionData = new CollectionData($data); + $collectionData->prepareInitialData(self::$internalClient); + } + } + + /** + * Work around potential error executing distinct on sharded clusters. + * + * @see https://github.com/mongodb/specifications/tree/master/source/transactions/tests#why-do-tests-that-run-distinct-sometimes-fail-with-staledbversionts. + */ + private function preventStaleDbVersionError(array $operations, Context $context) + { + if (! $this->isShardedCluster()) { + return; + } + + $hasStartTransaction = false; + $hasDistinct = false; + $collection = null; + + foreach ($operations as $operation) { + switch ($operation->name) { + case 'distinct': + $hasDistinct = true; + $collection = $context->getEntityMap()[$operation->object]; + break; + + case 'startTransaction': + $hasStartTransaction = true; + break; + + default: + continue 2; + } + + if ($hasStartTransaction && $hasDistinct) { + $this->assertInstanceOf(Collection::class, $collection); + $collection->distinct('foo'); + + return; + } + } + } +} diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php new file mode 100644 index 000000000..6e40b3d53 --- /dev/null +++ b/tests/UnifiedSpecTests/Util.php @@ -0,0 +1,112 @@ +level ?? null; + assertInternalType('string', $level); + + return new ReadConcern($level); + } + + public static function createReadPreference(stdClass $o) : ReadPreference + { + self::assertHasOnlyKeys($o, ['mode', 'tagSets', 'maxStalenessSeconds', 'hedge']); + + $mode = $o->mode ?? null; + $tagSets = $o->tagSets ?? null; + $maxStalenessSeconds = $o->maxStalenessSeconds ?? null; + $hedge = $o->hedge ?? null; + + assertInternalType('string', $mode); + + if (isset($tagSets)) { + assertInternalType('array', $tagSets); + assertContains('object', $tagSets); + } + + $options = []; + + if (isset($maxStalenessSeconds)) { + assertInternalType('int', $maxStalenessSeconds); + $options['maxStalenessSeconds'] = $maxStalenessSeconds; + } + + if (isset($hedge)) { + assertInternalType('object', $hedge); + $options['hedge'] = $hedge; + } + + return new ReadPreference($mode, $tagSets, $options); + } + + public static function createWriteConcern(stdClass $o) : WriteConcern + { + self::assertHasOnlyKeys($o, ['w', 'wtimeoutMS', 'journal']); + + $w = $o->w ?? -2; /* MONGOC_WRITE_CONCERN_W_DEFAULT */ + $wtimeoutMS = $o->wtimeoutMS ?? 0; + $journal = $o->journal ?? null; + + assertThat($w, logicalOr(isType('int'), isType('string'))); + assertInternalType('int', $wtimeoutMS); + + $args = [$w, $wtimeoutMS]; + + if (isset($journal)) { + assertInternalType('bool', $journal); + $args[] = $journal; + } + + return new WriteConcern(...$args); + } + + public static function prepareCommonOptions(array $options) : array + { + if (array_key_exists('readConcern', $options)) { + assertInternalType('object', $options['readConcern']); + $options['readConcern'] = self::createReadConcern($options['readConcern']); + } + + if (array_key_exists('readPreference', $options)) { + assertInternalType('object', $options['readPreference']); + $options['readPreference'] = self::createReadPreference($options['readPreference']); + } + + if (array_key_exists('writeConcern', $options)) { + assertInternalType('object', $options['writeConcern']); + $options['writeConcern'] = self::createWriteConcern($options['writeConcern']); + } + + return $options; + } +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json new file mode 100644 index 000000000..7f7f1978c --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-bucket-database-undefined.json @@ -0,0 +1,18 @@ +{ + "description": "entity-bucket-database-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "bucket": { + "id": "bucket0", + "database": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json new file mode 100644 index 000000000..20b0733e3 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-collection-database-undefined.json @@ -0,0 +1,19 @@ +{ + "description": "entity-collection-database-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "collection": { + "id": "collection0", + "database": "foo", + "collectionName": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json new file mode 100644 index 000000000..0f8110e6d --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-database-client-undefined.json @@ -0,0 +1,19 @@ +{ + "description": "entity-database-client-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "database": { + "id": "database0", + "client": "foo", + "databaseName": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json b/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json new file mode 100644 index 000000000..260356436 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/entity-session-client-undefined.json @@ -0,0 +1,18 @@ +{ + "description": "entity-session-client-undefined", + "schemaVersion": "1.0", + "createEntities": [ + { + "session": { + "id": "session0", + "client": "foo" + } + } + ], + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json new file mode 100644 index 000000000..ea425fb56 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/returnDocument-enum-invalid.json @@ -0,0 +1,66 @@ +{ + "description": "returnDocument-enum-invalid", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0" + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "test" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + } + ], + "tests": [ + { + "description": "FindOneAndReplace returnDocument invalid enum value", + "operations": [ + { + "name": "findOneAndReplace", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "_id": 1, + "x": 111 + }, + "returnDocument": "invalid" + } + } + ] + }, + { + "description": "FindOneAndUpdate returnDocument invalid enum value", + "operations": [ + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "invalid" + } + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json b/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json new file mode 100644 index 000000000..ceb553291 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-fail/schemaVersion-unsupported.json @@ -0,0 +1,10 @@ +{ + "description": "schemaVersion-unsupported", + "schemaVersion": "0.1", + "tests": [ + { + "description": "foo", + "operations": [] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json b/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json new file mode 100644 index 000000000..dc6e332e3 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-change-streams.json @@ -0,0 +1,410 @@ +{ + "description": "poc-change-streams", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ], + "ignoreCommandMonitoringEvents": [ + "getMore", + "killCursors" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "change-stream-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "client": { + "id": "client1", + "useMultipleMongoses": false + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "change-stream-tests" + } + }, + { + "database": { + "id": "database2", + "client": "client1", + "databaseName": "change-stream-tests-2" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "test" + } + }, + { + "collection": { + "id": "collection2", + "database": "database1", + "collectionName": "test2" + } + }, + { + "collection": { + "id": "collection3", + "database": "database2", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "change-stream-tests", + "documents": [] + }, + { + "collectionName": "test2", + "databaseName": "change-stream-tests", + "documents": [] + }, + { + "collectionName": "test", + "databaseName": "change-stream-tests-2", + "documents": [] + } + ], + "tests": [ + { + "description": "Executing a watch helper on a MongoClient results in notifications for changes to all collections in all databases in the cluster.", + "runOnRequirements": [ + { + "minServerVersion": "3.8.0", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "createChangeStream", + "object": "client0", + "saveResultAsEntity": "changeStream0" + }, + { + "name": "insertOne", + "object": "collection2", + "arguments": { + "document": { + "x": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection3", + "arguments": { + "document": { + "y": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "z": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test2" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests-2", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "y": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "z": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": 1, + "cursor": {}, + "pipeline": [ + { + "$changeStream": { + "allChangesForCluster": true, + "fullDocument": { + "$$unsetOrMatches": "default" + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "admin" + } + } + ] + } + ] + }, + { + "description": "Test consecutive resume", + "runOnRequirements": [ + { + "minServerVersion": "4.1.7", + "topologies": [ + "replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "getMore" + ], + "closeConnection": true + } + } + } + }, + { + "name": "createChangeStream", + "object": "collection0", + "arguments": { + "batchSize": 1 + }, + "saveResultAsEntity": "changeStream0" + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 1 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 2 + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "x": 3 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 1 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 2 + } + } + }, + { + "name": "iterateUntilDocumentOrError", + "object": "changeStream0", + "expectResult": { + "operationType": "insert", + "ns": { + "db": "change-stream-tests", + "coll": "test" + }, + "fullDocument": { + "_id": { + "$$type": "objectId" + }, + "x": 3 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + }, + "resumeAfter": { + "$$exists": true + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "test", + "cursor": { + "batchSize": 1 + }, + "pipeline": [ + { + "$changeStream": { + "fullDocument": { + "$$unsetOrMatches": "default" + }, + "resumeAfter": { + "$$exists": true + } + } + } + ] + }, + "commandName": "aggregate", + "databaseName": "change-stream-tests" + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json b/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json new file mode 100644 index 000000000..499396e0b --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-command-monitoring.json @@ -0,0 +1,222 @@ +{ + "description": "poc-command-monitoring", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent", + "commandSucceededEvent", + "commandFailedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "command-monitoring-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "command-monitoring-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "tests": [ + { + "description": "A successful find event with a getmore and the server kills the cursor", + "runOnRequirements": [ + { + "minServerVersion": "3.1", + "topologies": [ + "single", + "replicaset" + ] + } + ], + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": { + "$gte": 1 + } + }, + "sort": { + "_id": 1 + }, + "batchSize": 3, + "limit": 4 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": { + "$gte": 1 + } + }, + "sort": { + "_id": 1 + }, + "batchSize": 3, + "limit": 4 + }, + "commandName": "find", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandSucceededEvent": { + "reply": { + "ok": 1, + "cursor": { + "id": { + "$$type": [ + "int", + "long" + ] + }, + "ns": "command-monitoring-tests.test", + "firstBatch": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + }, + "commandName": "find" + } + }, + { + "commandStartedEvent": { + "command": { + "getMore": { + "$$type": [ + "int", + "long" + ] + }, + "collection": "test", + "batchSize": 1 + }, + "commandName": "getMore", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandSucceededEvent": { + "reply": { + "ok": 1, + "cursor": { + "id": 0, + "ns": "command-monitoring-tests.test", + "nextBatch": [ + { + "_id": 4, + "x": 44 + } + ] + } + }, + "commandName": "getMore" + } + } + ] + } + ] + }, + { + "description": "A failed find event", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "$or": true + } + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "$or": true + } + }, + "commandName": "find", + "databaseName": "command-monitoring-tests" + } + }, + { + "commandFailedEvent": { + "commandName": "find" + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-crud.json b/tests/UnifiedSpecTests/valid-pass/poc-crud.json new file mode 100644 index 000000000..2ed86d615 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-crud.json @@ -0,0 +1,446 @@ +{ + "description": "poc-crud", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "crud-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client0", + "databaseName": "admin" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + }, + { + "collection": { + "id": "collection1", + "database": "database0", + "collectionName": "coll1" + } + }, + { + "collection": { + "id": "collection2", + "database": "database0", + "collectionName": "coll2", + "collectionOptions": { + "readConcern": { + "level": "majority" + } + } + } + } + ], + "initialData": [ + { + "collectionName": "coll0", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + }, + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + } + ] + }, + { + "collectionName": "coll2", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + }, + { + "collectionName": "aggregate_out", + "databaseName": "crud-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "BulkWrite with mixed ordered operations", + "operations": [ + { + "name": "bulkWrite", + "object": "collection0", + "arguments": { + "requests": [ + { + "insertOne": { + "document": { + "_id": 3, + "x": 33 + } + } + }, + { + "updateOne": { + "filter": { + "_id": 2 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "updateMany": { + "filter": { + "_id": { + "$gt": 1 + } + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "insertOne": { + "document": { + "_id": 4, + "x": 44 + } + } + }, + { + "deleteMany": { + "filter": { + "x": { + "$nin": [ + 24, + 34 + ] + } + } + } + }, + { + "replaceOne": { + "filter": { + "_id": 4 + }, + "replacement": { + "_id": 4, + "x": 44 + }, + "upsert": true + } + } + ], + "ordered": true + }, + "expectResult": { + "deletedCount": 2, + "insertedCount": 2, + "insertedIds": { + "$$unsetOrMatches": { + "0": 3, + "3": 4 + } + }, + "matchedCount": 3, + "modifiedCount": 3, + "upsertedCount": 1, + "upsertedIds": { + "5": 4 + } + } + } + ], + "outcome": [ + { + "collectionName": "coll0", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 2, + "x": 24 + }, + { + "_id": 3, + "x": 34 + }, + { + "_id": 4, + "x": 44 + } + ] + } + ] + }, + { + "description": "InsertMany continue-on-error behavior with unordered (duplicate key in requests)", + "operations": [ + { + "name": "insertMany", + "object": "collection1", + "arguments": { + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ], + "ordered": false + }, + "expectError": { + "expectResult": { + "deletedCount": 0, + "insertedCount": 2, + "matchedCount": 0, + "modifiedCount": 0, + "upsertedCount": 0, + "upsertedIds": {} + } + } + } + ], + "outcome": [ + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + }, + { + "description": "ReplaceOne prohibits atomic modifiers", + "operations": [ + { + "name": "replaceOne", + "object": "collection1", + "arguments": { + "filter": { + "_id": 1 + }, + "replacement": { + "$set": { + "x": 22 + } + } + }, + "expectError": { + "isClientError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [] + } + ], + "outcome": [ + { + "collectionName": "coll1", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 1, + "x": 11 + } + ] + } + ] + }, + { + "description": "readConcern majority with out stage", + "runOnRequirements": [ + { + "minServerVersion": "4.1.0", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "aggregate", + "object": "collection2", + "arguments": { + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "aggregate_out" + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "coll2", + "pipeline": [ + { + "$sort": { + "x": 1 + } + }, + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$out": "aggregate_out" + } + ], + "readConcern": { + "level": "majority" + } + }, + "commandName": "aggregate", + "databaseName": "crud-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "aggregate_out", + "databaseName": "crud-tests", + "documents": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + }, + { + "description": "Aggregate with $listLocalSessions", + "runOnRequirements": [ + { + "minServerVersion": "3.6.0" + } + ], + "operations": [ + { + "name": "aggregate", + "object": "database1", + "arguments": { + "pipeline": [ + { + "$listLocalSessions": {} + }, + { + "$limit": 1 + }, + { + "$addFields": { + "dummy": "dummy field" + } + }, + { + "$project": { + "_id": 0, + "dummy": 1 + } + } + ] + }, + "expectResult": [ + { + "dummy": "dummy field" + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json b/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json new file mode 100644 index 000000000..c04ed89a7 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-gridfs.json @@ -0,0 +1,299 @@ +{ + "description": "poc-gridfs", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0" + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "gridfs-tests" + } + }, + { + "bucket": { + "id": "bucket0", + "database": "database0" + } + }, + { + "collection": { + "id": "bucket0_files_collection", + "database": "database0", + "collectionName": "fs.files" + } + }, + { + "collection": { + "id": "bucket0_chunks_collection", + "database": "database0", + "collectionName": "fs.chunks" + } + } + ], + "initialData": [ + { + "collectionName": "fs.files", + "databaseName": "gridfs-tests", + "documents": [ + { + "_id": { + "$oid": "000000000000000000000005" + }, + "length": 10, + "chunkSize": 4, + "uploadDate": { + "$date": "1970-01-01T00:00:00.000Z" + }, + "md5": "57d83cd477bfb1ccd975ab33d827a92b", + "filename": "length-10", + "contentType": "application/octet-stream", + "aliases": [], + "metadata": {} + } + ] + }, + { + "collectionName": "fs.chunks", + "databaseName": "gridfs-tests", + "documents": [ + { + "_id": { + "$oid": "000000000000000000000005" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 0, + "data": { + "$binary": { + "base64": "ESIzRA==", + "subType": "00" + } + } + }, + { + "_id": { + "$oid": "000000000000000000000006" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 1, + "data": { + "$binary": { + "base64": "VWZ3iA==", + "subType": "00" + } + } + }, + { + "_id": { + "$oid": "000000000000000000000007" + }, + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 2, + "data": { + "$binary": { + "base64": "mao=", + "subType": "00" + } + } + } + ] + } + ], + "tests": [ + { + "description": "Delete when length is 10", + "operations": [ + { + "name": "delete", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + } + } + ], + "outcome": [ + { + "collectionName": "fs.files", + "databaseName": "gridfs-tests", + "documents": [] + }, + { + "collectionName": "fs.chunks", + "databaseName": "gridfs-tests", + "documents": [] + } + ] + }, + { + "description": "Download when there are three chunks", + "operations": [ + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + }, + "expectResult": { + "$$matchesHexBytes": "112233445566778899aa" + } + } + ] + }, + { + "description": "Download when files entry does not exist", + "operations": [ + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000000" + } + }, + "expectError": { + "isError": true + } + } + ] + }, + { + "description": "Download when an intermediate chunk is missing", + "operations": [ + { + "name": "deleteOne", + "object": "bucket0_chunks_collection", + "arguments": { + "filter": { + "files_id": { + "$oid": "000000000000000000000005" + }, + "n": 1 + } + }, + "expectResult": { + "deletedCount": 1 + } + }, + { + "name": "download", + "object": "bucket0", + "arguments": { + "id": { + "$oid": "000000000000000000000005" + } + }, + "expectError": { + "isError": true + } + } + ] + }, + { + "description": "Upload when length is 5", + "operations": [ + { + "name": "upload", + "object": "bucket0", + "arguments": { + "filename": "filename", + "source": { + "$$hexBytes": "1122334455" + }, + "chunkSizeBytes": 4 + }, + "expectResult": { + "$$type": "objectId" + }, + "saveResultAsEntity": "oid0" + }, + { + "name": "find", + "object": "bucket0_files_collection", + "arguments": { + "filter": {}, + "sort": { + "uploadDate": -1 + }, + "limit": 1 + }, + "expectResult": [ + { + "_id": { + "$$matchesEntity": "oid0" + }, + "length": 5, + "chunkSize": 4, + "uploadDate": { + "$$type": "date" + }, + "md5": "283d4fea5dded59cf837d3047328f5af", + "filename": "filename" + } + ] + }, + { + "name": "find", + "object": "bucket0_chunks_collection", + "arguments": { + "filter": { + "_id": { + "$gt": { + "$oid": "000000000000000000000007" + } + } + }, + "sort": { + "n": 1 + } + }, + "expectResult": [ + { + "_id": { + "$$type": "objectId" + }, + "files_id": { + "$$matchesEntity": "oid0" + }, + "n": 0, + "data": { + "$binary": { + "base64": "ESIzRA==", + "subType": "00" + } + } + }, + { + "_id": { + "$$type": "objectId" + }, + "files_id": { + "$$matchesEntity": "oid0" + }, + "n": 1, + "data": { + "$binary": { + "base64": "VQ==", + "subType": "00" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json b/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json new file mode 100644 index 000000000..2b65d501a --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-retryable-reads.json @@ -0,0 +1,433 @@ +{ + "description": "poc-retryable-reads", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "single", + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "retryReads": false + }, + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "retryable-reads-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "retryable-reads-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll" + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "retryable-reads-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ], + "tests": [ + { + "description": "Aggregate succeeds after InterruptedAtShutdown", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "aggregate" + ], + "errorCode": 11600 + } + } + } + }, + { + "name": "aggregate", + "object": "collection0", + "arguments": { + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "expectResult": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "coll", + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "aggregate": "coll", + "pipeline": [ + { + "$match": { + "_id": { + "$gt": 1 + } + } + }, + { + "$sort": { + "x": 1 + } + } + ] + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find succeeds on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "expectResult": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {}, + "sort": { + "_id": 1 + }, + "limit": 2 + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find fails on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection1", + "arguments": { + "filter": {} + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "Find fails on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": {} + }, + "expectError": { + "isError": true + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "coll", + "filter": {} + }, + "databaseName": "retryable-reads-tests" + } + } + ] + } + ] + }, + { + "description": "ListDatabases succeeds on second attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "listDatabases" + ], + "closeConnection": true + } + } + } + }, + { + "name": "listDatabases", + "object": "client0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "listDatabases": 1 + } + } + }, + { + "commandStartedEvent": { + "command": { + "listDatabases": 1 + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json b/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json new file mode 100644 index 000000000..e64ce1bce --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-retryable-writes.json @@ -0,0 +1,481 @@ +{ + "description": "poc-retryable-writes", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "3.6", + "topologies": [ + "replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "retryWrites": false + }, + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "retryable-writes-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "retryable-writes-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "coll" + } + } + ], + "initialData": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ], + "tests": [ + { + "description": "FindOneAndUpdate is committed on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectResult": { + "_id": 1, + "x": 11 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "FindOneAndUpdate is not committed on first attempt", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 1 + }, + "data": { + "failBeforeCommitExceptionCode": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectResult": { + "_id": 1, + "x": 11 + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 12 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "FindOneAndUpdate is never committed", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "onPrimaryTransactionalWrite", + "mode": { + "times": 2 + }, + "data": { + "failBeforeCommitExceptionCode": 1 + } + } + } + }, + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + }, + "returnDocument": "Before" + }, + "expectError": { + "isError": true + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "InsertMany succeeds after PrimarySteppedDown", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 189, + "errorLabels": [ + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "insertMany", + "object": "collection0", + "arguments": { + "documents": [ + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ], + "ordered": true + }, + "expectResult": { + "insertedCount": 2, + "insertedIds": { + "$$unsetOrMatches": { + "0": 3, + "1": 4 + } + } + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + } + ] + } + ] + }, + { + "description": "InsertOne fails after connection failure when retryWrites option is false", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client1", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + }, + "expectError": { + "errorLabelsOmit": [ + "RetryableWriteError" + ] + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + } + ] + } + ] + }, + { + "description": "InsertOne fails after multiple retryable writeConcernErrors", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 2 + }, + "data": { + "failCommands": [ + "insert" + ], + "writeConcernError": { + "code": 91, + "errmsg": "Replication is being shut down" + } + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 3, + "x": 33 + } + }, + "expectError": { + "errorLabelsContain": [ + "RetryableWriteError" + ] + } + } + ], + "outcome": [ + { + "collectionName": "coll", + "databaseName": "retryable-writes-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-sessions.json b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json new file mode 100644 index 000000000..75f348942 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-sessions.json @@ -0,0 +1,466 @@ +{ + "description": "poc-sessions", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "3.6.0" + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "session-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ], + "tests": [ + { + "description": "Server supports explicit sessions", + "operations": [ + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "endSession", + "object": "session0" + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + } + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$sessionLsid": "session0" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + }, + { + "description": "Server supports implicit sessions", + "operations": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertSameLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$type": "object" + } + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$type": "object" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + }, + { + "description": "Dirty explicit session is discarded", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "assertSessionNotDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 2 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 2 + } + } + } + }, + { + "name": "assertSessionDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "assertSessionDirty", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "endSession", + "object": "session0" + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "_id": -1 + } + }, + "expectResult": [] + }, + { + "name": "assertDifferentLsidOnLastTwoCommands", + "object": "testRunner", + "arguments": { + "client": "client0" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 2 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 2 + }, + "commandName": "insert", + "databaseName": "session-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "find": "test", + "filter": { + "_id": -1 + }, + "lsid": { + "$$type": "object" + } + }, + "commandName": "find", + "databaseName": "session-tests" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "session-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json new file mode 100644 index 000000000..820ed6592 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions-convenient-api.json @@ -0,0 +1,505 @@ +{ + "description": "poc-transactions-convenient-api", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "client": { + "id": "client1", + "uriOptions": { + "readConcernLevel": "local", + "w": 1 + }, + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "database": { + "id": "database1", + "client": "client1", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + }, + { + "session": { + "id": "session1", + "client": "client1" + } + }, + { + "session": { + "id": "session2", + "client": "client0", + "sessionOptions": { + "defaultTransactionOptions": { + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "w": 1 + } + } + } + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "withTransaction and no transaction options set", + "operations": [ + { + "name": "withTransaction", + "object": "session0", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "$$exists": false + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "autocommit": false, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction inherits transaction options from client", + "operations": [ + { + "name": "withTransaction", + "object": "session1", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection1", + "arguments": { + "session": "session1", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client1", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session1" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "local" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session1" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction inherits transaction options from defaultTransactionOptions", + "operations": [ + { + "name": "withTransaction", + "object": "session2", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session2", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ] + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session2" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session2" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "withTransaction explicit transaction options", + "operations": [ + { + "name": "withTransaction", + "object": "session0", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 1 + } + } + } + } + ], + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "w": 1 + } + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 1 + } + ], + "ordered": true, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "readConcern": { + "level": "majority" + }, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "autocommit": false, + "writeConcern": { + "w": 1 + }, + "readConcern": { + "$$exists": false + }, + "startTransaction": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json new file mode 100644 index 000000000..a0b297d59 --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions-mongos-pin-auto.json @@ -0,0 +1,409 @@ +{ + "description": "poc-transactions-mongos-pin-auto", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": true, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ], + "tests": [ + { + "description": "remain pinned after non-transient Interrupted error on insertOne", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "targetedFailPoint", + "object": "testRunner", + "arguments": { + "session": "session0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "errorCode": 11601 + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 4 + } + }, + "expectError": { + "errorLabelsOmit": [ + "TransientTransactionError", + "UnknownTransactionCommitResult" + ], + "errorCodeName": "Interrupted" + } + }, + { + "name": "assertSessionPinned", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "commitTransaction", + "object": "session0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 4 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + }, + "recoveryToken": { + "$$type": "object" + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + }, + { + "_id": 3 + } + ] + } + ] + }, + { + "description": "unpin after transient error within a transaction", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 3 + } + }, + "expectResult": { + "$$unsetOrMatches": { + "insertedId": { + "$$unsetOrMatches": 3 + } + } + } + }, + { + "name": "targetedFailPoint", + "object": "testRunner", + "arguments": { + "session": "session0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "insert" + ], + "closeConnection": true + } + } + } + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": 4 + } + }, + "expectError": { + "errorLabelsContain": [ + "TransientTransactionError" + ], + "errorLabelsOmit": [ + "UnknownTransactionCommitResult" + ] + } + }, + { + "name": "assertSessionUnpinned", + "object": "testRunner", + "arguments": { + "session": "session0" + } + }, + { + "name": "abortTransaction", + "object": "session0" + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 3 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "insert": "test", + "documents": [ + { + "_id": 4 + } + ], + "ordered": true, + "readConcern": { + "$$exists": false + }, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "insert", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "abortTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + }, + "recoveryToken": { + "$$type": "object" + } + }, + "commandName": "abortTransaction", + "databaseName": "admin" + } + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + }, + { + "_id": 2 + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/valid-pass/poc-transactions.json b/tests/UnifiedSpecTests/valid-pass/poc-transactions.json new file mode 100644 index 000000000..62528f9ce --- /dev/null +++ b/tests/UnifiedSpecTests/valid-pass/poc-transactions.json @@ -0,0 +1,322 @@ +{ + "description": "poc-transactions", + "schemaVersion": "1.0", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded-replicaset" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "Client side error in command starting transaction", + "operations": [ + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "insertOne", + "object": "collection0", + "arguments": { + "session": "session0", + "document": { + "_id": { + ".": "." + } + } + }, + "expectError": { + "isClientError": true + } + }, + { + "name": "assertSessionTransactionState", + "object": "testRunner", + "arguments": { + "session": "session0", + "state": "starting" + } + } + ] + }, + { + "description": "explicitly create collection using create command", + "runOnRequirements": [ + { + "minServerVersion": "4.3.4", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "dropCollection", + "object": "database0", + "arguments": { + "collection": "test" + } + }, + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "createCollection", + "object": "database0", + "arguments": { + "session": "session0", + "collection": "test" + } + }, + { + "name": "assertCollectionNotExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test" + } + }, + { + "name": "commitTransaction", + "object": "session0" + }, + { + "name": "assertCollectionExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "drop": "test", + "writeConcern": { + "$$exists": false + } + }, + "commandName": "drop", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "create": "test", + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "create", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ] + }, + { + "description": "create index on a non-existing collection", + "runOnRequirements": [ + { + "minServerVersion": "4.3.4", + "topologies": [ + "replicaset", + "sharded-replicaset" + ] + } + ], + "operations": [ + { + "name": "dropCollection", + "object": "database0", + "arguments": { + "collection": "test" + } + }, + { + "name": "startTransaction", + "object": "session0" + }, + { + "name": "createIndex", + "object": "collection0", + "arguments": { + "session": "session0", + "name": "x_1", + "keys": { + "x": 1 + } + } + }, + { + "name": "assertIndexNotExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test", + "indexName": "x_1" + } + }, + { + "name": "commitTransaction", + "object": "session0" + }, + { + "name": "assertIndexExists", + "object": "testRunner", + "arguments": { + "databaseName": "transaction-tests", + "collectionName": "test", + "indexName": "x_1" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "drop": "test", + "writeConcern": { + "$$exists": false + } + }, + "commandName": "drop", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "createIndexes": "test", + "indexes": [ + { + "name": "x_1", + "key": { + "x": 1 + } + } + ], + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "createIndexes", + "databaseName": "transaction-tests" + } + }, + { + "commandStartedEvent": { + "command": { + "commitTransaction": 1, + "lsid": { + "$$sessionLsid": "session0" + }, + "txnNumber": 1, + "startTransaction": { + "$$exists": false + }, + "autocommit": false, + "writeConcern": { + "$$exists": false + } + }, + "commandName": "commitTransaction", + "databaseName": "admin" + } + } + ] + } + ] + } + ] +}