From cffa0f3b37a2d74ca5c37984a44c6b5cb5eee1c7 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:03:34 +0300 Subject: [PATCH 1/9] refactor: return indexed array from parse_csv function for consistent output --- src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index 2239670..e4ed100 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -437,5 +437,5 @@ function parse_csv(string|array $csv, ?string $separator = ',', ?callable $callb $csv = explode($separator, $csv); } - return array_filter(array_map($callback ?: 'trim', $csv)); + return array_values(array_filter(array_map($callback ?: 'trim', $csv))); } From 0c706c3014db868131bc5fc59affc067fc5b3a12 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:03:46 +0300 Subject: [PATCH 2/9] refactor: encapsulate cache invalidation logic in forgetCachedValue method for settings --- src/Models/Setting.php | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Models/Setting.php b/src/Models/Setting.php index b98b0d0..5716ae3 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -3,6 +3,7 @@ namespace Redot\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; use Redot\Casts\Union; class Setting extends Model @@ -93,14 +94,32 @@ public static function default(string $key): mixed protected static function booted() { static::created(function ($setting) { - cache()->forget('settings.' . $setting->key); + static::forgetCachedValue($setting); }); static::updated(function ($setting) { - cache()->forget('settings.' . $setting->key); + static::forgetCachedValue($setting); }); } + /** + * Forget cached setting values, including nested keys cached separately. + */ + protected static function forgetCachedValue(self $setting): void + { + cache()->forget('settings.' . $setting->key); + + foreach (array_keys(Arr::dot(Arr::wrap(static::default($setting->key)))) as $key) { + cache()->forget('settings.' . $setting->key . '.' . $key); + } + + if (is_array($setting->value)) { + foreach (array_keys(Arr::dot($setting->value)) as $key) { + cache()->forget('settings.' . $setting->key . '.' . $key); + } + } + } + /** * Get the specified setting value. */ From fda358737e8a37b10d065b9a88d5c8ff6b5d0ac0 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:03:57 +0300 Subject: [PATCH 3/9] refactor: update LangExtractor to accept dynamic file extensions and improve translation pattern generation --- src/packages/lang-extractor/src/LangExtractor.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/packages/lang-extractor/src/LangExtractor.php b/src/packages/lang-extractor/src/LangExtractor.php index b73ccf7..7511d3f 100644 --- a/src/packages/lang-extractor/src/LangExtractor.php +++ b/src/packages/lang-extractor/src/LangExtractor.php @@ -39,7 +39,7 @@ public function __construct($directories = [], $extensions = []) } if (count($extensions) > 0) { - $this->withExtensions('php'); + $this->withExtensions(...$extensions); } $this->pattern = $this->generatePatternUsing('__', 'trans', '@lang'); @@ -83,7 +83,9 @@ protected function generatePatternUsing(string ...$functions): string $ignore = glob(lang_path(config('app.fallback_locale')) . '/*.php'); $ignore = array_map(fn ($file) => basename($file, '.php') . '\.[^\s]', $ignore); - return '/(?:' . implode('|', $functions) . ")\((['\"])(?(?!" . implode('|', $ignore) . ")(?:[^']|\\\')+?)(? 0 ? '(?!' . implode('|', $ignore) . ')' : ''; + + return '/(?:' . implode('|', $functions) . ")\((['\"])(?{$ignore}(?:[^']|\\\')+?)(? Date: Sat, 25 Apr 2026 16:04:07 +0300 Subject: [PATCH 4/9] refactor: change view compiled path to use system temporary directory for tests --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 10913cf..55ec51d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -55,7 +55,7 @@ protected function defineEnvironment($app): void $app['config']->set('permission.testing', true); $app['config']->set('queue.default', 'sync'); $app['config']->set('session.driver', 'array'); - $app['config']->set('view.compiled', __DIR__ . '/../storage/framework/views'); + $app['config']->set('view.compiled', sys_get_temp_dir() . '/redot-core-tests/views'); } /** From 49906d2fc2e8dc579e3282f2713d9a35fe8d6d27 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:04:24 +0300 Subject: [PATCH 5/9] test: add comprehensive feature and unit tests for core components including service providers, middleware, models, and rules --- .../EnsureDependenciesBuiltTest.php | 28 ++++++++ .../Core/Middleware/LocalizationTest.php | 37 +++++++++++ tests/Feature/Core/ServiceProviderTest.php | 53 +++++++++++++++ tests/Unit/Core/Casts/UnionTest.php | 28 ++++++++ tests/Unit/Core/HelpersTest.php | 39 +++++++++++ tests/Unit/Core/Models/LanguageTest.php | 24 +++++++ tests/Unit/Core/Models/LanguageTokenTest.php | 66 +++++++++++++++++++ tests/Unit/Core/Models/SettingTest.php | 43 ++++++++++++ tests/Unit/Core/Rules/CaptchaTest.php | 30 +++++++++ tests/Unit/Core/Rules/PhoneTest.php | 13 ++++ tests/Unit/Packages/Datatables/ColumnTest.php | 42 ++++++++++++ .../LangExtractor/LangExtractorTest.php | 47 +++++++++++++ tests/Unit/Packages/Sidebar/ItemTest.php | 43 ++++++++++++ tests/Unit/Packages/Toastify/ToastifyTest.php | 20 ++++++ 14 files changed, 513 insertions(+) create mode 100644 tests/Feature/Core/Middleware/EnsureDependenciesBuiltTest.php create mode 100644 tests/Feature/Core/Middleware/LocalizationTest.php create mode 100644 tests/Feature/Core/ServiceProviderTest.php create mode 100644 tests/Unit/Core/Casts/UnionTest.php create mode 100644 tests/Unit/Core/HelpersTest.php create mode 100644 tests/Unit/Core/Models/LanguageTest.php create mode 100644 tests/Unit/Core/Models/LanguageTokenTest.php create mode 100644 tests/Unit/Core/Models/SettingTest.php create mode 100644 tests/Unit/Core/Rules/CaptchaTest.php create mode 100644 tests/Unit/Core/Rules/PhoneTest.php create mode 100644 tests/Unit/Packages/Datatables/ColumnTest.php create mode 100644 tests/Unit/Packages/LangExtractor/LangExtractorTest.php create mode 100644 tests/Unit/Packages/Sidebar/ItemTest.php create mode 100644 tests/Unit/Packages/Toastify/ToastifyTest.php diff --git a/tests/Feature/Core/Middleware/EnsureDependenciesBuiltTest.php b/tests/Feature/Core/Middleware/EnsureDependenciesBuiltTest.php new file mode 100644 index 0000000..d2c8b77 --- /dev/null +++ b/tests/Feature/Core/Middleware/EnsureDependenciesBuiltTest.php @@ -0,0 +1,28 @@ + [ + $trackedFile => filemtime(base_path($trackedFile)), + ], + 'directories' => [], + ])); + + $response = (new EnsureDependenciesBuilt)->handle( + Request::create('/'), + fn () => new Response('next') + ); + + expect($response->getContent())->toBe('next'); + + File::delete($directory . '/lock.json'); +}); diff --git a/tests/Feature/Core/Middleware/LocalizationTest.php b/tests/Feature/Core/Middleware/LocalizationTest.php new file mode 100644 index 0000000..e01160d --- /dev/null +++ b/tests/Feature/Core/Middleware/LocalizationTest.php @@ -0,0 +1,37 @@ +get('/{locale}/localized-probe', fn () => response(app()->getLocale() . '|' . request()->route('locale', 'missing'))) + ->name('website.localized-probe'); + + $this->get('/ar/localized-probe') + ->assertOk() + ->assertSee('ar|missing'); + + expect(session('website_locale'))->toBe('ar') + ->and(app()->getLocale())->toBe('ar'); +}); + +it('redirects unsupported route locales to the fallback locale', function () { + Route::middleware(Localization::class) + ->get('/{locale}/fallback-probe', fn () => response('ok')) + ->name('website.fallback-probe'); + + $this->get('/fr/fallback-probe?foo=bar') + ->assertRedirect('/en/fallback-probe?foo=bar') + ->assertStatus(301); +}); + +it('lets the locale query string override the route locale', function () { + Route::middleware(Localization::class) + ->get('/{locale}/query-locale-probe', fn () => response('ok')) + ->name('website.query-locale-probe'); + + $this->get('/en/query-locale-probe?locale=ar') + ->assertRedirect('/ar/query-locale-probe?locale=ar') + ->assertStatus(301); +}); diff --git a/tests/Feature/Core/ServiceProviderTest.php b/tests/Feature/Core/ServiceProviderTest.php new file mode 100644 index 0000000..6a6a017 --- /dev/null +++ b/tests/Feature/Core/ServiceProviderTest.php @@ -0,0 +1,53 @@ +bound(Sidebar::class))->toBeTrue() + ->and(app()->make(Sidebar::class))->toBeInstanceOf(Sidebar::class) + ->and(app('sidebar'))->toBe(app()->make(Sidebar::class)) + ->and(app()->bound(Toastify::class))->toBeTrue() + ->and(app('toastify'))->toBeInstanceOf(Toastify::class); +}); + +it('merges package configuration', function () { + expect(config('redot.features.dashboard.enabled'))->toBeTrue() + ->and(config('redot.features.dashboard.prefix'))->toBe('dashboard') + ->and(config('redot.locales'))->toHaveCount(2) + ->and(config('datatables.assets'))->toBeArray() + ->and(config('toastify.defaults'))->toBeArray() + ->and(config('toastify.toastifiers.success'))->toBeArray(); +}); + +it('runs package migrations in the testbench database', function () { + foreach ([ + 'settings', + 'languages', + 'language_tokens', + 'login_tokens', + 'permissions', + 'roles', + 'model_has_permissions', + 'model_has_roles', + 'role_has_permissions', + ] as $table) { + expect(Schema::hasTable($table))->toBeTrue("Expected table [$table] to exist."); + } +}); + +it('registers package artisan commands', function () { + $commands = array_keys(Artisan::all()); + + expect($commands)->toContain( + 'uploads:clear', + 'permissions:sync', + 'lang:extract', + 'lang:sync', + 'lang:publish', + 'lang:revert', + 'make:datatable', + ); +}); diff --git a/tests/Unit/Core/Casts/UnionTest.php b/tests/Unit/Core/Casts/UnionTest.php new file mode 100644 index 0000000..3a101b7 --- /dev/null +++ b/tests/Unit/Core/Casts/UnionTest.php @@ -0,0 +1,28 @@ +get(new class extends Model {}, 'value', $stored, []))->toBe($expected); +})->with([ + ['true', true], + ['false', false], + ['15', 15], + ['{"primary":"blue"}', ['primary' => 'blue']], + ['["en","ar"]', ['en', 'ar']], + ['plain text', 'plain text'], +]); + +it('prepares booleans and arrays for storage', function (mixed $value, mixed $expected) { + $cast = new Union; + + expect($cast->set(new class extends Model {}, 'value', $value, []))->toBe($expected); +})->with([ + [true, 'true'], + [false, 'false'], + [['primary' => 'blue'], '{"primary":"blue"}'], + ['plain text', 'plain text'], +]); diff --git a/tests/Unit/Core/HelpersTest.php b/tests/Unit/Core/HelpersTest.php new file mode 100644 index 0000000..aad3904 --- /dev/null +++ b/tests/Unit/Core/HelpersTest.php @@ -0,0 +1,39 @@ +toBe(['alpha', 'beta', 'gamma']) + ->and(parse_csv([' one ', '', ' two ']))->toBe(['one', 'two']); +}); + +it('limits collections and appends an ellipsis item with remaining count', function () { + expect(collect_ellipsis(['a', 'b', 'c', 'd'], 2, ':count more')->all()) + ->toBe(['a', 'b', '2 more']); +}); + +it('returns consistent api exception payloads', function () { + $response = throw_api_exception(new AuthenticationException); + + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toMatchArray([ + 'code' => 401, + 'success' => false, + 'message' => 'Unauthenticated.', + 'payload' => [], + ]); +}); + +it('includes validation errors in api exception payloads', function () { + $exception = ValidationException::withMessages([ + 'email' => ['The email field is required.'], + ]); + + $response = throw_api_exception($exception); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['payload'])->toBe([ + 'email' => ['The email field is required.'], + ]); +}); diff --git a/tests/Unit/Core/Models/LanguageTest.php b/tests/Unit/Core/Models/LanguageTest.php new file mode 100644 index 0000000..74a05f4 --- /dev/null +++ b/tests/Unit/Core/Models/LanguageTest.php @@ -0,0 +1,24 @@ +getRouteKeyName())->toBe('code'); +}); + +it('exposes direction from the rtl flag', function () { + expect(Language::make(['is_rtl' => false])->direction)->toBe('ltr') + ->and(Language::make(['is_rtl' => true])->direction)->toBe('rtl'); +}); + +it('resolves the current language from the application locale', function () { + Language::create(['code' => 'en', 'name' => 'English', 'is_rtl' => false]); + Language::create(['code' => 'ar', 'name' => 'Arabic', 'is_rtl' => true]); + + app()->setLocale('ar'); + + expect(Language::current()) + ->toBeInstanceOf(Language::class) + ->code->toBe('ar') + ->direction->toBe('rtl'); +}); diff --git a/tests/Unit/Core/Models/LanguageTokenTest.php b/tests/Unit/Core/Models/LanguageTokenTest.php new file mode 100644 index 0000000..fd1167d --- /dev/null +++ b/tests/Unit/Core/Models/LanguageTokenTest.php @@ -0,0 +1,66 @@ + 'en', 'name' => 'English', 'is_rtl' => false]); + + $token = LanguageToken::create([ + 'language_id' => $language->id, + 'key' => 'Hello', + 'value' => 'Hello', + 'original_translation' => 'Hello', + 'from_json' => 1, + 'is_published' => 1, + ]); + + expect($token->from_json)->toBeTrue() + ->and($token->is_published)->toBeTrue() + ->and($token->language->is($language))->toBeTrue(); +}); + +it('marks published tokens as unpublished when their value changes', function () { + $language = Language::create(['code' => 'en', 'name' => 'English', 'is_rtl' => false]); + $token = LanguageToken::create([ + 'language_id' => $language->id, + 'key' => 'Hello', + 'value' => 'Hello', + 'original_translation' => 'Hello', + 'from_json' => true, + 'is_published' => true, + ]); + + $token->update(['value' => 'Hi']); + + expect($token->refresh()->is_published)->toBeFalse(); +}); + +it('scopes language tokens by state', function () { + $language = Language::create(['code' => 'en', 'name' => 'English', 'is_rtl' => false]); + + LanguageToken::create([ + 'language_id' => $language->id, + 'key' => 'Published', + 'value' => 'Published', + 'original_translation' => 'Published', + 'from_json' => true, + 'is_published' => true, + ]); + + LanguageToken::create([ + 'language_id' => $language->id, + 'key' => 'Modified', + 'value' => 'Changed', + 'original_translation' => 'Original', + 'from_json' => false, + 'is_published' => false, + ]); + + expect(LanguageToken::published()->count())->toBe(1) + ->and(LanguageToken::unpublished()->count())->toBe(1) + ->and(LanguageToken::modified()->count())->toBe(1) + ->and(LanguageToken::notModified()->count())->toBe(1) + ->and(LanguageToken::query()->fromJson()->count())->toBe(1) + ->and(LanguageToken::query()->notFromJson()->count())->toBe(1); +}); diff --git a/tests/Unit/Core/Models/SettingTest.php b/tests/Unit/Core/Models/SettingTest.php new file mode 100644 index 0000000..61e2bc4 --- /dev/null +++ b/tests/Unit/Core/Models/SettingTest.php @@ -0,0 +1,43 @@ +toHaveKey('app_name') + ->and(Setting::defaults())->toMatchArray([ + 'app_logo_dark' => 'assets/images/logo-dark.svg', + 'website_locales' => ['en', 'ar'], + 'service_worker_enabled' => true, + ]) + ->and(Setting::rules())->toHaveKey('app_name') + ->and(Setting::rules())->toHaveKey('app_name.*') + ->and(Setting::rules())->toHaveKey('website_locales'); +}); + +it('returns configured defaults including nested values', function () { + expect(Setting::default('theme.primary'))->toBe('blue') + ->and(Setting::default('app_name.en'))->toBe('Dashboard') + ->and(Setting::default('missing'))->toBeNull(); +}); + +it('persists and retrieves scalar boolean numeric and array values', function () { + Setting::set('page_loader_enabled', true); + Setting::set('items_per_page', 25); + Setting::set('theme', ['primary' => 'red', 'radius' => 2]); + + expect(Setting::get('page_loader_enabled', fresh: true))->toBeTrue() + ->and(Setting::get('items_per_page', fresh: true))->toBe(25) + ->and(Setting::get('theme', fresh: true))->toBe(['primary' => 'red', 'radius' => 2]) + ->and(Setting::get('theme.primary', fresh: true))->toBe('red') + ->and(Setting::get('theme.radius', fresh: true))->toBe(2); +}); + +it('invalidates cached settings when a setting changes', function () { + Setting::set('app_name', ['en' => 'Old']); + + expect(Setting::get('app_name.en', fresh: true))->toBe('Old'); + + Setting::where('key', 'app_name')->first()->update(['value' => ['en' => 'New']]); + + expect(Setting::get('app_name.en'))->toBe('New'); +}); diff --git a/tests/Unit/Core/Rules/CaptchaTest.php b/tests/Unit/Core/Rules/CaptchaTest.php new file mode 100644 index 0000000..d600872 --- /dev/null +++ b/tests/Unit/Core/Rules/CaptchaTest.php @@ -0,0 +1,30 @@ +passes('captcha', 'anything'))->toBeTrue(); +}); + +it('verifies captcha tokens against cloudflare in production', function () { + app()->detectEnvironment(fn () => 'production'); + Setting::set('cloudflare_turnstile_secret_key', 'secret'); + + Http::fake([ + 'https://challenges.cloudflare.com/turnstile/v0/siteverify' => Http::response(['success' => true]), + ]); + + expect((new Captcha)->passes('captcha', 'token'))->toBeTrue(); + + Http::assertSent(fn ($request) => $request->url() === 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + && $request['secret'] === 'secret' + && $request['response'] === 'token'); +}); + +it('fails captcha validation in production without a secret', function () { + app()->detectEnvironment(fn () => 'production'); + + expect((new Captcha)->passes('captcha', 'token'))->toBeFalse(); +}); diff --git a/tests/Unit/Core/Rules/PhoneTest.php b/tests/Unit/Core/Rules/PhoneTest.php new file mode 100644 index 0000000..4018bc0 --- /dev/null +++ b/tests/Unit/Core/Rules/PhoneTest.php @@ -0,0 +1,13 @@ +passes('phone', '01001234567'))->toBeTrue() + ->and((new Phone('US'))->passes('phone', '+14155552671'))->toBeTrue(); +}); + +it('rejects invalid phone numbers', function () { + expect((new Phone('EG'))->passes('phone', '123'))->toBeFalse() + ->and((new Phone('US'))->passes('phone', 'not-a-phone'))->toBeFalse(); +}); diff --git a/tests/Unit/Packages/Datatables/ColumnTest.php b/tests/Unit/Packages/Datatables/ColumnTest.php new file mode 100644 index 0000000..165f040 --- /dev/null +++ b/tests/Unit/Packages/Datatables/ColumnTest.php @@ -0,0 +1,42 @@ +width('10rem', '8rem', '12rem') + ->fixed(direction: 'end') + ->sortable() + ->searchable() + ->hidden() + ->exportable(false); + + expect($column->name)->toBe('author.name') + ->and($column->relationship)->toBeTrue() + ->and($column->label)->toBe('Author') + ->and($column->width)->toBe('10rem') + ->and($column->minWidth)->toBe('8rem') + ->and($column->maxWidth)->toBe('12rem') + ->and($column->fixed)->toBeTrue() + ->and($column->fixedDirection)->toBe('end') + ->and($column->sortable)->toBeTrue() + ->and($column->searchable)->toBeTrue() + ->and($column->visible)->toBeFalse() + ->and($column->exportable)->toBeFalse(); +}); + +it('escapes plain values and preserves html columns', function () { + $row = new class extends Model + { + protected $attributes = [ + 'name' => 'Taylor', + 'email' => 'taylor@example.com', + ]; + }; + + expect(Column::make('name')->get($row))->toBe('<strong>Taylor</strong>') + ->and(TextColumn::make('email')->email()->get($row)) + ->toBe('taylor@example.com'); +}); diff --git a/tests/Unit/Packages/LangExtractor/LangExtractorTest.php b/tests/Unit/Packages/LangExtractor/LangExtractorTest.php new file mode 100644 index 0000000..2e1a2fe --- /dev/null +++ b/tests/Unit/Packages/LangExtractor/LangExtractorTest.php @@ -0,0 +1,47 @@ +withExtensions('php', 'blade.php') + ->extract() + ->all(); + + expect($translations)->toHaveCount(3) + ->and($translations)->toMatchArray([ + 'Hello world' => 'Hello world', + 'Dashboard' => 'Dashboard', + 'Saved successfully' => 'Saved successfully', + ]); +}); + +it('merges extracted translations with arrays and existing json files', function () { + $directory = sys_get_temp_dir() . '/redot-lang-extractor-merge'; + $path = sys_get_temp_dir() . '/redot-translations.json'; + + File::deleteDirectory($directory); + File::ensureDirectoryExists($directory); + File::put($directory . '/view.php', " 'Existing value'])); + + $translations = (new LangExtractor([$directory], ['php'])) + ->extract() + ->mergeWithArray(['Array key' => 'Array value']) + ->mergeWithFile($path) + ->all(); + + expect($translations)->toBe([ + 'Array key' => 'Array value', + 'Existing key' => 'Existing value', + 'New key' => 'New key', + ]); +}); diff --git a/tests/Unit/Packages/Sidebar/ItemTest.php b/tests/Unit/Packages/Sidebar/ItemTest.php new file mode 100644 index 0000000..1400106 --- /dev/null +++ b/tests/Unit/Packages/Sidebar/ItemTest.php @@ -0,0 +1,43 @@ +title('Dashboard') + ->icon('layout-dashboard') + ->route('dashboard.index', ['locale' => 'en']) + ->url('/dashboard') + ->external(false) + ->hidden(false); + + expect($item->title)->toBe('Dashboard') + ->and($item->icon)->toBe('layout-dashboard') + ->and($item->route)->toBe('dashboard.index') + ->and($item->parameters)->toBe(['locale' => 'en']) + ->and($item->url)->toBe('/dashboard') + ->and($item->external)->toBeFalse() + ->and($item->isHidden())->toBeFalse(); +}); + +it('assigns parent items when children are configured', function () { + $parent = Item::make()->title('Content'); + $child = Item::make()->title('Pages'); + + $parent->children([$child]); + + expect($parent->children)->toHaveCount(1) + ->and($child->parent)->toBe($parent); +}); + +it('filters hidden sidebar items', function () { + $sidebar = Sidebar::make([ + Item::make()->title('Visible')->url('/visible'), + Item::make()->title('Hidden')->url('/hidden')->hidden(true), + ]); + + expect($sidebar->getItems()) + ->toHaveCount(1) + ->and($sidebar->getItems()[0]->title)->toBe('Visible'); +}); diff --git a/tests/Unit/Packages/Toastify/ToastifyTest.php b/tests/Unit/Packages/Toastify/ToastifyTest.php new file mode 100644 index 0000000..d0bd7b1 --- /dev/null +++ b/tests/Unit/Packages/Toastify/ToastifyTest.php @@ -0,0 +1,20 @@ +success('Saved', ['duration' => 1000]); + toastify()->error('Failed'); + + expect(session('toastify'))->toBe([ + ['message' => 'Saved', 'type' => 'success', 'options' => ['duration' => 1000]], + ['message' => 'Failed', 'type' => 'error', 'options' => []], + ]); +}); + +it('renders toastify css and js views', function () { + $toastify = app(Toastify::class); + + expect($toastify->css())->toContain('toastify') + ->and($toastify->js())->toContain('toastify'); +}); From 87b36c3646f12ccab7f0ecd94ea629b4932b6fd3 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:17:54 +0300 Subject: [PATCH 6/9] test: add unit and feature tests for authentication context, datatables actions, and filters --- AGENTS.md | 4 +- tests/Feature/Core/StaticIntegrityTest.php | 78 +++++++++++++++++++ tests/Fixtures/Auth/CapturingLoginRoutes.php | 16 ++++ .../Auth/VerifiableAuthContextUser.php | 34 ++++++++ tests/Fixtures/Core/EmptyModel.php | 10 +++ .../Datatables/DatatableActionRow.php | 15 ++++ .../Datatables/DatatableColumnRow.php | 13 ++++ .../Datatables/DatatableFilterFixture.php | 14 ++++ tests/Unit/Core/Casts/UnionTest.php | 6 +- tests/Unit/Packages/Auth/AuthContextTest.php | 56 +++++++++++++ .../Packages/Auth/RedotAuthManagerTest.php | 37 +++++++++ tests/Unit/Packages/Datatables/ActionTest.php | 50 ++++++++++++ tests/Unit/Packages/Datatables/ColumnTest.php | 10 +-- tests/Unit/Packages/Datatables/FilterTest.php | 66 ++++++++++++++++ 14 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/Core/StaticIntegrityTest.php create mode 100644 tests/Fixtures/Auth/CapturingLoginRoutes.php create mode 100644 tests/Fixtures/Auth/VerifiableAuthContextUser.php create mode 100644 tests/Fixtures/Core/EmptyModel.php create mode 100644 tests/Fixtures/Datatables/DatatableActionRow.php create mode 100644 tests/Fixtures/Datatables/DatatableColumnRow.php create mode 100644 tests/Fixtures/Datatables/DatatableFilterFixture.php create mode 100644 tests/Unit/Packages/Auth/AuthContextTest.php create mode 100644 tests/Unit/Packages/Auth/RedotAuthManagerTest.php create mode 100644 tests/Unit/Packages/Datatables/ActionTest.php create mode 100644 tests/Unit/Packages/Datatables/FilterTest.php diff --git a/AGENTS.md b/AGENTS.md index ce9d53e..2a9aa5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,13 @@ `src/` contains the package code, organized by Laravel-style domains such as `Commands/`, `Http/`, `Jobs/`, `Models/`, `Rules/`, and `Traits/`. In-repo subpackages live under `src/packages/` (`auth`, `datatables`, `lang-extractor`, `sidebar`, `toastify`) and are PSR-4 autoloaded from `composer.json`. Package configuration lives in `config/redot.php`, and generator templates in `stubs/`. Treat `storage/` as runtime output, not source. ## Build, Test, and Development Commands -Run `composer install` to install dependencies for PHP 8.3+ and Laravel 13+. Use `composer lint` or `vendor/bin/pint` to apply the repository formatting rules before pushing. This repository is a library, not a standalone app, so there is no local web server command here; validate behavior through the consuming Laravel application when changing service providers, middleware, or stubs. +Run `composer install` to install dependencies for PHP 8.3+ and Laravel 13+. Use `composer test` or `vendor/bin/pest` to run the Pest/Orchestra Testbench suite, and `composer lint` or `vendor/bin/pint` to apply the repository formatting rules before pushing. This repository is a library, not a standalone app, so there is no local web server command here; validate behavior through the consuming Laravel application when changing service providers, middleware, or stubs that are not covered by package tests. ## Coding Style & Naming Conventions Follow Laravel conventions and PSR-4 namespaces: classes use `StudlyCase`, methods and properties use `camelCase`, and config, view, and stub files use `snake_case` or dot-oriented Laravel naming where appropriate. Use 4-space indentation in PHP. Format with Laravel Pint using the preset from `pint.json`; keep concatenation spacing consistent and avoid manual style deviations that Pint will rewrite. ## Testing Guidelines -There is currently no committed `tests/` suite or PHPUnit/Pest configuration in this repository. At minimum, run `composer lint` and verify changes in a host Laravel app. If you introduce automated tests, place them under `tests/Feature/*Test.php` or `tests/Unit/*Test.php` and keep coverage focused on package behavior, especially commands, middleware, and service-provider bootstrapping. +The repository has a Pest suite backed by Orchestra Testbench. Keep root package tests under `tests/Feature/Core` or `tests/Unit/Core`, and bundled package tests under `tests/Unit/Packages/` or `tests/Feature/Packages/` when feature-level package tests are needed. Prefer package-level fixtures and Laravel fakes over host-app assumptions; compiled views and temp artifacts should stay outside repository `storage/`. Run `composer test` and `composer lint` before pushing, and still verify behavior in a host Laravel app when changing service providers, middleware, stubs, or workflows not covered by automated tests. ## Commit & Pull Request Guidelines Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, `refactor:`, and `chore:`. Keep commit subjects short and imperative, for example `fix: handle missing upload path`. Open PRs against `master`, include a concise description, note any config or migration impact, and list manual verification steps. Include screenshots only when changing rendered views or generated stubs. CI runs Pint on pull requests and may auto-commit formatting fixes, so lint locally first. diff --git a/tests/Feature/Core/StaticIntegrityTest.php b/tests/Feature/Core/StaticIntegrityTest.php new file mode 100644 index 0000000..0ee87b1 --- /dev/null +++ b/tests/Feature/Core/StaticIntegrityTest.php @@ -0,0 +1,78 @@ +map(fn (string $path): string => realpath($root . '/' . $path)) + ->filter() + ->all(); + $packagePaths = collect($prefixes) + ->reject(fn (string $path, string $namespace): bool => $namespace === 'Redot\\') + ->map(fn (string $path): string => realpath($root . '/' . $path)) + ->filter() + ->all(); + + foreach ($prefixes as $namespace => $path) { + foreach (File::allFiles($root . '/' . $path) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + if (in_array(realpath($file->getPathname()), $autoloadFiles, true)) { + continue; + } + + if ($namespace === 'Redot\\' && Str::startsWith($file->getPathname(), $root . '/src/packages')) { + continue; + } + + $relative = Str::of($file->getRelativePathname()) + ->replace(DIRECTORY_SEPARATOR, '\\') + ->replaceEnd('.php', ''); + + $class = $namespace . $relative; + + expect(class_exists($class) || interface_exists($class) || trait_exists($class)) + ->toBeTrue("Expected [$class] to autoload."); + } + } +}); + +it('keeps command classes concrete and named', function () { + foreach (File::allFiles(package_root() . '/src/Commands') as $file) { + $class = 'Redot\\Commands\\' . $file->getBasename('.php'); + $reflection = new ReflectionClass($class); + + expect($reflection->isSubclassOf(Command::class))->toBeTrue("$class must extend Command."); + + $instance = app($class); + $name = method_exists($instance, 'getName') ? $instance->getName() : null; + + expect($name)->not->toBeEmpty("$class must define a command name."); + } +}); + +it('can resolve package views referenced by service providers', function () { + foreach ([ + 'toastify::css', + 'toastify::js', + 'datatables::datatable', + 'datatables::filters.string', + 'datatables::partials.table', + 'datatables::pagination.default', + ] as $view) { + expect(View::exists($view))->toBeTrue("Expected view [$view] to exist."); + } +}); diff --git a/tests/Fixtures/Auth/CapturingLoginRoutes.php b/tests/Fixtures/Auth/CapturingLoginRoutes.php new file mode 100644 index 0000000..9c83c7b --- /dev/null +++ b/tests/Fixtures/Auth/CapturingLoginRoutes.php @@ -0,0 +1,16 @@ + 5, + 'active' => true, + ]; +} diff --git a/tests/Fixtures/Datatables/DatatableColumnRow.php b/tests/Fixtures/Datatables/DatatableColumnRow.php new file mode 100644 index 0000000..f353354 --- /dev/null +++ b/tests/Fixtures/Datatables/DatatableColumnRow.php @@ -0,0 +1,13 @@ + 'Taylor', + 'email' => 'taylor@example.com', + ]; +} diff --git a/tests/Fixtures/Datatables/DatatableFilterFixture.php b/tests/Fixtures/Datatables/DatatableFilterFixture.php new file mode 100644 index 0000000..79bf6e9 --- /dev/null +++ b/tests/Fixtures/Datatables/DatatableFilterFixture.php @@ -0,0 +1,14 @@ +get(new class extends Model {}, 'value', $stored, []))->toBe($expected); + expect($cast->get(new EmptyModel, 'value', $stored, []))->toBe($expected); })->with([ ['true', true], ['false', false], @@ -19,7 +19,7 @@ it('prepares booleans and arrays for storage', function (mixed $value, mixed $expected) { $cast = new Union; - expect($cast->set(new class extends Model {}, 'value', $value, []))->toBe($expected); + expect($cast->set(new EmptyModel, 'value', $value, []))->toBe($expected); })->with([ [true, 'true'], [false, 'false'], diff --git a/tests/Unit/Packages/Auth/AuthContextTest.php b/tests/Unit/Packages/Auth/AuthContextTest.php new file mode 100644 index 0000000..cdd50cb --- /dev/null +++ b/tests/Unit/Packages/Auth/AuthContextTest.php @@ -0,0 +1,56 @@ + 'home')->name('dashboard.index'); + Route::getRoutes()->refreshNameLookups(); + URL::setRoutes(Route::getRoutes()); + + $context = new AuthContext( + guard: 'admins', + provider: 'admins', + broker: 'admins', + model: VerifiableAuthContextUser::class, + scope: null, + api: false, + namePrefix: 'dashboard.', + views: ['unlock' => 'auth.unlock'], + home: 'dashboard.index', + identifiers: ['email', 'phone'], + ); + + expect($context->routeName('login'))->toBe('dashboard.login') + ->and($context->identifierInputName())->toBe('identifier') + ->and($context->guest())->toBe(['guest:admins']) + ->and($context->auth())->toBe([ + 'auth:admins', + Locked::class . ':admins,dashboard.unlock', + ]) + ->and($context->homeUrl())->toBe(route('dashboard.index')); +}); + +it('disables unsupported auth features from context configuration', function () { + $context = new AuthContext( + guard: 'web', + provider: 'users', + broker: 'users', + model: stdClass::class, + scope: null, + api: true, + namePrefix: '', + views: [], + home: 'home', + disable: ['register', 'magic-link'], + ); + + expect($context->featureEnabled('register'))->toBeFalse() + ->and($context->featureEnabled('magic-link'))->toBeFalse() + ->and($context->featureEnabled('email-verification'))->toBeFalse() + ->and($context->featureEnabled('lock-screen'))->toBeFalse() + ->and($context->auth())->toBe(['auth:web']); +}); diff --git a/tests/Unit/Packages/Auth/RedotAuthManagerTest.php b/tests/Unit/Packages/Auth/RedotAuthManagerTest.php new file mode 100644 index 0000000..61ea788 --- /dev/null +++ b/tests/Unit/Packages/Auth/RedotAuthManagerTest.php @@ -0,0 +1,37 @@ +set('auth.guards.admins', ['driver' => 'session', 'provider' => 'admins']); + config()->set('auth.providers.admins', ['driver' => 'eloquent', 'model' => VerifiableAuthContextUser::class]); + config()->set('auth.passwords.admins', ['provider' => 'admins']); + + Route::name('dashboard.')->group(function () { + app(RedotAuthManager::class)->routes( + guard: 'admins', + views: ['login' => 'auth.login'], + disable: ['register', 'password-reset', 'magic-link', 'email-verification', 'logout', 'lock-screen'], + registrars: ['login' => CapturingLoginRoutes::class], + home: 'dashboard.index', + ); + }); + + expect(CapturingLoginRoutes::$context)->toBeInstanceOf(AuthContext::class) + ->guard->toBe('admins') + ->provider->toBe('admins') + ->broker->toBe('admins') + ->api->toBeFalse() + ->namePrefix->toBe('dashboard.') + ->home->toBe('dashboard.index'); +}); + +it('rejects missing auth guard configuration', function () { + app(RedotAuthManager::class)->routes('missing'); +})->throws(InvalidArgumentException::class, 'Guard [missing] is not configured.'); diff --git a/tests/Unit/Packages/Datatables/ActionTest.php b/tests/Unit/Packages/Datatables/ActionTest.php new file mode 100644 index 0000000..0314363 --- /dev/null +++ b/tests/Unit/Packages/Datatables/ActionTest.php @@ -0,0 +1,50 @@ +href(fn (Model $row) => '/users/' . $row->getAttribute('id') . '/edit') + ->method('post') + ->body(['active' => fn (Model $row) => $row->getAttribute('active')]) + ->newTab() + ->fancybox() + ->confirmable(message: 'Continue?') + ->condition(fn (Model $row) => $row->getAttribute('active')); + + $attributes = $action->buildAttributes($row)->getAttributes(); + + expect($action->shouldRender($row))->toBeTrue() + ->and($attributes['href'])->toBe('/users/5/edit') + ->and($attributes['target'])->toBe('_blank') + ->and($attributes['data-fancybox'])->toBe('') + ->and($attributes['confirm'])->toBe('Continue?') + ->and($attributes['class'])->toContain('datatable-action'); +}); + +it('rejects invalid action methods and confirmable get actions', function () { + Action::make()->method('trace'); +})->throws(InvalidArgumentException::class, 'Invalid method provided "trace"'); + +it('requires confirmable actions to use non-get methods when attributes are built', function () { + Action::make('Delete')->confirmable()->buildAttributes(new EmptyModel); +})->throws(InvalidArgumentException::class, 'Confirmable actions must have a method other than "get".'); + +it('groups actions and renders only when a child action can render', function () { + $row = new DatatableActionRow(['id' => 7]); + + $visible = Action::make('Visible'); + $hidden = Action::make('Hidden')->hidden(); + $group = ActionGroup::make('More')->actions([$visible, $hidden]); + + expect($visible->grouped)->toBeTrue() + ->and($hidden->grouped)->toBeTrue() + ->and($group->shouldRender($row))->toBeTrue() + ->and(ActionGroup::make('Empty')->actions([$hidden])->shouldRender($row))->toBeFalse(); +}); diff --git a/tests/Unit/Packages/Datatables/ColumnTest.php b/tests/Unit/Packages/Datatables/ColumnTest.php index 165f040..2fe47ba 100644 --- a/tests/Unit/Packages/Datatables/ColumnTest.php +++ b/tests/Unit/Packages/Datatables/ColumnTest.php @@ -1,8 +1,8 @@ 'Taylor', - 'email' => 'taylor@example.com', - ]; - }; + $row = new DatatableColumnRow; expect(Column::make('name')->get($row))->toBe('<strong>Taylor</strong>') ->and(TextColumn::make('email')->email()->get($row)) diff --git a/tests/Unit/Packages/Datatables/FilterTest.php b/tests/Unit/Packages/Datatables/FilterTest.php new file mode 100644 index 0000000..863fad1 --- /dev/null +++ b/tests/Unit/Packages/Datatables/FilterTest.php @@ -0,0 +1,66 @@ +id(); + $table->string('name'); + $table->integer('score'); + $table->string('status'); + $table->boolean('active')->nullable(); + $table->date('published_on')->nullable(); + }); + + DatatableFilterFixture::insert([ + ['name' => 'Alpha', 'score' => 10, 'status' => 'draft', 'active' => true, 'published_on' => '2026-01-01'], + ['name' => 'Beta', 'score' => 20, 'status' => 'published', 'active' => false, 'published_on' => '2026-02-01'], + ['name' => 'Gamma', 'score' => 30, 'status' => 'published', 'active' => null, 'published_on' => '2026-03-01'], + ]); +}); + +it('applies string filters to query columns', function () { + $query = DatatableFilterFixture::query(); + + StringFilter::make('name')->apply($query, ['operator' => 'contains', 'value' => 'amm']); + + expect($query->pluck('name')->all())->toBe(['Gamma']); +}); + +it('applies number filters to query columns', function () { + $query = DatatableFilterFixture::query(); + + NumberFilter::make('score')->apply($query, ['operator' => 'greater_than_or_equals', 'value' => 20]); + + expect($query->pluck('name')->all())->toBe(['Beta', 'Gamma']); +}); + +it('applies select and ternary filters', function () { + $select = DatatableFilterFixture::query(); + SelectFilter::make('status')->apply($select, 'published'); + + $ternary = DatatableFilterFixture::query(); + TernaryFilter::make('active')->empty()->apply($ternary, 'empty'); + + expect($select->pluck('name')->all())->toBe(['Beta', 'Gamma']) + ->and($ternary->pluck('name')->all())->toBe(['Gamma']); +}); + +it('applies date filters with ranges', function () { + $query = DatatableFilterFixture::query(); + + DateFilter::make('published_on')->apply($query, [ + 'from' => '2026-01-15', + 'to' => '2026-02-15', + ]); + + expect($query->pluck('name')->all())->toBe(['Beta']); +}); From bdf0fb0711d32d6df28db051bcdfc4e421521010 Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 16:18:25 +0300 Subject: [PATCH 7/9] docs: add Test Coverage Improvement Plan outlining goals, current state, and completed tests for the `redot/core` Laravel package --- PLAN.md | 534 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..ab46025 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,534 @@ +# Test Coverage Improvement Plan + +This plan covers the `redot/core` Laravel package, including the root package code, bundled subpackages, stubs, configuration, migrations, views, and package bootstrapping behavior. The repository already contains Pest, Orchestra Testbench, `phpunit.xml`, and an empty `tests/Feature` / `tests/Unit` structure, so the first goal is to turn the existing harness into a reliable package test suite. + +## Goals + +- Prove the package boots cleanly inside Orchestra Testbench with Laravel 13, PHP 8.3, SQLite in memory, and all bundled service providers. +- Cover public behavior rather than implementation details, especially service providers, commands, middleware, routes, validation rules, model behavior, jobs, helpers, and package APIs. +- Add regression tests for all code paths that touch files, configuration, routes, permissions, localization, authentication flows, datatables, language extraction, sidebar composition, toast rendering, and generated stubs. +- Keep tests deterministic: no real network calls, no real external services, no host application assumptions, and no writes outside Testbench temp paths or repository-controlled fixtures. +- Make `composer test` and `composer lint` the standard local verification commands. + +## Current State + +- `composer.json` already includes `pestphp/pest`, `pestphp/pest-plugin-laravel`, `orchestra/testbench`, `mockery/mockery`, and scripts for `composer test` and `composer lint`. +- `phpunit.xml` defines `Feature` and `Unit` suites and uses a `testing` SQLite connection. +- `tests/TestCase.php` registers Livewire, Sanctum, Spatie Permission, and `RedotServiceProvider`, loads package migrations, and enables `RefreshDatabase`. +- Initial Pest coverage now exists under `tests/Feature/Core`, `tests/Unit/Core`, and `tests/Unit/Packages`. +- `tests/TestCase.php` writes compiled Blade views to `/tmp/redot-core-tests/views` so tests do not create runtime artifacts under repository `storage/`. +- `AGENTS.md` is stale on this point because it says there is no committed test suite; update it to reflect the Pest/Testbench suite. + +## Completed So Far + +- [x] Added harness smoke tests for Testbench boot, merged config, package migrations, container aliases, and registered artisan commands. +- [x] Added root package unit coverage for `Setting`, `Language`, `LanguageToken`, `Union`, selected helpers, `Phone`, and `Captcha`. +- [x] Added root package feature coverage for `Localization` and the non-rebuild path of `EnsureDependenciesBuilt`. +- [x] Added package unit coverage for datatable column basics, `LangExtractor`, sidebar item/sidebar basics, and toastify session/view behavior. +- [x] Added auth package coverage for `AuthContext` and `RedotAuthManager` context resolution/error handling. +- [x] Added datatables package coverage for actions, action groups, and core string/number/select/ternary/date filters. +- [x] Added static integrity coverage for PSR-4 autoloading, root command naming, and package view resolution. +- [x] Reorganized tests by package boundary under `Core/` and `Packages//`. +- [x] Added regression fixes discovered by tests: + - `Setting` now invalidates nested cache keys when parent settings change. + - `parse_csv()` now reindexes filtered values. + - `LangExtractor` now honors constructor extensions and works when no fallback language files exist. +- [x] Updated `AGENTS.md` so testing guidance reflects the committed Pest/Testbench suite. +- [x] Verified the current suite with `composer test` and `composer lint`. + +## Test Harness Work + +1. [x] Add smoke tests for the current harness. + - Assert Testbench boots with `RedotServiceProvider`. + - Assert package migrations run and create `settings`, `languages`, `language_tokens`, `login_tokens`, and permission tables. + - Assert config values from `config/redot.php` are merged. + - Assert container aliases such as `sidebar` resolve. + +2. [~] Add shared test helpers and fixtures. + - Create test models for traits and datatables under `tests/Fixtures`. + - Create temporary route files, Blade views, translation files, upload directories, and generated stub targets under Testbench paths. + - [x] Add inline fixtures for datatable filters and auth context coverage. + - [ ] Add reusable factories or builders for `LoginToken`, users, roles, and permissions. + - [x] Add HTTP fakes for captcha verification. + - [ ] Add reusable fakes for storage, queues, notifications, mail, cache, and events where behavior crosses Laravel boundaries. + +3. [~] Stabilize package path assumptions. + - Verify tests never depend on files that only exist in a consuming app unless the test creates fixture files first. + - [x] Use `/tmp/redot-core-tests/views` for compiled Blade views instead of repository `storage/`. + - [x] Use test-defined routes for localization and auth context coverage. + - [ ] Expand temp-directory coverage for uploads, generated files, and language publishing. + +## Priority Order + +1. Service provider bootstrapping, config, migrations, helpers, and validation rules. +2. Middleware and routing behavior because those can break consuming applications broadly. +3. Auth package flows and route registrars. +4. Datatables package APIs, filters, columns, serialization, rendering, and export adapters. +5. Commands, jobs, and filesystem-heavy behavior. +6. Lower-risk presentational helpers: sidebar and toastify. +7. Stub generation and static repository integrity checks. + +## Root Package Coverage + +### Service Provider + +- `RedotServiceProvider::register` + - [ ] Registers publishable config, stubs, and migrations with the expected tags. + - [x] Registers every artisan command listed in the provider. + - [x] Registers `Sidebar` as a singleton and aliases it as `sidebar`. + - [x] Registers auth, datatables, lang extractor, and toastify service providers. + +- `RedotServiceProvider::boot` + - Adds Redot metadata to `about`. + - Registers Blade directives and component namespaces. + - Sets default pagination view. + - Configures the `api` rate limiter by authenticated user ID or IP. + - Preserves empty strings on settings `PUT` requests while keeping Laravel's default null conversion elsewhere. + - Prohibits destructive commands only in production. + - Loads locales from the database when available and falls back to `config('redot.locales')`. + - [x] Registers `phone` and `captcha` validator extensions indirectly through rule coverage. + - Configures JSON cast encoding and decoding with unicode and slash preservation. + +### Application Bootstrap + +- `Redot\Application::configure` + - Loads website API, dashboard API, global, website, dashboard, and fallback route groups when enabled. + - Honors `redot.features.*.enabled` and dashboard prefix settings. + - Applies locale URL prefix only when `redot.routing.append_locale_to_url` is enabled. + - Registers web, dashboard, and API middleware in the expected order. + - Renders API exceptions as JSON for `expectsJson()` and `api/*` requests. + - Avoids JSON rendering for normal web requests. + +### Helpers + +- Cover every function in `src/helpers.php`. +- [~] Include happy paths, missing config/model data, null inputs, translated data, URL generation, asset hashing, API exception formatting, settings lookup, and fallback behavior. +- [x] Add regression tests for `parse_csv()` reindexing and API exception formatting. + +### Models And Casts + +- `Setting` + - [x] Defaults from `config('redot.settings')`. + - [x] Array and translated values. + - [x] Validation rule shape for dashboard settings. + - Missing setting fallback behavior. + +- `Language` + - [x] Locale code/name mapping. + - [x] RTL flag handling. + - Interactions with service provider locale bootstrapping. + +- `LanguageToken` + - [x] Fillable/cast behavior. + - Key uniqueness and locale values. + - Sync/extract/publish/revert job interactions. + +- `LoginToken` + - Token creation, expiry, consumption, and user relation behavior. + - Invalid, expired, and already-used token paths. + +- `Union` cast + - [x] Casts primitive and structured values correctly. + - [~] Handles null, arrays, JSON strings, invalid input, and model serialization. + +### Validation Rules + +- `Phone` + - [x] Valid international and local phone formats. + - [~] Invalid number, unsupported country, empty value, malformed value, and custom country parameters. + +- `Captcha` + - [x] Successful verification using HTTP fakes. + - [~] Failed verification, missing secret key, missing token, malformed API response, network exception, and disabled/empty configuration behavior. + +### Middleware + +- `Localization` + - [x] Sets locale from route parameter. + - [x] Falls back to default locale. + - [x] Rejects or redirects unsupported locales according to config. + - Handles RTL locale metadata. + - [x] Preserves intended URL/query string behavior. + +- `EnsureDependenciesBuilt` + - [x] Allows requests when dependencies are built. + - Blocks or redirects when required build artifacts are missing. + - Skips behavior for expected environments or paths if applicable. + +- `RoutePermission` + - Allows users with required permissions. + - Denies guests and users without permissions. + - Handles dashboard routes with missing route names. + - Works with Spatie Permission cache and guard names. + +### Controllers + +- `FallbackController` + - Redirects non-locale URLs when configured. + - Returns proper fallback responses for website, dashboard, API, and unknown routes. + - Preserves query strings and method expectations. + +### Traits + +- `CanUploadFile` + - Uploads valid files to the configured disk/path. + - Deletes replaced files. + - Handles nullable uploads, missing paths, invalid files, image optimization hooks, and cleanup on model deletion. + +- `Taggable` + - Stores and retrieves tags. + - Handles empty, duplicate, translated, and normalized tag input. + - Supports querying/filtering by tags if the trait exposes scopes. + +- `RespondAsApi` + - JSON success and error response shapes. + - Status codes, headers, validation errors, and exception payloads. + +- `UserAuditable` + - Sets created/updated/deleted user IDs when authenticated. + - Handles guest operations. + - Works with soft deletes if supported by the host model. + +## Commands Coverage + +Use Pest feature tests with Artisan, fake filesystem paths, and isolated fixtures. + +- `BuildDependenciesCommand` + - Builds expected dependency artifacts. + - Reports success and failure clearly. + - Handles missing package manager, failed process, and already-built state. + +- `ClearUploadsCommand` + - Removes only intended upload files. + - Preserves configured keep paths and unrelated storage files. + - Handles empty storage and missing directories. + +- `EntityMakeCommand` + - Generates the expected model/controller/request/resource/view/migration files. + - Handles existing files, force options, namespaces, pluralization, and invalid names. + +- `ExtractLanguageTokensCommand` + - Dispatches or runs extraction. + - Honors locale/config options. + - Handles no tokens, duplicate tokens, and invalid source paths. + +- `LintCommand` + - Invokes configured lint command. + - Reports non-zero exit codes. + - Does not require a real host app. + +- `ModelPopulateCommand` + - Populates model data from supported input. + - Handles invalid model classes, guarded attributes, casts, and relationships. + +- `PublicLinkCommand` + - Creates or reports public storage links. + - Handles existing links and unsupported filesystems. + +- `PublishLanguageTokensCommand` + - Publishes token files for all configured locales. + - Handles empty token sets and overwrite behavior. + +- `RevertLanguageTokensCommand` + - Reverts published tokens back to database values or source state. + - Handles missing published files. + +- `SyncLanguageTokensCommand` + - Syncs extracted tokens to the database. + - Handles create, update, delete, duplicate, and dry-run paths if available. + +- `SyncPermissionsCommand` + - Creates roles and permissions from route definitions. + - Handles removed routes, duplicate permissions, guard names, and Spatie cache resets. + +- `ViewMakeCommand` + - Generates views from `stubs/`. + - Handles dashboard, website, create, edit, show, index, datatable variants. + - Preserves existing files unless force is supplied. + +## Jobs Coverage + +- `ExtractLanguageTokens` + - Scans Blade/PHP/JS sources for translation tokens. + - Ignores vendor, storage, cache, and configured excluded paths. + - Handles nested directories, duplicate tokens, multiline calls, and malformed files. + +- `SyncLanguageTokens` + - Persists discovered tokens idempotently. + - Removes or marks stale tokens according to intended behavior. + - Preserves existing translations. + +- `PublishLanguageTokens` + - Writes language files for every configured locale. + - Creates missing directories. + - Uses deterministic ordering. + - Handles write failures. + +- `RevertLanguageTokens` + - Restores database values from published language files. + - Handles missing locale files, invalid PHP arrays, and partial locale data. + +## Auth Package Coverage + +### Service Provider And Manager + +- `RedotAuthServiceProvider` + - Registers config, routes, actions, middleware, facades, and bindings. + - Supports overriding action implementations through the container. + +- `RedotAuthManager` and `AuthContext` + - [x] Resolve guards, brokers, providers, routes, and context values. + - [~] Handle dashboard and website auth contexts separately. + - [x] Fail clearly for invalid contexts. + +### Actions + +- `Login` + - Valid credentials, invalid credentials, locked users, rate limiting, remember-me behavior, session regeneration, and intended redirect. + +- `Logout` + - Session logout, token logout if supported, session invalidation, CSRF regeneration, and redirect/JSON responses. + +- `Registration` + - User creation, validation, password hashing, events, auto-login, duplicate email, and disabled registration if configurable. + +- `PasswordReset` + - Send reset link, throttle, invalid email, valid reset, invalid token, expired token, password confirmation, and broker failures. + +- `EmailVerification` + - Send notification, signed verification URL, valid verification, invalid signature, already verified users, and throttling. + +- `MagicLink` + - Token generation, notification sending, valid login, invalid token, expired token, consumed token, and redirect behavior. + +- `Lock` + - Lock current session/user, unlock with valid credentials, reject invalid credentials, and respect middleware behavior. + +### Routes And Middleware + +- Route registrar classes for login, logout, registration, password reset, email verification, magic link, and lock routes. +- `Locked` middleware for locked and unlocked users, guest users, multiple guards, route exclusions, and redirects. +- `RateLimitsRequests` concern using Laravel rate limiter keys and decay settings. +- `QueriesUsers` concern for email, ID, provider, guard, and missing user behavior. + +## Datatables Package Coverage + +### Service Provider And Routes + +- `DatatablesServiceProvider` + - Publishes/merges config. + - Loads views, translations, assets, and routes. + - Registers commands and dependencies. + +- `routes/datatable.php` + - Search, filter, pagination, export, and action endpoints. + - Authorization, validation, missing resource, and invalid column responses. + +### Datatable Core + +- `Datatable` + - Resource definition, query building, column registration, action registration, filter registration, search, sorting, pagination, per-page limits, and serialization. + - Relationship loading and aggregate columns. + - Empty state and invalid configuration behavior. + +### Columns + +- `Column` + - [x] Label, key, sortable/searchable flags, visibility, export behavior, and HTML escaping. + +- `TextColumn`, `NumericColumn`, `DateColumn`, `ColorColumn`, `IconColumn`, `StatusColumn`, `TagsColumn`, `TernaryColumn` + - Formatting, null values, custom callbacks, localization, CSS classes, export values, and invalid values. + +### Filters + +- Base `Filter` + - [~] Attributes, labels, default values, query application, serialization, and reset behavior. + +- `StringFilter` + - [~] Contains, exact, starts-with, empty, and case behavior. + +- `NumberFilter` + - [~] Equals, range, min, max, invalid numeric input, and null handling. + +- `DateFilter` + - [~] Date equality, ranges, timezone boundaries, invalid dates, and null handling. + +- `SelectFilter` + - [~] Single and multiple selection, missing options, translated labels, and invalid values. + +- `TernaryFilter` + - [~] True, false, all, null, and custom query callbacks. + +- `TrashedFilter` + - With, only, and without trashed records on soft-deleting models. + +### Actions And Traits + +- `Action` and `ActionGroup` + - [x] Labels, icons, URLs, methods, confirmation metadata, visibility callbacks, grouping, and attribute building. + +- `BuildAttributes` + - HTML attribute merging, escaping, boolean attributes, class merging, and data attributes. + +- `InteractsWithRelations` + - Nested relation paths, missing relations, eager loading, sorting, searching, and null-safe traversal. + +- `Serializable` + - Array and JSON output stability for columns, filters, actions, and datatable state. + +### Views, Assets, And Exports + +- Blade views render without errors for: + - Main datatable view. + - Filters. + - Table, search, pagination, per-page selector, export, refresh, empty state, and actions. + - PDF default view. + +- PDF adapters: + - `DomPdf` and `LaravelMpdf` dependency-present and dependency-missing paths. + - Generated response headers, file names, and HTML input. + - `MissingDependencyException` behavior. + +- Command: + - `DatatableMakeCommand` generates expected datatable class from its stub, handles existing files, force, namespaces, and invalid names. + +## Lang Extractor Package Coverage + +- `LaravelLangExtractorServiceProvider` + - Registers command and any config/resources. + +- `LangExtractor` + - [x] Extracts translation keys from supported syntaxes. + - [~] Handles Blade, PHP, nested directories, ignored paths, duplicate keys, dynamic keys, multiline strings, escaped quotes, and empty files. + - [x] Produces deterministic merged output. + +- `LangExtractCommand` + - Runs extraction from CLI. + - Handles configured paths, output paths, locale selection, overwrite behavior, and invalid input. + +## Sidebar Package Coverage + +- `Sidebar` + - Adds, removes, sorts, groups, and retrieves items. + - Handles active state, permissions, visibility callbacks, nested items, URLs, route names, icons, badges, and serialization. + - Confirms singleton behavior through the container. + +- `Item` + - [~] Constructor defaults, fluent setters, child item handling, active matching, authorization callbacks, and array output. + +## Toastify Package Coverage + +- `LaravelToastifyServiceProvider` + - Merges/publishes config. + - Loads views and helpers. + +- `Toastify` + - [~] Queues success, error, warning, info, and custom toasts. + - [x] Stores messages in session. + - Handles options, durations, positions, duplicate messages, and clearing. + +- Helpers and views + - [x] Helper functions call the service correctly. + - [x] CSS and JS Blade views render with default and custom config. + +## Database And Migration Coverage + +- [x] Every migration runs on SQLite in memory. +- Tables have expected columns, indexes, nullable fields, defaults, and foreign keys where applicable. +- [x] Spatie permission tables are compatible with package permissions. +- [x] Migrations are idempotent under Testbench refresh. +- [~] Models can create, update, delete, and query records using the migrated schema. + +## Stubs And Generated Code Coverage + +- Validate all files under `stubs/` are syntactically usable after placeholder replacement: + - `dashboard.index.stub` + - `dashboard.index-datatable.stub` + - `dashboard.create.stub` + - `dashboard.edit.stub` + - `dashboard.show.stub` + - `website.page.stub` + +- For each generator command: + - Generate into a temp Testbench app path. + - Assert expected files exist. + - Assert generated PHP files pass syntax checks. + - Assert generated Blade files render with minimal fixture data when practical. + - Assert generated routes, view names, namespaces, and model references match Laravel conventions. + +## Static Integrity Tests + +- [x] Assert all classes in `composer.json` PSR-4 namespaces autoload. +- [~] Assert service providers referenced in code exist and can be instantiated. +- [x] Assert command classes extend Laravel command base classes and define signatures. +- [x] Assert views referenced by code exist. +- Assert config keys referenced by code exist in default config. +- Assert translation files contain the same keys for `en` and `ar`. +- Assert no source file writes to `storage/` or app paths during package boot. + +## External Dependency Strategy + +- Use Laravel fakes for mail, notifications, queue, events, storage, HTTP, and cache. +- Mock process execution for lint/build commands unless the test explicitly verifies integration behavior. +- Fake captcha verification HTTP responses. +- Avoid requiring DomPDF or mPDF packages unless testing dependency-missing paths; dependency-present adapter tests can be conditional or use lightweight mocks. +- Avoid running Node, npm, composer, or host application commands from tests. + +## Coverage Metrics + +- Phase 1 target: at least smoke coverage for every service provider, model, middleware, validation rule, helper file, and package facade/API. +- Phase 2 target: branch coverage for all commands, jobs, auth actions, datatable filters, datatable columns, and route registrars. +- Phase 3 target: regression tests for every bug fixed after this plan is adopted. +- Track coverage by package area rather than only global percentage because this repository contains multiple independent subpackages. + +## Current Test Layout + +```text +tests/ + Feature/ + Core/ + Middleware/ + ServiceProviderTest.php + Unit/ + Core/ + Casts/ + HelpersTest.php + Models/ + Rules/ + Packages/ + Datatables/ + LangExtractor/ + Sidebar/ + Toastify/ + Fixtures/ + Models/ + Datatables/ + Routes/ + Views/ + Files/ +``` + +## Execution Checklist + +1. [x] Run `composer install` if dependencies are missing. +2. [x] Add harness smoke tests and ensure `composer test` passes. +3. [ ] Add factories/fixtures needed by multiple areas. +4. [~] Implement root package tests. +5. [~] Implement auth package tests. +6. [~] Implement datatables package tests. +7. [~] Implement lang extractor, sidebar, and toastify tests. +8. [ ] Add command and stub-generation integration tests. +9. [x] Add static integrity tests. +10. [x] Run `composer test`. +11. [x] Run `composer lint`. +12. [x] Update `AGENTS.md` testing guidance to reflect the real Pest/Testbench suite once tests exist. + +## Done Criteria + +- `composer test` passes from a clean checkout. +- `composer lint` passes. +- New tests do not require a consuming Laravel app. +- Every public package surface listed above has at least one direct test. +- High-risk flows, including auth, localization, permissions, filesystem writes, language token publishing, and datatable query behavior, include happy-path and failure-path tests. +- Generated files and stubs are covered by syntax or render tests. +- The plan is revisited after each major feature addition or bug fix. From 93b9ab338fadb2138b4baca2ce59e1c5e76d0bdb Mon Sep 17 00:00:00 2001 From: Abdelrhman Said <70618755+AbdelrhmanSaid@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:27:08 +0300 Subject: [PATCH 8/9] Add GitHub Actions workflow for testing --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fb21962 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + tools: composer:v2 + coverage: xdebug + + - name: Install Dependencies + run: composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Tests + run: composer test From 0e825f7d5f38770bd50f82b8493ed6c9f9f3d94c Mon Sep 17 00:00:00 2001 From: Abdelrhman Said Date: Sat, 25 Apr 2026 21:36:09 +0300 Subject: [PATCH 9/9] refactor: remove LaravelLangExtractorServiceProvider and related command files --- src/RedotServiceProvider.php | 2 - .../src/Console/LangExtractCommand.php | 37 ------------------- .../LaravelLangExtractorServiceProvider.php | 26 ------------- 3 files changed, 65 deletions(-) delete mode 100644 src/packages/lang-extractor/src/Console/LangExtractCommand.php delete mode 100644 src/packages/lang-extractor/src/LaravelLangExtractorServiceProvider.php diff --git a/src/RedotServiceProvider.php b/src/RedotServiceProvider.php index dd6963e..fc7858d 100644 --- a/src/RedotServiceProvider.php +++ b/src/RedotServiceProvider.php @@ -32,7 +32,6 @@ use Redot\Commands\SyncPermissionsCommand; use Redot\Commands\ViewMakeCommand; use Redot\Datatables\DatatablesServiceProvider; -use Redot\LangExtractor\LaravelLangExtractorServiceProvider; use Redot\Models\Language; use Redot\Rules\Captcha; use Redot\Rules\Phone; @@ -67,7 +66,6 @@ public function register(): void $this->app->register(RedotAuthServiceProvider::class); $this->app->register(DatatablesServiceProvider::class); - $this->app->register(LaravelLangExtractorServiceProvider::class); $this->app->register(LaravelToastifyServiceProvider::class); } diff --git a/src/packages/lang-extractor/src/Console/LangExtractCommand.php b/src/packages/lang-extractor/src/Console/LangExtractCommand.php deleted file mode 100644 index 7e7be5b..0000000 --- a/src/packages/lang-extractor/src/Console/LangExtractCommand.php +++ /dev/null @@ -1,37 +0,0 @@ -argument('language'); - $path = lang_path($language . '.json'); - - $extractor = new LangExtractor; - $extractor->extract()->mergeWithFile($path)->save($path, true); - - $this->info('Translations saved to ' . str_replace(base_path(), '', $path)); - } -} diff --git a/src/packages/lang-extractor/src/LaravelLangExtractorServiceProvider.php b/src/packages/lang-extractor/src/LaravelLangExtractorServiceProvider.php deleted file mode 100644 index 565f5b2..0000000 --- a/src/packages/lang-extractor/src/LaravelLangExtractorServiceProvider.php +++ /dev/null @@ -1,26 +0,0 @@ -commands([ - Console\LangExtractCommand::class, - ]); - } - - /** - * Register the application services. - */ - public function register() - { - // ... - } -}