Compare commits

..

No commits in common. "b7b534fa5ab5738059ee03f6036f7edd36db6f66" and "141554a8541254e5006495ce15b0c5aabcf00eee" have entirely different histories.

15 changed files with 35 additions and 664 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@
composer.lock composer.lock
/.php-cs-fixer.cache /.php-cs-fixer.cache
/.phpstan/ /.phpstan/
.junie .junie.json

View file

View file

@ -1,59 +0,0 @@
# FlagPole Milestones
This document outlines the roadmap for enhancing FlagPole with advanced targeting, persistence, and observability features.
## Table of Contents
- [Milestone 1: Advanced Rule Engine](#milestone-1-advanced-rule-engine)
- [Milestone 2: Pluggable Targeting Keys](#milestone-2-pluggable-targeting-keys)
- [Milestone 3: Persistent Repositories](#milestone-3-persistent-repositories)
- [Milestone 4: Observability & Logging](#milestone-4-observability--logging)
- [Milestone 5: Documentation & Polish](#milestone-5-documentation--polish)
- [Milestone 6: Refactoring & DRY](#milestone-6-refactoring--dry)
- [Milestone 7: Validation & Safety](#milestone-7-validation--safety)
## Milestone 1: Advanced Rule Engine
Move beyond simple allow-lists to support complex attribute-based targeting.
- [x] Define `Rule` DTO/Interface (`src/Rule.php`).
- [x] Add `rules` collection to `Flag` DTO.
- [x] Implement rule evaluation logic in `Evaluator` (supporting operators like `eq`, `gt`, `lt`, `in`, `contains`).
- [x] Update `Evaluator::evaluate` precedence: `allowList` > `enabled` > `rules` > `rolloutPercentage`.
## Milestone 2: Pluggable Targeting Keys
Allow explicit control over which context attribute is used for rollout hashing.
- [x] Add optional `targetingKey` property to `Flag`.
- [x] Refactor `Evaluator::resolveTargetingKey` to honor `Flag->targetingKey` if present.
- [x] Modernize hashing implementation in `Evaluator::computeBucket` for PHP 8.2+.
## Milestone 3: Persistent Repositories
Provide out-of-the-box support for non-volatile flag storage.
- [x] Implement `JsonFileRepository` in `src/Repository/JsonFileRepository.php`.
- [x] Ensure robust JSON parsing and mapping to `Flag` objects.
## Milestone 4: Observability & Logging
Provide insights into why flags are being enabled or disabled.
- [x] Integrate PSR-3 `LoggerInterface` into `FeatureManager` and `Evaluator`.
- [x] Implement detailed logging for evaluation outcomes (e.g., which rule or strategy matched).
- [x] (Optional) Create `EvaluationResult` DTO for programmatic access to evaluation reasons.
## Milestone 5: Documentation & Polish
- [x] Update `README.md` with examples for new features.
- [x] Add comprehensive tests for Rule Engine and JSON Repository.
- [x] Verify zero regression for existing simple flag usage.
## Milestone 6: Refactoring & DRY
Centralize logic and remove duplication to improve maintainability.
- [x] Extract flag hydration logic into a dedicated `FlagHydrator` class to be reused across repositories.
- [x] Refactor `InMemoryFlagRepository` to use the new hydration logic.
- [x] Refactor `JsonFileRepository` to use the new hydration logic.
## Milestone 7: Validation & Safety
Enhance the engine to be more robust and developer-friendly.
- [x] Add validation to the rule engine to handle or log unknown operators instead of failing silently.
- [x] Implement configuration validation to ensure `Flag` and `Rule` objects are correctly formed before evaluation.
- [x] Optimize `JsonFileRepository` to avoid unnecessary parsing or consider lazy-loading if the config grows large.

View file

@ -4,8 +4,6 @@ Feature flag handling for PHP. Simple, framework-agnostic, and lightweight.
![CI](https://github.com/getphred/flagpole/actions/workflows/ci.yml/badge.svg) ![CI](https://github.com/getphred/flagpole/actions/workflows/ci.yml/badge.svg)
![Packagist](https://img.shields.io/packagist/v/getphred/flagpole.svg) ![Packagist](https://img.shields.io/packagist/v/getphred/flagpole.svg)
[![Total Downloads](https://img.shields.io/packagist/dt/getphred/flagpole.svg?style=flat-square)](https://packagist.org/packages/getphred/pairity)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md)
## Installation ## Installation
@ -29,9 +27,6 @@ $repo = InMemoryFlagRepository::fromArray([
'enabled' => null, // not a hard on/off 'enabled' => null, // not a hard on/off
'rolloutPercentage' => 25, // 25% gradual rollout 'rolloutPercentage' => 25, // 25% gradual rollout
'allowList' => ['user_1'], // always on for specific users 'allowList' => ['user_1'], // always on for specific users
'rules' => [ // attribute-based rules
['attribute' => 'plan', 'operator' => 'eq', 'value' => 'pro'],
],
], ],
'hard-off' => [ 'enabled' => false ], 'hard-off' => [ 'enabled' => false ],
'hard-on' => [ 'enabled' => true ], 'hard-on' => [ 'enabled' => true ],
@ -39,7 +34,7 @@ $repo = InMemoryFlagRepository::fromArray([
$flags = new FeatureManager($repo); $flags = new FeatureManager($repo);
$context = Context::fromArray(['userId' => 'user_42', 'plan' => 'pro']); $context = Context::fromArray(['userId' => 'user_42']);
if ($flags->isEnabled('new-dashboard', $context, false)) { if ($flags->isEnabled('new-dashboard', $context, false)) {
// show the new dashboard // show the new dashboard
@ -52,27 +47,31 @@ if ($flags->isEnabled('new-dashboard', $context, false)) {
- Flag: has a `name` and optional strategies: - Flag: has a `name` and optional strategies:
- `enabled`: explicit boolean on/off overrides everything. - `enabled`: explicit boolean on/off overrides everything.
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash of the flag name + user key.
- `allowList`: list of user keys that always get the flag enabled. - `allowList`: list of user keys that always get the flag enabled.
- `rules`: complex attribute targeting (e.g. `version > 2.0`, `plan == 'pro'`).
- `rolloutPercentage`: 0-100 gradual rollout based on a stable hash.
- Context: attributes about the subject (e.g. `userId`, `email`) used for evaluation. - Context: attributes about the subject (e.g. `userId`, `email`) used for evaluation.
- Repository: source of truth for flags. Provided: `InMemoryFlagRepository`, `JsonFileRepository`. - Repository: source of truth for flags. Provided: `InMemoryFlagRepository`. You can implement your own.
- Hydration: `FlagHydrator` centralizes flag creation and provides validation for targeting rules.
- Observability: Optional PSR-3 logging of evaluation results and reasons.
## Targeting key ## Targeting key
Evaluator looks for a stable key in the context in this order: `key`, `userId`, `id`, `email`. Evaluator looks for a stable key in the context in this order: `key`, `userId`, `id`, `email`.
You can also specify an explicit `targetingKey` per flag to use a specific attribute (e.g. `orgId`).
## Rollout hashing and boundary behavior
- Stable bucketing uses `crc32(flagName:key)` normalized to an unsigned 32-bit integer, then mapped to buckets 0..99.
- This guarantees consistent behavior across 32-bit and 64-bit platforms.
- Boundary rules:
- 0% rollout always evaluates to `false` when a targeting key is present.
- 100% rollout always evaluates to `true` when a targeting key is present.
- If no targeting key is present in the `Context`, percentage rollout falls back to the `default` you pass to `isEnabled()`.
## Precedence semantics ## Precedence semantics
When evaluating a flag, the following precedence applies: When evaluating a flag, the following precedence applies:
1. `allowList` — if the targeting key is in the allow-list, the flag is enabled. 1. `allowList` — if the targeting key is in the allow-list, the flag is enabled.
2. `enabled` — explicit on/off overrides everything below. 2. `enabled` — explicit on/off overrides percentage rollout and defaults.
3. `rules` — attribute-based targeting rules. 3. `rolloutPercentage` — uses stable bucketing over the targeting key.
4. `rolloutPercentage` — uses stable bucketing over the targeting key. 4. Fallback — returns the provided default when none of the above apply.
5. Fallback — returns the provided default when none of the above apply.
## Framework integration ## Framework integration

View file

@ -1,54 +0,0 @@
# FlagPole Specifications
This document describes the current technical specifications and architecture of the FlagPole feature flagging library.
## Core Concepts
### Flag
An immutable data object representing a feature flag and its evaluation strategies.
- **Properties**:
- `name` (string): Unique identifier.
- `enabled` (bool|null): Explicit override. If `true`/`false`, evaluation stops here.
- `rolloutPercentage` (int|null): 0-100 value for gradual rollout.
- `allowList` (list<string>): Keys that always receive `true`.
- `rules` (list<Rule>): Complex attribute-based targeting rules.
- `targetingKey` (string|null): Optional override for the attribute used in rollout/allowList.
### Context
A container for attributes (e.g., `userId`, `email`, `userGroup`) used during evaluation.
- **Targeting Keys**: By default, the evaluator looks for `key`, `userId`, `id`, or `email` (in that order), unless overridden by `Flag->targetingKey`.
### Evaluator
The engine that applies flag strategies against a context.
- **Precedence**:
1. `allowList`: If the targeting key is in the list, return `true`.
2. `enabled`: If non-null, return its boolean value.
3. `rules`: If any rule matches the context attributes, return `true`.
4. `rolloutPercentage`: Hash-based stable bucketing (0-99).
5. `fallback`: Returns the user-provided default.
### FeatureManager
The main entry point for the consumer.
- **Method**: `isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool`
- **Logging**: Supports PSR-3 logging of evaluation reasons.
## Technical Details
### Rollout Hashing
- Algorithm: `xxh3(flagName + ":" + targetingKey)`.
- Normalization: `hexdec(substr(hash, 0, 8)) % 100`.
- Bucketing: 0-99.
### Repositories
- `FlagRepositoryInterface`: Defines `get(string $name)` and `all()`.
- `InMemoryFlagRepository`: Provided for testing and simple setups.
- `JsonFileRepository`: Loads flags from a JSON file. Now supports lazy-loading hydration for better performance.
### FlagHydrator
A dedicated component responsible for transforming raw configuration arrays into validated `Flag` and `Rule` objects.
- **Validation**: Ensures all rules have required fields and valid operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`).
- **Reuse**: Used by both `InMemoryFlagRepository` and `JsonFileRepository` to ensure consistent flag creation.
## Constraints
- PHP 8.2+ required.
- No external dependencies (PSR interfaces excepted in future).

View file

@ -4,99 +4,41 @@ declare(strict_types=1);
namespace FlagPole; namespace FlagPole;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
final class Evaluator final class Evaluator
{ {
private LoggerInterface $logger;
public function __construct(?LoggerInterface $logger = null)
{
$this->logger = $logger ?? new NullLogger();
}
public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool public function evaluate(Flag $flag, ?Context $context = null, bool $default = false): bool
{ {
$context ??= new Context(); $context ??= new Context();
// 1) Allow list wins if present // 1) Allow list wins if present
if (!empty($flag->allowList)) { if (!empty($flag->allowList)) {
$key = $this->resolveTargetingKey($context, $flag->targetingKey); $key = $this->resolveTargetingKey($context);
if ($key !== null && in_array((string)$key, $flag->allowList, true)) { if ($key !== null && in_array((string)$key, $flag->allowList, true)) {
$this->logger->info(sprintf('Flag "%s" enabled for key "%s" via allowList.', $flag->name, $key));
return true; return true;
} }
} }
// 2) Explicit on/off // 2) Explicit on/off
if ($flag->enabled !== null) { if ($flag->enabled !== null) {
$this->logger->info(sprintf('Flag "%s" evaluated to %s via explicit "enabled" override.', $flag->name, $flag->enabled ? 'TRUE' : 'FALSE'));
return $flag->enabled; return $flag->enabled;
} }
// 3) Rules matching // 3) Percentage rollout if available
if (!empty($flag->rules)) {
foreach ($flag->rules as $rule) {
if ($this->matchRule($rule, $context)) {
$this->logger->info(sprintf('Flag "%s" enabled via rule (attribute: %s, operator: %s).', $flag->name, $rule->attribute, $rule->operator));
return true;
}
}
}
// 4) Percentage rollout if available
if ($flag->rolloutPercentage !== null) { if ($flag->rolloutPercentage !== null) {
$key = $this->resolveTargetingKey($context, $flag->targetingKey); $key = $this->resolveTargetingKey($context);
if ($key === null) { if ($key === null) {
$this->logger->info(sprintf('Flag "%s" rollout evaluation failed: no targeting key found. Using default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE'));
return $default; return $default;
} }
$bucket = $this->computeBucket($flag->name, (string)$key); $bucket = $this->computeBucket($flag->name, (string)$key);
$result = $bucket < $flag->rolloutPercentage; return $bucket < $flag->rolloutPercentage;
$this->logger->info(sprintf(
'Flag "%s" evaluated to %s via rolloutPercentage (%d%%). Bucket: %d, Key: %s.',
$flag->name,
$result ? 'TRUE' : 'FALSE',
$flag->rolloutPercentage,
$bucket,
$key
));
return $result;
} }
// 5) Fallback // 4) Fallback
$this->logger->info(sprintf('Flag "%s" using fallback default: %s.', $flag->name, $default ? 'TRUE' : 'FALSE'));
return $default; return $default;
} }
private function matchRule(Rule $rule, Context $context): bool private function resolveTargetingKey(Context $context): ?string
{ {
$attributeValue = $context->get($rule->attribute);
return match ($rule->operator) {
'eq' => $attributeValue === $rule->value,
'neq' => $attributeValue !== $rule->value,
'gt' => $attributeValue > $rule->value,
'gte' => $attributeValue >= $rule->value,
'lt' => $attributeValue < $rule->value,
'lte' => $attributeValue <= $rule->value,
'in' => is_array($rule->value) && in_array($attributeValue, $rule->value, true),
'nin' => is_array($rule->value) && !in_array($attributeValue, $rule->value, true),
'contains' => is_string($attributeValue) && is_string($rule->value) && str_contains($attributeValue, $rule->value),
default => throw new \LogicException(sprintf('Unsupported operator "%s" encountered during evaluation.', $rule->operator)),
};
}
private function resolveTargetingKey(Context $context, ?string $preferredKey = null): ?string
{
if ($preferredKey !== null) {
$v = $context->get($preferredKey);
if ($v !== null && $v !== '') {
return (string)$v;
}
}
$candidates = ['key', 'userId', 'id', 'email']; $candidates = ['key', 'userId', 'id', 'email'];
foreach ($candidates as $attr) { foreach ($candidates as $attr) {
$v = $context->get($attr); $v = $context->get($attr);
@ -109,8 +51,10 @@ final class Evaluator
private function computeBucket(string $flagName, string $key): int private function computeBucket(string $flagName, string $key): int
{ {
$hash = hash('xxh3', $flagName . ':' . $key); $hash = crc32($flagName . ':' . $key);
// Normalize to unsigned 32-bit to avoid negative values on some platforms
return (int)(hexdec(substr($hash, 0, 8)) % 100); $unsigned = (int) sprintf('%u', $hash);
// Map to 0..99
return (int)($unsigned % 100);
} }
} }

View file

@ -5,18 +5,13 @@ declare(strict_types=1);
namespace FlagPole; namespace FlagPole;
use FlagPole\Repository\FlagRepositoryInterface; use FlagPole\Repository\FlagRepositoryInterface;
use Psr\Log\LoggerInterface;
final class FeatureManager final class FeatureManager
{ {
private Evaluator $evaluator;
public function __construct( public function __construct(
private FlagRepositoryInterface $repository, private FlagRepositoryInterface $repository,
?Evaluator $evaluator = null, private Evaluator $evaluator = new Evaluator()
?LoggerInterface $logger = null
) { ) {
$this->evaluator = $evaluator ?? new Evaluator($logger);
} }
public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool public function isEnabled(string $flagName, ?Context $context = null, bool $default = false): bool

View file

@ -15,9 +15,6 @@ final class Flag
public readonly ?int $rolloutPercentage = null, public readonly ?int $rolloutPercentage = null,
/** @var list<string> */ /** @var list<string> */
public readonly array $allowList = [], public readonly array $allowList = [],
/** @var list<Rule> */
public readonly array $rules = [],
public readonly ?string $targetingKey = null,
) { ) {
if ($this->rolloutPercentage !== null) { if ($this->rolloutPercentage !== null) {
if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) { if ($this->rolloutPercentage < 0 || $this->rolloutPercentage > 100) {

View file

@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace FlagPole;
/**
* Hydrates Flag objects from raw configuration arrays.
*/
final class FlagHydrator
{
private const VALID_OPERATORS = [
'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'contains'
];
/**
* @param string $name
* @param array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>, rules?:list<array{attribute:string, operator:string, value:mixed}>, targetingKey?:string|null} $data
* @return Flag
*/
public function hydrate(string $name, array $data): Flag
{
$rules = [];
if (isset($data['rules']) && is_array($data['rules'])) {
foreach ($data['rules'] as $ruleDef) {
if (!isset($ruleDef['attribute'], $ruleDef['operator'], $ruleDef['value'])) {
throw new \InvalidArgumentException(sprintf('Invalid rule definition for flag "%s". Missing attribute, operator, or value.', $name));
}
if (!in_array($ruleDef['operator'], self::VALID_OPERATORS, true)) {
throw new \InvalidArgumentException(sprintf('Invalid operator "%s" for flag "%s".', $ruleDef['operator'], $name));
}
$rules[] = new Rule(
attribute: $ruleDef['attribute'],
operator: $ruleDef['operator'],
value: $ruleDef['value']
);
}
}
return new Flag(
name: $name,
enabled: $data['enabled'] ?? null,
rolloutPercentage: $data['rolloutPercentage'] ?? null,
allowList: $data['allowList'] ?? [],
rules: $rules,
targetingKey: $data['targetingKey'] ?? null
);
}
}

View file

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace FlagPole\Repository; namespace FlagPole\Repository;
use FlagPole\Flag; use FlagPole\Flag;
use FlagPole\FlagHydrator;
use FlagPole\Rule;
/** /**
* Simple in-memory repository. Useful for bootstrapping or tests. * Simple in-memory repository. Useful for bootstrapping or tests.
@ -17,14 +15,18 @@ final class InMemoryFlagRepository implements FlagRepositoryInterface
private array $flags = []; private array $flags = [];
/** /**
* @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>, rules?:list<array{attribute:string, operator:string, value:mixed}>, targetingKey?:string|null}> $config * @param array<string, array{enabled?:bool|null, rolloutPercentage?:int|null, allowList?:list<string>}> $config
*/ */
public static function fromArray(array $config): self public static function fromArray(array $config): self
{ {
$hydrator = new FlagHydrator();
$items = []; $items = [];
foreach ($config as $name => $def) { foreach ($config as $name => $def) {
$items[$name] = $hydrator->hydrate((string)$name, $def); $items[$name] = new Flag(
name: (string)$name,
enabled: $def['enabled'] ?? null,
rolloutPercentage: $def['rolloutPercentage'] ?? null,
allowList: $def['allowList'] ?? []
);
} }
return new self($items); return new self($items);
} }

View file

@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace FlagPole\Repository;
use FlagPole\Flag;
use FlagPole\FlagHydrator;
use FlagPole\Rule;
/**
* Loads flags from a JSON file.
*/
final class JsonFileRepository implements FlagRepositoryInterface
{
/** @var array<string, array<string, mixed>>|null */
private ?array $rawData = null;
/** @var array<string, Flag> */
private array $flags = [];
private ?FlagHydrator $hydrator = null;
public function __construct(
private readonly string $filePath
) {
}
public function get(string $name): ?Flag
{
$this->ensureLoaded();
if (isset($this->flags[$name])) {
return $this->flags[$name];
}
if (isset($this->rawData[$name])) {
$this->hydrator ??= new FlagHydrator();
$this->flags[$name] = $this->hydrator->hydrate($name, $this->rawData[$name]);
return $this->flags[$name];
}
return null;
}
/**
* @return iterable<string, Flag>
*/
public function all(): iterable
{
$this->ensureLoaded();
if ($this->rawData !== null) {
foreach (array_keys($this->rawData) as $name) {
$this->get((string)$name);
}
}
return $this->flags;
}
private function ensureLoaded(): void
{
if ($this->rawData !== null) {
return;
}
if (!file_exists($this->filePath)) {
$this->rawData = [];
return;
}
$content = file_get_contents($this->filePath);
if ($content === false) {
$this->rawData = [];
return;
}
try {
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->rawData = [];
return;
}
if (!is_array($data)) {
$this->rawData = [];
return;
}
$this->rawData = $data;
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace FlagPole;
/**
* A rule defines a condition that must be met for a flag to be enabled.
*/
final class Rule
{
public function __construct(
public readonly string $attribute,
public readonly string $operator,
public readonly mixed $value,
) {
}
}

View file

@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace FlagPole\Tests;
use FlagPole\Context;
use FlagPole\Evaluator;
use FlagPole\Flag;
use FlagPole\FlagHydrator;
use FlagPole\Rule;
use PHPUnit\Framework\TestCase;
final class ComprehensiveTest extends TestCase
{
public function testFlagValidation(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('rolloutPercentage must be between 0 and 100');
new Flag('invalid', rolloutPercentage: 101);
}
public function testFlagValidationNegative(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('rolloutPercentage must be between 0 and 100');
new Flag('invalid', rolloutPercentage: -1);
}
public function testHydratorMissingFields(): void
{
$hydrator = new FlagHydrator();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid rule definition for flag "test". Missing attribute, operator, or value.');
$hydrator->hydrate('test', [
'rules' => [
['attribute' => 'plan'] // missing operator and value
]
]);
}
public function testHydratorInvalidOperator(): void
{
$hydrator = new FlagHydrator();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid operator "invalid_op" for flag "test".');
$hydrator->hydrate('test', [
'rules' => [
['attribute' => 'plan', 'operator' => 'invalid_op', 'value' => 'pro']
]
]);
}
/**
* @dataProvider operatorProvider
*/
public function testAllOperators(string $operator, mixed $ruleValue, mixed $contextValue, bool $expected): void
{
$evaluator = new Evaluator();
$flag = new Flag('test', rules: [
new Rule('attr', $operator, $ruleValue)
]);
$context = Context::fromArray(['attr' => $contextValue]);
$this->assertSame($expected, $evaluator->evaluate($flag, $context));
}
public static function operatorProvider(): array
{
return [
'eq match' => ['eq', 'val', 'val', true],
'eq mismatch' => ['eq', 'val', 'other', false],
'neq match' => ['neq', 'val', 'other', true],
'neq mismatch' => ['neq', 'val', 'val', false],
'gt match' => ['gt', 10, 11, true],
'gt mismatch' => ['gt', 10, 10, false],
'gte match eq' => ['gte', 10, 10, true],
'gte match gt' => ['gte', 10, 11, true],
'gte mismatch' => ['gte', 10, 9, false],
'lt match' => ['lt', 10, 9, true],
'lt mismatch' => ['lt', 10, 10, false],
'lte match eq' => ['lte', 10, 10, true],
'lte match lt' => ['lte', 10, 9, true],
'lte mismatch' => ['lte', 10, 11, false],
'in match' => ['in', ['a', 'b'], 'a', true],
'in mismatch' => ['in', ['a', 'b'], 'c', false],
'nin match' => ['nin', ['a', 'b'], 'c', true],
'nin mismatch' => ['nin', ['a', 'b'], 'a', false],
'contains match' => ['contains', 'foo', 'foobar', true],
'contains mismatch' => ['contains', 'baz', 'foobar', false],
];
}
public function testRuntimeUnsupportedOperator(): void
{
$evaluator = new Evaluator();
// We bypass the hydrator which has validation to test the evaluator safety
$flag = new Flag('test', rules: [
new Rule('attr', 'unsupported', 'val')
]);
$context = Context::fromArray(['attr' => 'val']);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Unsupported operator "unsupported" encountered during evaluation.');
$evaluator->evaluate($flag, $context);
}
}

View file

@ -8,7 +8,6 @@ use FlagPole\Context;
use FlagPole\FeatureManager; use FlagPole\FeatureManager;
use FlagPole\Repository\InMemoryFlagRepository; use FlagPole\Repository\InMemoryFlagRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
final class FeatureManagerTest extends TestCase final class FeatureManagerTest extends TestCase
{ {
@ -24,27 +23,6 @@ final class FeatureManagerTest extends TestCase
$this->assertFalse($fm->isEnabled('hard-off', null, true)); $this->assertFalse($fm->isEnabled('hard-off', null, true));
} }
public function testLogging(): void
{
$logger = new class extends AbstractLogger {
public array $logs = [];
public function log($level, string|\Stringable $message, array $context = []): void
{
$this->logs[] = (string)$message;
}
};
$repo = InMemoryFlagRepository::fromArray([
'test-flag' => ['enabled' => true],
]);
$fm = new FeatureManager($repo, null, $logger);
$fm->isEnabled('test-flag');
$this->assertCount(1, $logger->logs);
$this->assertStringContainsString('Flag "test-flag" evaluated to TRUE via explicit "enabled" override', $logger->logs[0]);
}
public function testAllowListWins(): void public function testAllowListWins(): void
{ {
$repo = InMemoryFlagRepository::fromArray([ $repo = InMemoryFlagRepository::fromArray([
@ -138,70 +116,4 @@ final class FeatureManagerTest extends TestCase
$this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false)); $this->assertFalse($fm->isEnabled('gradual', $emptyCtx, false));
$this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true)); $this->assertTrue($fm->isEnabled('gradual', $emptyCtx, true));
} }
public function testRulesMatching(): void
{
$repo = InMemoryFlagRepository::fromArray([
'beta-users' => [
'rules' => [
['attribute' => 'group', 'operator' => 'eq', 'value' => 'beta'],
],
],
'version-check' => [
'rules' => [
['attribute' => 'version', 'operator' => 'gte', 'value' => '2.0'],
],
],
'multi-rules' => [
'rules' => [
['attribute' => 'plan', 'operator' => 'in', 'value' => ['pro', 'enterprise']],
['attribute' => 'region', 'operator' => 'eq', 'value' => 'us-east'],
],
],
]);
$fm = new FeatureManager($repo);
// Single rule 'eq'
$this->assertTrue($fm->isEnabled('beta-users', Context::fromArray(['group' => 'beta'])));
$this->assertFalse($fm->isEnabled('beta-users', Context::fromArray(['group' => 'standard'])));
// Single rule 'gte'
$this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.0'])));
$this->assertTrue($fm->isEnabled('version-check', Context::fromArray(['version' => '2.1'])));
$this->assertFalse($fm->isEnabled('version-check', Context::fromArray(['version' => '1.9'])));
// Multi rules (OR behavior because it matches ANY rule in the list as implemented currently)
// Wait, the plan said: "Precedence: allowList > enabled > rules > rolloutPercentage"
// And the implementation I did:
/*
if (!empty($flag->rules)) {
foreach ($flag->rules as $rule) {
if ($this->matchRule($rule, $context)) {
return true;
}
}
}
*/
// This is indeed OR behavior. If ANY rule matches, it returns true.
$this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'pro'])));
$this->assertTrue($fm->isEnabled('multi-rules', Context::fromArray(['region' => 'us-east'])));
$this->assertFalse($fm->isEnabled('multi-rules', Context::fromArray(['plan' => 'free', 'region' => 'eu-west'])));
}
public function testExplicitTargetingKey(): void
{
$repo = InMemoryFlagRepository::fromArray([
'custom-key' => [
'targetingKey' => 'orgId',
'allowList' => ['org_123'],
],
]);
$fm = new FeatureManager($repo);
$ctxMatched = Context::fromArray(['orgId' => 'org_123']);
$ctxUnmatched = Context::fromArray(['orgId' => 'org_456', 'userId' => 'org_123']); // userId matches but orgId doesn't
$this->assertTrue($fm->isEnabled('custom-key', $ctxMatched));
$this->assertFalse($fm->isEnabled('custom-key', $ctxUnmatched));
}
} }

View file

@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
namespace FlagPole\Tests;
use FlagPole\Repository\JsonFileRepository;
use PHPUnit\Framework\TestCase;
final class JsonFileRepositoryTest extends TestCase
{
private string $tempFile;
protected function setUp(): void
{
$this->tempFile = tempnam(sys_get_temp_dir(), 'flagpole_test_');
}
protected function tearDown(): void
{
if (file_exists($this->tempFile)) {
unlink($this->tempFile);
}
}
public function testLoadsFlagsFromJson(): void
{
$json = json_encode([
'test-flag' => [
'enabled' => true,
'rules' => [
['attribute' => 'plan', 'operator' => 'eq', 'value' => 'pro']
]
]
]);
file_put_contents($this->tempFile, $json);
$repo = new JsonFileRepository($this->tempFile);
$flag = $repo->get('test-flag');
$this->assertNotNull($flag);
$this->assertEquals('test-flag', $flag->name);
$this->assertTrue($flag->enabled);
$this->assertCount(1, $flag->rules);
$this->assertEquals('plan', $flag->rules[0]->attribute);
}
public function testHandlesMissingFile(): void
{
$repo = new JsonFileRepository('/non/existent/file.json');
$this->assertNull($repo->get('any'));
$this->assertEmpty($repo->all());
}
public function testHandlesInvalidJson(): void
{
file_put_contents($this->tempFile, 'invalid json');
$repo = new JsonFileRepository($this->tempFile);
$this->assertNull($repo->get('any'));
}
public function testLazyHydration(): void
{
$json = json_encode([
'flag-1' => ['enabled' => true],
'flag-2' => ['enabled' => false],
]);
file_put_contents($this->tempFile, $json);
$repo = new JsonFileRepository($this->tempFile);
// Access internal property via reflection to verify it's NOT hydrated yet
$reflection = new \ReflectionClass($repo);
$flagsProp = $reflection->getProperty('flags');
$flagsProp->setAccessible(true);
$this->assertEmpty($flagsProp->getValue($repo));
// Hydrate flag-1
$flag1 = $repo->get('flag-1');
$this->assertNotNull($flag1);
$this->assertTrue($flag1->enabled);
$hydratedFlags = $flagsProp->getValue($repo);
$this->assertArrayHasKey('flag-1', $hydratedFlags);
$this->assertArrayNotHasKey('flag-2', $hydratedFlags);
// Hydrate all
$all = $repo->all();
$this->assertCount(2, $all);
$hydratedFlagsAfterAll = $flagsProp->getValue($repo);
$this->assertArrayHasKey('flag-1', $hydratedFlagsAfterAll);
$this->assertArrayHasKey('flag-2', $hydratedFlagsAfterAll);
}
}