diff --git a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php index 158ede8f9..dfc5c6749 100644 --- a/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php +++ b/src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php @@ -102,12 +102,14 @@ public function getFeature(string $key): ?FeatureFlag { $json = $this->getJsonItem(self::FEATURES_NAMESPACE, $key); if ($json) { - $flag = FeatureFlag::decode($json); - if ($flag->isDeleted()) { + // Check the deleted marker before decoding. Some store writers + // persist tombstones that omit most of the schema, and the model + // decoder requires the full schema. + if (($json['deleted'] ?? false) === true) { $this->_logger->warning("FeatureRequester: Attempted to get deleted feature with key: " . $key); return null; } - return $flag; + return FeatureFlag::decode($json); } else { $this->_logger->warning("FeatureRequester: Attempted to get missing feature with key: " . $key); return null; @@ -124,12 +126,11 @@ public function getSegment(string $key): ?Segment { $json = $this->getJsonItem(self::SEGMENTS_NAMESPACE, $key); if ($json) { - $segment = Segment::decode($json); - if ($segment->isDeleted()) { + if (($json['deleted'] ?? false) === true) { $this->_logger->warning("FeatureRequester: Attempted to get deleted segment with key: " . $key); return null; } - return $segment; + return Segment::decode($json); } else { $this->_logger->warning("FeatureRequester: Attempted to get missing segment with key: " . $key); return null; @@ -146,10 +147,11 @@ public function getAllFeatures(): ?array $jsonList = $this->getJsonItemList(self::FEATURES_NAMESPACE); $itemsOut = []; foreach ($jsonList as $json) { - $flag = FeatureFlag::decode($json); - if (!$flag->isDeleted()) { - $itemsOut[$flag->getKey()] = $flag; + if (($json['deleted'] ?? false) === true) { + continue; } + $flag = FeatureFlag::decode($json); + $itemsOut[$flag->getKey()] = $flag; } return $itemsOut; } diff --git a/src/LaunchDarkly/Impl/Model/FeatureFlag.php b/src/LaunchDarkly/Impl/Model/FeatureFlag.php index 00c60b6aa..0e6969180 100644 --- a/src/LaunchDarkly/Impl/Model/FeatureFlag.php +++ b/src/LaunchDarkly/Impl/Model/FeatureFlag.php @@ -144,6 +144,9 @@ public function getDebugEventsUntilDate(): ?int return $this->_debugEventsUntilDate; } + /** + * @psalm-suppress PossiblyUnusedMethod + */ public function isDeleted(): bool { return $this->_deleted; diff --git a/src/LaunchDarkly/Impl/Model/Segment.php b/src/LaunchDarkly/Impl/Model/Segment.php index 688e1aacd..70756b419 100644 --- a/src/LaunchDarkly/Impl/Model/Segment.php +++ b/src/LaunchDarkly/Impl/Model/Segment.php @@ -86,6 +86,9 @@ public static function decode(array $v): Segment return static::getDecoder()($v); } + /** + * @psalm-suppress PossiblyUnusedMethod + */ public function isDeleted(): bool { return $this->_deleted; diff --git a/tests/Impl/Integrations/FeatureRequesterBaseTest.php b/tests/Impl/Integrations/FeatureRequesterBaseTest.php new file mode 100644 index 000000000..37a614c08 --- /dev/null +++ b/tests/Impl/Integrations/FeatureRequesterBaseTest.php @@ -0,0 +1,114 @@ +> */ + private array $data; + + /** + * @param array> $data map of namespace to (key to raw JSON string) + */ + public function __construct(array $data) + { + parent::__construct('', '', []); + $this->data = $data; + } + + protected function readItemString(string $namespace, string $key): ?string + { + return $this->data[$namespace][$key] ?? null; + } + + protected function readItemStringList(string $namespace): ?array + { + return array_values($this->data[$namespace] ?? []); + } +} + +class FeatureRequesterBaseTest extends TestCase +{ + private const FLAG_JSON = '{"key":"flagkey","version":1,"on":true,"prerequisites":[],"salt":"salty",' . + '"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,' . + '"variations":["fall","off"],"deleted":false,"trackEvents":false,"trackEventsFallthrough":false,' . + '"debugEventsUntilDate":null,"clientSide":false}'; + + private const SEGMENT_JSON = '{"key":"segkey","version":1,"included":[],"excluded":[],"rules":[],' . + '"salt":"salty","deleted":false}'; + + private const FULL_FLAG_TOMBSTONE_JSON = '{"key":"deletedkey","version":2,"on":false,"prerequisites":[],' . + '"salt":"","targets":[],"contextTargets":[],"rules":[],"fallthrough":{},"offVariation":null,' . + '"variations":[],"deleted":true,"trackEvents":false,"trackEventsFallthrough":false,' . + '"debugEventsUntilDate":null,"clientSide":false}'; + + private const MINIMAL_TOMBSTONE_JSON = '{"version":2,"deleted":true}'; + + private const KEYED_MINIMAL_TOMBSTONE_JSON = '{"key":"deletedkey","version":2,"deleted":true}'; + + public function testGetFeatureReturnsFlag(): void + { + $requester = new FakeStoreFeatureRequester(['features' => ['flagkey' => self::FLAG_JSON]]); + $flag = $requester->getFeature('flagkey'); + $this->assertNotNull($flag); + $this->assertEquals('flagkey', $flag->getKey()); + } + + public function testGetFeatureReturnsNullForFullSchemaTombstone(): void + { + $requester = new FakeStoreFeatureRequester( + ['features' => ['deletedkey' => self::FULL_FLAG_TOMBSTONE_JSON]] + ); + $this->assertNull($requester->getFeature('deletedkey')); + } + + public function testGetFeatureReturnsNullForMinimalTombstone(): void + { + $requester = new FakeStoreFeatureRequester( + ['features' => ['deletedkey' => self::MINIMAL_TOMBSTONE_JSON]] + ); + $this->assertNull($requester->getFeature('deletedkey')); + } + + public function testGetFeatureReturnsNullForKeyedMinimalTombstone(): void + { + $requester = new FakeStoreFeatureRequester( + ['features' => ['deletedkey' => self::KEYED_MINIMAL_TOMBSTONE_JSON]] + ); + $this->assertNull($requester->getFeature('deletedkey')); + } + + public function testGetSegmentReturnsSegment(): void + { + $requester = new FakeStoreFeatureRequester(['segments' => ['segkey' => self::SEGMENT_JSON]]); + $segment = $requester->getSegment('segkey'); + $this->assertNotNull($segment); + $this->assertEquals('segkey', $segment->getKey()); + } + + public function testGetSegmentReturnsNullForMinimalTombstone(): void + { + $requester = new FakeStoreFeatureRequester( + ['segments' => ['segkey' => self::MINIMAL_TOMBSTONE_JSON]] + ); + $this->assertNull($requester->getSegment('segkey')); + } + + public function testGetAllFeaturesSkipsTombstones(): void + { + $requester = new FakeStoreFeatureRequester(['features' => [ + 'flagkey' => self::FLAG_JSON, + 'deleted1' => self::FULL_FLAG_TOMBSTONE_JSON, + 'deleted2' => self::MINIMAL_TOMBSTONE_JSON, + 'deleted3' => self::KEYED_MINIMAL_TOMBSTONE_JSON, + ]]); + $flags = $requester->getAllFeatures(); + $this->assertNotNull($flags); + $this->assertEquals(['flagkey'], array_keys($flags)); + } +}