diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..fcc18e0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,34 @@ +## Description + +Brief summary of changes and motivation. + +Fixes # (issue) + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing + +- [ ] Tests added/updated +- [ ] All tests pass locally +- [ ] Tested with both tenancy modes (if applicable) + +**Test Environment:** +- PHP: +- Laravel: + +## Checklist + +- [ ] Code follows project style +- [ ] Self-reviewed code +- [ ] Updated documentation +- [ ] Backward compatible (or breaking change documented) +- [ ] No new warnings/errors + +## Additional Notes + +Any extra context or screenshots. \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d0d0621 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[mdazizulhakim.cse@gmail.com](mdazizulhakim.cse@gmail.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/README.md b/README.md index a6e4dd9..f001e07 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# laravel-setanjo - Multi-Tenant Laravel Settings Package +# laravel-setanjo - Laravel Settings Package with Multi-Tenant Support
-A powerful Laravel package for managing application settings with multi-tenant support. Store global settings or tenant-specific configurations with automatic type casting, caching, and a clean API. Perfect for A/B testing, feature flags, and user preferences. +A powerful Laravel package for managing application settings and configurations. Store global application settings or model-specific configurations (user preferences, company settings, etc.) with automatic type casting, caching, and a clean API. Perfect for feature flags, A/B testing, user preferences, and dynamic configuration management. + +**Note**: This package does **not** provide multi-tenancy features for your application. However, if your Laravel project already has multi-tenancy implemented, this package can store tenant-specific settings alongside your existing tenant architecture. ## Features -- ๐ข **Multi-Tenant Support**: Both strict and polymorphic tenancy modes -- ๐๏ธ **Polymorphic Storage**: Store settings for any model type -- ๐๏ธ **Global Settings**: Settings without any tenant scope +- ๐ข **Multi-Tenant Ready**: Works with existing multi-tenant applications +- ๐๏ธ **Model-Specific Settings**: Store settings for any Eloquent model (User, Company, etc.) +- ๐๏ธ **Global Settings**: Application-wide settings without model scope - โก **Caching**: Optional caching with configurable cache store -- ๐ **Validation**: Validate tenant models and prevent unauthorized access -- ๐ฆ **Clean API**: Simple, intuitive API inspired by popular packages +- ๐ **Validation**: Validate models and prevent unauthorized access +- ๐ฆ **Clean API**: Simple, intuitive API for setting and retrieving values - ๐งช **Fully Tested**: Comprehensive test suite included - โ **Type Safety**: Automatic type detection and conversion diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..76d0295 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,115 @@ +# Security Policy + +## Supported Versions + +We take security seriously and provide security updates for the following versions of laravel-setanjo: + +| Version | Supported | Laravel Compatibility | +| ------- | ------------------ | -------------------- | +| 1.x.x | :white_check_mark: | Laravel 10.x, 11.x, 12.x | +| < 1.0 | :white_check_mark: | Laravel 10.x, 11.x, 12.x | + +**Note**: Only the latest major version receives security updates. We recommend keeping your installation up to date with the latest stable release. + +## Reporting a Vulnerability + +We appreciate responsible disclosure of security vulnerabilities. If you discover a security issue, please follow these steps: + +### How to Report + +**Please DO NOT create a public GitHub issue for security vulnerabilities.** + +Instead, report security issues privately by: + +1. **Email**: Send details to [mdazizulhakim.cse@gmail.com](mdazizulhakim.cse@gmail.com) or the package maintainer directly +2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature + + +### What to Include + +When reporting a security vulnerability, please include: + +- **Description** of the vulnerability and its potential impact +- **Steps to reproduce** the issue with detailed instructions +- **Affected versions** or version ranges +- **Proof of concept** code or screenshots (if applicable) +- **Suggested fix** or mitigation (if you have ideas) +- **Your contact information** for follow-up questions + +### Response Timeline + +We are committed to responding to security reports promptly: + +- **Initial Response**: Within 48 hours of report +- **Assessment**: Within 7 days we'll provide initial assessment +- **Fix Development**: Critical issues will be prioritized for immediate fixes +- **Disclosure**: Coordinated disclosure after fix is available + +### What to Expect + +**If the vulnerability is accepted:** +- We'll work with you to understand and reproduce the issue +- Develop and test a fix +- Release a security patch +- Credit you in the security advisory (if desired) +- Coordinate public disclosure timing + +**If the vulnerability is declined:** +- We'll explain why it's not considered a security issue +- Provide guidance if it's a configuration or usage issue +- Suggest alternative reporting channels if appropriate + +## Security Considerations + +### Multi-Tenant Security + +This package handles multi-tenant data. Key security considerations: + +- **Tenant Isolation**: Settings are properly isolated between tenants +- **Authorization**: Validate tenant access before reading/writing settings +- **Model Validation**: Ensure only allowed models can be used as tenants + +### Best Practices + +When using laravel-setanjo: + +1. **Validate Input**: Always validate setting values before storage +2. **Sanitize Output**: Be cautious when displaying user-provided setting values +3. **Access Control**: Implement proper authorization for setting management +4. **Audit Trail**: Consider logging sensitive setting changes +5. **Cache Security**: Ensure cache stores are properly secured + +### Known Security Considerations + +- Settings stored in database are not encrypted by default +- Cache invalidation timing may expose information about setting changes +- Polymorphic mode requires careful tenant model validation + +## Security Updates + +Security updates will be: + +- Released as patch versions (e.g., 1.0.x) +- Documented in [CHANGELOG.md](CHANGELOG.md) with security labels +- Announced through GitHub releases +- Tagged with `security` label + +## Acknowledgments + +We thank the security research community for helping keep laravel-setanjo secure. Security researchers who responsibly disclose vulnerabilities will be acknowledged in: + +- Security advisories +- CHANGELOG.md +- Hall of fame (if established) + +## Contact + +For security-related questions or concerns: + +- **Security Issues**: Use private reporting methods above +- **General Security Questions**: Create a GitHub discussion +- **Documentation**: Suggest improvements via pull request + +--- + +**Remember**: Security is everyone's responsibility. If you're unsure whether something is a security issue, err on the side of caution and report \ No newline at end of file diff --git a/composer.json b/composer.json index 836b664..64146d3 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,15 @@ { "name": "ahs12/laravel-setanjo", - "description": "Multi-tenant Laravel settings package with polymorphic support", + "description": "Laravel settings package for managing application configurations, user preferences, feature flags, and A/B testing with multi-tenant support", "keywords": [ "laravel", "settings", + "configuration", + "feature-flags", + "ab-testing", + "user-preferences", "multi-tenant", - "polymorphic", - "configuration" + "polymorphic" ], "homepage": "https://github.com/ahs12/laravel-setanjo", "license": "MIT", diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Commands/ClearCacheCommand.php b/src/Commands/ClearCacheCommand.php index f381079..af9ce07 100644 --- a/src/Commands/ClearCacheCommand.php +++ b/src/Commands/ClearCacheCommand.php @@ -10,7 +10,8 @@ class ClearCacheCommand extends Command { protected $signature = 'setanjo:clear-cache {--tenant= : Clear cache for specific tenant (format: App\\Models\\User:ID)} - {--all : Clear all setanjo cache}'; + {--all : Clear all setanjo cache} + {--debug : Show debug information}'; protected $description = 'Clear setanjo settings cache'; @@ -29,6 +30,11 @@ public function handle(): int $tenant = $this->option('tenant'); $all = $this->option('all'); + $debug = $this->option('debug'); + + if ($debug) { + $this->showDebugInfo(); + } if ($tenant) { $tenant = $this->normalizeTenantKey($tenant); @@ -42,32 +48,38 @@ public function handle(): int return self::SUCCESS; } - /** - * Normalize tenant key to handle escaped backslashes - */ + protected function showDebugInfo(): void + { + $this->info('=== Cache Debug Information ==='); + $this->line('Cache Default Store: '.config('cache.default')); + $this->line('Setanjo Cache Store: '.(config('setanjo.cache.store') ?: 'default')); + $this->line('Setanjo Cache Prefix: '.config('setanjo.cache.prefix', 'setanjo')); + $this->line('Cache TTL: '.config('setanjo.cache.ttl', 3600).' seconds'); + + $testKey = $this->buildCacheKey('global'); + $this->line('Example Cache Key: '.$testKey); + $this->line('================================'); + } + protected function normalizeTenantKey(string $tenantKey): string { - // Try to reconstruct the proper namespace if backslashes are missing if (! str_contains($tenantKey, '\\') && str_contains($tenantKey, 'ModelsUser:')) { - // Handle common Laravel pattern: AppModelsUser:ID -> App\Models\User:ID $tenantKey = preg_replace('/^App([A-Z][a-z]+)+User:/', 'App\\Models\\User:', $tenantKey); } elseif (! str_contains($tenantKey, '\\') && preg_match('/^([A-Z][a-z]+)+([A-Z][a-z]+):(\d+)$/', $tenantKey, $matches)) { - // General pattern reconstruction for missing backslashes $fullClass = $matches[0]; $parts = preg_split('/(?=[A-Z])/', $fullClass, -1, PREG_SPLIT_NO_EMPTY); if (count($parts) >= 3) { - // Assume App\Models\ModelName pattern $reconstructed = $parts[0].'\\'.$parts[1].'\\'.implode('', array_slice($parts, 2)); - $this->line("Auto-reconstructed: '{$reconstructed}'"); + if ($this->option('debug')) { + $this->line("Auto-reconstructed: '{$reconstructed}'"); + } $tenantKey = $reconstructed; } } - // Handle double-escaped backslashes that might occur in command line $tenantKey = str_replace('\\\\', '\\', $tenantKey); - // Validate format: ModelClass:ID if (! preg_match('/^[A-Za-z\\\\]+:\d+$/', $tenantKey)) { $this->error('Invalid tenant format. Use: ModelClass:ID (e.g., App\\Models\\User:1)'); $this->error('On Windows, try using quotes: --tenant="App\\Models\\User:1"'); @@ -78,16 +90,30 @@ protected function normalizeTenantKey(string $tenantKey): string } /** - * Clear cache for specific tenant + * Clear cache for specific tenant using same logic as repository */ protected function clearTenantCache(string $tenantKey): void { $cacheKey = $this->buildCacheKey($tenantKey); + $store = Cache::store(config('setanjo.cache.store')); - Cache::store(config('setanjo.cache.store'))->forget($cacheKey); + if ($this->option('debug')) { + $hasCache = $store->has($cacheKey); + $this->line("Cache key '{$cacheKey}' exists: ".($hasCache ? 'YES' : 'NO')); + } + + // Use same logic as repository + $success = $this->clearCacheUsingRepositoryLogic($store, $cacheKey); + + if ($this->option('debug')) { + $this->line('Cache clear result: '.($success ? 'SUCCESS' : 'FAILED')); + + // Verify it's gone + $stillExists = $store->has($cacheKey); + $this->line('Cache still exists after clear: '.($stillExists ? 'YES' : 'NO')); + } $this->info("Cleared cache for tenant: {$tenantKey}"); - $this->line("Cache key used: {$cacheKey}"); } /** @@ -96,146 +122,169 @@ protected function clearTenantCache(string $tenantKey): void protected function clearGlobalCache(): void { $cacheKey = $this->buildCacheKey('global'); + $store = Cache::store(config('setanjo.cache.store')); + + if ($this->option('debug')) { + $hasCache = $store->has($cacheKey); + $this->line("Global cache key '{$cacheKey}' exists: ".($hasCache ? 'YES' : 'NO')); + } + + // Use same logic as repository + $success = $this->clearCacheUsingRepositoryLogic($store, $cacheKey); - Cache::store(config('setanjo.cache.store'))->forget($cacheKey); + if ($this->option('debug')) { + $this->line('Cache clear result: '.($success ? 'SUCCESS' : 'FAILED')); + + // Verify it's gone + $stillExists = $store->has($cacheKey); + $this->line('Cache still exists after clear: '.($stillExists ? 'YES' : 'NO')); + } $this->info('Cleared global setanjo cache.'); } /** - * Clear all setanjo cache using Laravel's cache methods + * Clear all setanjo cache */ protected function clearAllCache(): void { $store = Cache::store(config('setanjo.cache.store')); - // Option 1: Use pattern matching for Redis - if ($this->isRedisStore()) { - $this->clearRedisPatternCache($store); - - return; - } - - // Option 2: Clear known cache keys (safer approach) - if ($this->clearKnownCacheKeys($store)) { - return; - } + try { + // If cache supports tags, clear all setanjo cache at once + if ($this->supportsCacheTags($store)) { + if ($this->option('debug')) { + $this->line('Using cache tags to clear all setanjo cache...'); + } - // Option 3: Fallback - ask to flush entire store (if supported) - $this->warn('Cache driver does not support selective clearing.'); + $store->tags(['setanjo'])->flush(); + $this->info('Cleared all setanjo cache using tags.'); - if ($this->confirm('Do you want to flush the entire cache store?')) { - try { - if (method_exists($store, 'flush')) { - $store->flush(); - $this->info('Entire cache store flushed.'); - } else { - $this->error('Cache store does not support flushing. Please clear cache manually or use --tenant option.'); - } - } catch (\Exception $e) { - $this->error('Failed to flush cache: '.$e->getMessage()); + return; } - } else { - $this->info('Cache clearing cancelled.'); - } - } - /** - * Clear known cache keys from database - */ - protected function clearKnownCacheKeys($store): bool - { - try { + // Fallback: Clear individual cache entries + $clearedCount = 0; + // Get all unique tenant combinations from database $tenants = \DB::table(config('setanjo.table', 'settings')) ->select('tenantable_type', 'tenantable_id') + ->whereNotNull('tenantable_type') + ->whereNotNull('tenantable_id') ->distinct() ->get(); - $clearedCount = 0; + if ($this->option('debug')) { + $this->line('Found '.$tenants->count().' unique tenant combinations in database'); + } // Clear global cache $globalKey = $this->buildCacheKey('global'); - $store->forget($globalKey); + if ($this->option('debug')) { + $hasGlobalCache = $store->has($globalKey); + $this->line('Global cache exists: '.($hasGlobalCache ? 'YES' : 'NO')); + } + + $success = $this->clearCacheUsingRepositoryLogic($store, $globalKey); $clearedCount++; + if ($this->option('debug')) { + $this->line('Cleared global cache: '.($success ? 'SUCCESS' : 'FAILED')); + } + // Clear each tenant's cache foreach ($tenants as $tenant) { if ($tenant->tenantable_type && $tenant->tenantable_id) { $tenantKey = "{$tenant->tenantable_type}:{$tenant->tenantable_id}"; $cacheKey = $this->buildCacheKey($tenantKey); - $store->forget($cacheKey); + + if ($this->option('debug')) { + $hasTenantCache = $store->has($cacheKey); + $this->line("Tenant cache '{$tenantKey}' exists: ".($hasTenantCache ? 'YES' : 'NO')); + } + + $success = $this->clearCacheUsingRepositoryLogic($store, $cacheKey); $clearedCount++; + + if ($this->option('debug')) { + $this->line("Cleared tenant cache '{$tenantKey}': ".($success ? 'SUCCESS' : 'FAILED')); + } } } - $this->info("Cleared {$clearedCount} setanjo cache keys."); + $this->info("Processed {$clearedCount} setanjo cache keys."); + + // Verification + if ($this->option('debug')) { + $this->line(''); + $this->line('=== Verification ==='); + + $globalExists = $store->has($globalKey); + $this->line('Global cache still exists: '.($globalExists ? 'YES' : 'NO')); + + $verifiedCount = 0; + foreach ($tenants->take(3) as $tenant) { + if ($tenant->tenantable_type && $tenant->tenantable_id) { + $tenantKey = "{$tenant->tenantable_type}:{$tenant->tenantable_id}"; + $cacheKey = $this->buildCacheKey($tenantKey); + $exists = $store->has($cacheKey); + $this->line("Tenant cache '{$tenantKey}' still exists: ".($exists ? 'YES' : 'NO')); + if (! $exists) { + $verifiedCount++; + } + } + } - return true; + $this->line('Verification: '.$verifiedCount.'/'.min(3, $tenants->count()).' tenant caches successfully cleared'); + } } catch (\Exception $e) { - $this->error('Failed to clear known cache keys: '.$e->getMessage()); - - return false; + $this->error('Failed to clear cache: '.$e->getMessage()); + if ($this->option('debug')) { + $this->error('Exception details: '.$e->getTraceAsString()); + } } } /** - * Build cache key using same logic as repository + * Clear cache using the same logic as the repository */ - protected function buildCacheKey(string $tenantKey): string + protected function clearCacheUsingRepositoryLogic($store, string $cacheKey): bool { - $prefix = config('setanjo.cache.prefix', 'setanjo'); - - return "{$prefix}:settings:{$tenantKey}"; + // Use tags if supported (same as repository) + if ($this->supportsCacheTags($store)) { + return $store->tags(['setanjo', 'settings'])->forget($cacheKey); + } else { + return $store->forget($cacheKey); + } } /** - * Check if using Redis store + * Check if cache store supports tags (copied from repository) */ - protected function isRedisStore(): bool + protected function supportsCacheTags($store): bool { - $defaultStore = config('cache.default'); - $currentStore = config('setanjo.cache.store') ?: $defaultStore; - - return $currentStore === 'redis' || - (is_null(config('setanjo.cache.store')) && $defaultStore === 'redis'); + return method_exists($store, 'tags') && + in_array($this->getCurrentCacheDriver(), ['redis', 'memcached']); } /** - * Clear Redis cache using pattern matching + * Get current cache driver name (copied from repository) */ - protected function clearRedisPatternCache($store): void + protected function getCurrentCacheDriver(): string { - try { - // Try different methods to get Redis connection - $redis = null; - - if (method_exists($store, 'getRedis')) { - $redis = $store->getRedis(); - } elseif (method_exists($store, 'connection')) { - $redis = $store->connection(); - } else { - // Fallback to Laravel's Redis facade - $redis = \Illuminate\Support\Facades\Redis::connection(); - } + $storeConfig = config('setanjo.cache.store'); - $prefix = config('setanjo.cache.prefix', 'setanjo'); - $pattern = "{$prefix}:settings:*"; - - $keys = $redis->keys($pattern); + return $storeConfig ?: config('cache.default'); + } - if (! empty($keys)) { - $redis->del($keys); - $this->info('Cleared '.count($keys).' setanjo cache keys from Redis.'); - } else { - $this->info('No setanjo cache keys found in Redis.'); - } + /** + * Build cache key using same logic as repository + */ + protected function buildCacheKey(string $tenantKey): string + { + $prefix = config('setanjo.cache.prefix', 'setanjo'); - } catch (\Exception $e) { - $this->error('Failed to clear Redis cache: '.$e->getMessage()); - $this->info('Falling back to known cache keys method...'); - $this->clearKnownCacheKeys($store); - } + return "{$prefix}:settings:{$tenantKey}"; } } diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 4f680bc..bd754e3 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -96,7 +96,7 @@ public function setValueAttribute($value): void $type = SettingType::from($typeValue); $this->attributes['value'] = match ($type) { - SettingType::ARRAY , SettingType::JSON, SettingType::OBJECT => json_encode($value), + SettingType::ARRAY , SettingType::JSON, SettingType::OBJECT => json_encode($value, JSON_THROW_ON_ERROR), SettingType::BOOLEAN => $value ? '1' : '0', default => (string) $value, }; diff --git a/src/Repositories/DatabaseSettingsRepository.php b/src/Repositories/DatabaseSettingsRepository.php index 697e1b2..68afa6c 100644 --- a/src/Repositories/DatabaseSettingsRepository.php +++ b/src/Repositories/DatabaseSettingsRepository.php @@ -181,13 +181,13 @@ protected function clearCache($tenant): void } $store = Cache::store(config('setanjo.cache.store')); + $cacheKey = $this->getCacheKey($tenant); // Use tags if supported if ($this->supportsCacheTags($store)) { - $store->tags(['setanjo'])->flush(); + $store->tags(['setanjo', 'settings'])->forget($cacheKey); } else { // Clear specific cache key - $cacheKey = $this->getCacheKey($tenant); $store->forget($cacheKey); } } diff --git a/tests/Feature/CommandsFeatureTest.php b/tests/Feature/CommandsFeatureTest.php new file mode 100644 index 0000000..c4a5996 --- /dev/null +++ b/tests/Feature/CommandsFeatureTest.php @@ -0,0 +1,224 @@ +artisan('setanjo:clear-cache') + ->assertExitCode(0); + }); + + test('it can clear cache when disabled', function () { + config()->set('setanjo.cache.enabled', false); + + $this->artisan('setanjo:clear-cache') + ->expectsOutput('Cache is disabled. Nothing to clear.') + ->assertExitCode(0); + }); + + test('it can clear tenant specific cache', function () { + $user = User::create(['name' => 'John Doe', 'email' => 'john@example.com']); + + Settings::for($user)->set('user_setting', 'user_value'); + + $this->artisan('setanjo:clear-cache', [ + '--tenant' => 'Ahs12\\Setanjo\\Tests\\Models\\User:'.$user->id, + ]) + ->assertExitCode(0); + }); + + test('it can clear all cache', function () { + Settings::set('global_setting', 'global_value'); + + $user = User::create(['name' => 'John Doe', 'email' => 'john@example.com']); + Settings::for($user)->set('user_setting', 'user_value'); + + $this->artisan('setanjo:clear-cache --all') + ->assertExitCode(0); + }); + + test('it handles invalid tenant format', function () { + $this->artisan('setanjo:clear-cache', ['--tenant' => 'invalid-format']) + ->assertExitCode(0); + }); +}); + +describe('Install Defaults Command', function () { + test('it can install default settings', function () { + config()->set('setanjo.defaults', [ + 'app_name' => [ + 'value' => 'Test App', + ], + 'theme' => 'dark', + 'max_users' => [ + 'value' => 100, + ], + ]); + + $this->artisan('setanjo:install-defaults') + ->expectsOutput('Installed setting: app_name') + ->expectsOutput('Installed setting: theme') + ->expectsOutput('Installed setting: max_users') + ->assertExitCode(0); + + expect(Settings::get('app_name'))->toBe('Test App'); + expect(Settings::get('theme'))->toBe('dark'); + expect(Settings::get('max_users'))->toBe(100); + }); + + test('it can force reinstall default settings', function () { + Settings::set('app_name', 'Old Name'); + Settings::set('theme', 'light'); + + config()->set('setanjo.defaults', [ + 'app_name' => [ + 'value' => 'New Name', + ], + 'theme' => 'dark', + ]); + + $this->artisan('setanjo:install-defaults --force') + ->expectsOutput('Installed setting: app_name') + ->expectsOutput('Installed setting: theme') + ->assertExitCode(0); + + expect(Settings::get('app_name'))->toBe('New Name'); + expect(Settings::get('theme'))->toBe('dark'); + }); + + test('it skips existing settings without force', function () { + Settings::set('app_name', 'Existing App'); + + config()->set('setanjo.defaults', [ + 'app_name' => [ + 'value' => 'New App', + ], + 'theme' => 'dark', + ]); + + $this->artisan('setanjo:install-defaults') + ->expectsOutput('Skipped existing setting: app_name') + ->expectsOutput('Installed setting: theme') + ->assertExitCode(0); + + expect(Settings::get('app_name'))->toBe('Existing App'); + expect(Settings::get('theme'))->toBe('dark'); + }); + + test('it handles empty defaults configuration', function () { + config()->set('setanjo.defaults', []); + + $this->artisan('setanjo:install-defaults') + ->expectsOutput('No default settings configured.') + ->assertExitCode(0); + }); + + test('it handles missing defaults configuration', function () { + Config::set('setanjo.defaults', null); + + $this->artisan('setanjo:install-defaults') + ->expectsOutput('No default settings configured.') + ->assertExitCode(0); + }); + + test('it shows installation summary', function () { + Settings::set('existing_setting', 'value'); + + config()->set('setanjo.defaults', [ + 'existing_setting' => 'new_value', + 'new_setting' => 'value', + 'another_setting' => 'another_value', + ]); + + $this->artisan('setanjo:install-defaults') + ->expectsOutput('Installation complete! Installed: 2, Skipped: 1') + ->assertExitCode(0); + }); + + test('it handles different default value formats', function () { + config()->set('setanjo.defaults', [ + 'simple_value' => 'simple', + 'complex_value' => [ + 'value' => 'complex', + ], + 'boolean_value' => true, + 'numeric_value' => 42, + ]); + + $this->artisan('setanjo:install-defaults') + ->assertExitCode(0); + + expect(Settings::get('simple_value'))->toBe('simple'); + expect(Settings::get('complex_value'))->toBe('complex'); + expect(Settings::get('boolean_value'))->toBeTrue(); + expect(Settings::get('numeric_value'))->toBe(42); + }); +}); + +describe('Commands Integration', function () { + test('commands work together', function () { + config()->set('setanjo.defaults', [ + 'test_setting' => 'test_value', + ]); + + $this->artisan('setanjo:install-defaults') + ->assertExitCode(0); + + expect(Settings::get('test_setting'))->toBe('test_value'); + + $this->artisan('setanjo:clear-cache') + ->assertExitCode(0); + + expect(Settings::get('test_setting'))->toBe('test_value'); + }); + + test('install defaults then clear specific tenant cache', function () { + $user = User::create(['name' => 'Test User', 'email' => 'test@example.com']); + + config()->set('setanjo.defaults', [ + 'user_theme' => 'blue', + ]); + + $this->artisan('setanjo:install-defaults') + ->assertExitCode(0); + + Settings::for($user)->set('user_preference', 'dark_mode'); + + $this->artisan('setanjo:clear-cache', [ + '--tenant' => 'Ahs12\\Setanjo\\Tests\\Models\\User:'.$user->id, + ]) + ->assertExitCode(0); + + expect(Settings::get('user_theme'))->toBe('blue'); + expect(Settings::for($user)->get('user_preference'))->toBe('dark_mode'); + }); + + test('force install after cache operations', function () { + config()->set('setanjo.defaults', [ + 'original_setting' => 'original_value', + ]); + + $this->artisan('setanjo:install-defaults') + ->assertExitCode(0); + + $this->artisan('setanjo:clear-cache') + ->assertExitCode(0); + + config()->set('setanjo.defaults', [ + 'original_setting' => 'updated_value', + ]); + + $this->artisan('setanjo:install-defaults --force') + ->expectsOutput('Installed setting: original_setting') + ->assertExitCode(0); + + expect(Settings::get('original_setting'))->toBe('updated_value'); + }); +});